A Connect account can belong to more than one workspace, and your application sees exactly one of them at a time - the one the session token is pinned to.

This module is how a user moves between them, and how you render a team.

### Everything here comes from the session

None of these endpoints take a `userId` or a `workspaceId` to read from. They take the signed-in user's `sessionToken` and resolve both from it, which is why they are safe to call from a browser through the SDK bridge: there is no parameter to tamper with, and therefore no form of "list someone else's team".

### Listing

```ts
const { workspaces } = await connect.workspaces.list({ asUser: sessionToken });
```

Each row carries three facts your switcher needs *before* the user picks:

| Field | Why it is there |
|---|---|
| `isActive` | Which one this session is on now |
| `inAudience` | Whether your application is sold to that kind of workspace |
| `subscribed` | Whether it holds an entitling subscription to you |

Render an out-of-audience or unsubscribed workspace **disabled with the reason**, not hidden. A workspace that silently disappears from the list reads as data loss; one that is visible and explained reads as a thing to go fix. `switch` enforces the same two facts, so your label and the outcome cannot disagree.

### Switching is a new session, not an edit

```ts
const next = await connect.workspaces.switch(
  { workspaceId: "ws_other" },
  { asUser: sessionToken },
);
// Store next.sessionToken exactly as you stored the one from /sso/token.
// The old token is revoked.
```

It has to be. `sessionToken` is pinned to a workspace at mint time, and that pinning is what every downstream check reads - the data store's tenant path, RBAC resolution, entitlement gates. So a switch re-runs sign-in's own authorization for the target workspace (active membership, audience, MFA if that workspace requires it) and issues a fresh credential.

Persist the new token before navigating. If you drop it, the user is signed out - the old one no longer works.

Switches are written to the audit trail. Changing which tenant a session can reach is the same class of event as the sign-in that preceded it.

### Team

```ts
const { members } = await connect.workspaces.members({ asUser: sessionToken });
```

Rows carry **resolved permissions for your application**, not raw Connect roles. You get `granted: ["pulse:campaign:manage"]` and an inferred `role`, not `["admin"]` - so you never reimplement Connect's role resolution, and you learn nothing about the customer's Connect account that your application has no business knowing.

### What this module deliberately does not do

There is no create-workspace and no invite-member endpoint. Both are Connect's own surfaces, and both involve billing identity, seat counts and compliance that an application has no way to reason about. Link users to Connect instead - `<ConnectWorkspaceSwitcher>` does this for you, and `identity.manageWorkspaceUrl()` gives you the URL if you are building your own.

## Endpoints

### GET `/api/v1/application/workspaces`

List the workspaces the signed-in user belongs to

**Scope** `workspaces:read` &nbsp;·&nbsp; **Rate limit** 120 requests / min per key

What a workspace switcher renders.

Takes no `userId` and no `workspaceId` - both come from `X-Connect-Session-Token`, which is why this is safe to reach from a browser through the SDK bridge and why there is no form of it that lists someone else's memberships.

Each row carries three facts your switcher needs *before* the user picks: `isActive`, `inAudience` (is your application sold to that kind of workspace) and `subscribed`. Render the last two as disabled-with-a-reason rather than hiding the row - a workspace that silently vanishes reads as data loss.

#### Errors
`unauthenticated` (401 - no session token), `forbidden` (403 - key missing `workspaces:read`, or the session is revoked/expired).

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | The user's workspaces |
| `401` | unauthenticated - no session token |
| `403` | forbidden |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `workspaces` | `array` | yes |  |

### POST `/api/v1/application/workspaces`

Open another workspace under the caller's own tenant

**Scope** `workspaces:create` &nbsp;·&nbsp; **Rate limit** 120 requests / min per key

A branch office, a second business unit - the write half of a workspace switcher's "Create workspace" row.

Takes no `tenantId` and no `workspaceId`, same as every other endpoint under `/workspaces*`: both come from `X-Connect-Session-Token`, and the tenant is the one the signed-in user already owns.

**Only the tenant's owner may call this successfully.** Someone who merely belongs to a colleague's tenant is refused with a message naming that - a personal account is the way to a workspace of your own.

**The tenant's plan decides whether it may hold another workspace at all**, and that is not pre-checked - there is no capacity read on this module. A refusal arrives as a `409` with a message written to be shown ("Your current plan covers a single workspace - subscribe to a plan that includes additional workspaces to open another.", or "You've used all 3 of the workspaces your plans include. Upgrade a plan or delete a workspace to open another."). `type: "business"` still needs a non-free email domain on the caller's account, same rule `workspace.createWorkspace` has always enforced.

**This does not switch the session.** The response is the new workspace in the same row shape `GET /workspaces` already returns, with `isActive` and `subscribed` both `false` - chain `POST /workspaces/switch` to move into it, guarded on `inAudience` the way a switcher's own disabled-row logic already is.

#### Errors
`forbidden` (403 - the caller does not own the tenant they belong to, or a platform admin/manager account tried to create one), `conflict` (409 - the tenant's plan does not allow another workspace, or the business-email-domain rule refused `type: "business"`).

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `name` | `string` | yes |  |
| `type` | `individual` \| `business` | yes |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | The new workspace |
| `400` | validation_error |
| `401` | unauthenticated - no session token |
| `403` | forbidden - not the tenant's owner, or a platform admin/manager account |
| `409` | conflict - the tenant's plan does not allow another workspace, or the business-email-domain rule refused |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `workspace` | `object` | yes |  |

### POST `/api/v1/application/workspaces/switch`

Move this session to another workspace

**Scope** `workspaces:switch` &nbsp;·&nbsp; **Rate limit** 120 requests / min per key

Returns a **new** `sessionToken`, pinned to the target workspace. The presented one is revoked.

It cannot work any other way: a session token is pinned to a workspace at mint time, and that pinning is what every downstream check reads - the data store's tenant path, RBAC resolution, entitlement gates. So a switch re-runs sign-in's own authorization against the target workspace:

- the user must be an **active member** of it
- it must be **in your audience**, when your application enforces that at sign-in
- **MFA must already be enrolled**, if that workspace requires it

Passing the workspace the session is already on is a `403` - a no-op that silently returned a fresh token would be a quiet way to extend a session forever.

**Persist the new token before navigating.** Drop it and the user is signed out; the old one no longer works.

Switches are written to the audit trail - changing which tenant a session can reach is the same class of event as the sign-in before it.

#### Errors
`not_found` (404 - no such workspace, or the user is not a member; deliberately indistinguishable), `forbidden` (403 - out of audience, MFA not enrolled, or already on that workspace).

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | `string` | yes |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | A new session token for the target workspace |
| `400` | validation_error |
| `403` | forbidden - audience, MFA, or already active |
| `404` | not_found - no such workspace, or not a member |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `sessionToken` | `string` | yes |  |
| `sessionTokenExpiresIn` | `integer` | yes |  |
| `workspaceId` | `string` | yes |  |
| `workspaceName` | `string` | yes |  |
| `workspaceType` | `individual` \| `business` | yes |  |

### GET `/api/v1/application/workspaces/billing-profile`

Read the billing identity of the workspace this session is on

**Scope** `workspaces:read` &nbsp;·&nbsp; **Rate limit** 120 requests / min per key

The legal name, GSTIN, registered address and entity form the customer gave CosmoOps so we can invoice them.

**This is a prefill source, not a shared record.** It is the *Bill To* side - who we invoice - and your application's own letterhead is the *Bill From* side. They are the same three facts for a single-entity customer and the reason this endpoint exists is so a tax id is typed once instead of twice; they are **not** the same field, and a group that bills us through a holding company while quoting under a trading subsidiary has to stay expressible. So copy it into your own settings on first setup and keep your copy authoritative. There is deliberately no write form: an application must not edit the details its own invoices are raised against.

**Nothing here is verified.** It is self-declared by the customer in their billing settings, not the KYB record - no PAN check stands behind it. Treat it as a convenience, never as evidence of identity.

Every field is nullable, because every one is optional to a customer who has not needed an invoice yet - fall back to the workspace name you already hold on the session. `razorpayCustomerId` is deliberately not returned.

#### Two gates
`workspaces:read` on your key says your application may ask. **`billing:read` on the acting member** says the person you are asking for may - a tax identity belongs to whoever can already see it in Connect's billing screens, which is an owner, admin or manager. It cannot be granted through a role on your application.

#### Errors
`unauthenticated` (401 - no session token), `forbidden` (403 - the member has no `billing:read`), `not_found` (404).

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | The workspace's self-declared billing identity |
| `401` | unauthenticated - no session token |
| `403` | forbidden - the member has no billing:read |
| `404` | not_found |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `billingProfile` | `object` | yes |  |

### GET `/api/v1/application/workspaces/profile`

Read a subscribing workspace's identity

**Scope** `workspace-profile:read` &nbsp;·&nbsp; **Rate limit** 120 requests / min per key

A workspace's name, legal name, account type and its owner's display name and email - from your own server, with nobody signed in. What a voice agent greets a caller with, and where a notification to the owner goes.

```bash
curl "https://connect.cosmoops.com/api/v1/application/workspaces/profile?workspaceId=WORKSPACE_ID" \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT"
```

Unlike the rest of `/workspaces*`, this takes no session token and refuses one: those answer "my team" for a signed-in member, this answers "the business that installed us". The workspace must subscribe to your application. `legalName` is the self-declared billing name and is `null` until the customer has needed an invoice. Cache it - a name changes about never.

#### Errors
`validation_error` (400 - no `workspaceId`), `forbidden` (403 - key missing `workspace-profile:read`, or your application is not installed there), `rate_limited` (429).

**Parameters**

| Name | In | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | query | yes |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | The workspace profile |
| `400` | validation_error |
| `403` | forbidden |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | `string` | yes |  |
| `name` | `string` | yes |  |
| `legalName` | `any` | yes |  |
| `type` | `individual` \| `business` | yes |  |
| `status` | `active` \| `suspended` \| `pending_deletion` \| `deleted` | yes |  |
| `owner` | `object` | yes |  |

### GET `/api/v1/application/workspaces/members`

List the team of the workspace this session is on

**Scope** `workspaces:read` &nbsp;·&nbsp; **Rate limit** 120 requests / min per key

The workspace comes from the session, so this answers "my team" and has no form that answers another tenant's.

Each row carries **two ids, and they are not interchangeable**. `userId` identifies the person across this API; `memberId` is what the calendar speaks in - `Booking.hostMemberIds` and `GET /scheduling/bookings`' `hostMemberId` filter both take that one. Join a diary to this roster on `memberId`.

Rows carry **permissions already resolved for your application** - `granted: ["pulse:campaign:manage"]` and an inferred `role` - not raw Connect roles like `admin`. That means you never reimplement Connect's role resolution, and you learn nothing about the customer's Connect account that your application has no business knowing.

Capped at 200 active members in one read. This is a team page, not a directory.

#### Errors
`unauthenticated` (401 - no session token), `forbidden` (403).

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | Active members with resolved permissions |
| `401` | unauthenticated - no session token |
| `403` | forbidden |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `members` | `array` | yes |  |

### GET `/api/v1/application/workspaces/members/access`

Seats, what the caller may do with them, and pending invitations

**Scope** `members:manage` &nbsp;·&nbsp; **Rate limit** 120 requests / min per key

Everything needed to draw your own team screen's controls, in one read.

`capacity` is the **workspace's** member allowance, not your plan's: a baseline plus `allowances.members` from every active subscription the workspace holds, folded across applications. So a seat bought through another application is a seat you may fill. `used` counts active members **and pending invitations** - an invitation is a seat already spoken for, and without counting it a five-seat workspace would send fifty and refuse forty-five at the moment each person clicked accept.

Two refusal reasons, and they need different words on screen:

- `no_allowance` - nothing the workspace subscribes to grants member seats. The answer is a plan.
- `exhausted` - every seat is taken. The answer is an upgrade, or removing somebody.

`viewer` is the **signed-in caller's** Connect permissions, not yours: render Invite and Change-role from it rather than showing buttons that 403.

This is advisory. The authoritative check runs inside the invite itself, which is where two people inviting the last seat at once is actually settled.

Answers only a caller who may invite - see `POST /workspaces/members/invite` for who that is. A caller whose invite right comes only from a role on your application sees only the invitations that carry a role on your application. `viewer.canManageAccess` follows the same rule for `connect:member:update`.

#### Errors
`unauthenticated` (401 - no session token), `forbidden` (403 - the key lacks `members:manage`, or the caller may not invite).

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | Capacity, the caller's abilities, and pending invitations |
| `401` | unauthenticated - no session token |
| `403` | forbidden - scope or caller permission |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `capacity` | `object` | yes |  |
| `viewer` | `object` | yes |  |
| `invitations` | `array` | yes |  |

### POST `/api/v1/application/workspaces/members/invite`

Invite someone into the workspace with access to your application

**Scope** `members:manage` &nbsp;·&nbsp; **Rate limit** 120 requests / min per key

Sends Connect's own invitation email and returns the `inviteUrl` regardless, so you can offer copy/share when delivery fails. The invitation lasts seven days.

`roleKey` names a role from **your** application's catalog, or the reserved `admin`; omit it to invite someone into the workspace with no access to your application. You cannot send a workspace-wide Connect role: the grant array is built by Connect as `[member, app:<yourKey>:<roleKey>]` and the request has no way to express anything else.

Two gates beyond the key's scope, and both are about the person rather than your application:

- the signed-in caller must be able to invite: Connect's `member:invite` on the workspace (workspace admins and managers), **or** a role on your application carrying the Connect permission `connect:member:invite` ("Invite members", ticked on the role in the console; your Admin role always has it). Someone inviting on the second must pass a `roleKey`: a seat with no role on your application is workspace membership, which your application cannot hand out. The SSO session's `permissions` lists `connect:member:invite` when a role on your application carries it; a workspace admin or manager may invite without it, and `GET /workspaces/members/access` answers for both
- they cannot invite someone with access they do not hold themselves - the same ceiling Connect applies to a role change

The seat is spent when the invitation is created, not when it is accepted. Withdraw one with `POST /workspaces/invitations/revoke` to get it back.

#### Errors
`validation_error` (400 - not an email address), `forbidden` (403 - caller may not invite, sent no `roleKey` while inviting on an application role, or is granting past their own access), `conflict` (409 - no member seats included, or all of them used; the message is written to be shown).

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `inviteeEmail` | `email` | yes |  |
| `roleKey` | `any` | no |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | The invitation, and whether its email went out |
| `400` | validation_error |
| `401` | unauthenticated - no session token |
| `403` | forbidden - caller permission or grant ceiling |
| `409` | conflict - no seat available |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `invitationId` | `string` | yes |  |
| `inviteeEmail` | `string` | yes |  |
| `expiresAt` | `string` | yes |  |
| `inviteUrl` | `string` | yes |  |
| `emailSent` | `boolean` | yes |  |

### POST `/api/v1/application/workspaces/members/access`

Grant or revoke your application's role for a member

**Scope** `members:manage` &nbsp;·&nbsp; **Rate limit** 120 requests / min per key

Moves **only your application's grant**. Connect computes the member's next roles as *what they have now, minus any grant naming your application, plus the one you asked for* - so this cannot touch a workspace-wide role, another application's access, or remove them from the workspace. Pass `roleKey: null` to revoke.

It runs through the same path the Connect portal uses, so everything that makes a role change safe still applies: the plan's per-application seat cap, per-seat billing reconciliation, and eviction of that member's cached permissions - a revoked role stops working within seconds rather than at the end of a session.

The signed-in caller must hold Connect's `member:update` - from their workspace role, or as `connect:member:update` ("Change members' access") on a role on your application - and cannot grant access they do not hold themselves. On the second, they also cannot change the access of a member who holds anything on your application that they do not.

Re-sending the role a member already has is a no-op, not an error - safe for a UI that submits whatever is on screen.

#### Errors
`validation_error` (400), `not_found` (404 - no such member in this workspace), `forbidden` (403 - caller permission or grant ceiling), `conflict` (409 - the plan's seats for your application are full, or the role is not one you offer).

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `userId` | `string` | yes |  |
| `roleKey` | `any` | yes |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | The member's role and resolved permissions after the change |
| `400` | validation_error |
| `401` | unauthenticated - no session token |
| `403` | forbidden - caller permission or grant ceiling |
| `404` | not_found - no such member |
| `409` | conflict - seat cap, or unknown role key |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `userId` | `string` | yes |  |
| `roleKey` | `any` | yes |  |
| `granted` | `array` | yes |  |

### POST `/api/v1/application/workspaces/invitations/revoke`

Withdraw a pending invitation and free its seat

**Scope** `members:manage` &nbsp;·&nbsp; **Rate limit** 120 requests / min per key

The counterpart to inviting. An invitation sent to a mistyped address holds a member seat for its full seven days, so an application that can spend a seat and not release one can leave a customer paying for a seat nobody can use.

Only an invitation **your** application sent - one carrying a grant for another application is a `404`, indistinguishable from one that does not exist.

#### Errors
`validation_error` (400), `not_found` (404 - no such invitation, or not yours), `conflict` (409 - already accepted, expired or revoked).

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `invitationId` | `string` | yes |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | Revoked |
| `400` | validation_error |
| `401` | unauthenticated - no session token |
| `403` | forbidden - scope or caller permission |
| `404` | not_found - no such invitation, or not yours |
| `409` | conflict - not pending |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `revoked` | `boolean` | yes |  |