Two endpoints, and the difference between them is the whole model.

- `GET /rbac/users/{userId}` - **one member's resolved permissions** for this application. Connect has already flattened their role, the implicit `admin` synthesis and any workspace-scoped custom role into one list.
- `GET /roles` - **the catalog itself**: the roles and permission ids you declared, not anybody's result.

You call the first on requests; you call the second when rendering a role picker.

```ts
const { granted } = await connect.access.getUserRoles(userId, { workspaceId });
if (!granted.includes("pulse_email:campaign:manage")) {
  return new Response("Forbidden", { status: 403 });
}
```

**Connect never enforces these.** It resolves them and hands them over; your backend is the only thing standing between a user and the action. Hiding a button is a courtesy, the check behind it is the control.

Permission ids are namespaced by your application key (`<key>:<resource>:<action>`) and are yours to define - see the Authorization section of your application in the Console.

## Endpoints

### GET `/api/v1/application/rbac/users/{userId}/roles`

Re-check a member's role for this application

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

On-demand counterpart of the SSO token exchange's `role`/`permissions` fields (see `/sso/token` above) - the same resolution, callable anytime instead of only at login.

#### When to use it
- Your own session outlives Connect's, so the login-time `role`/`permissions` snapshot has gone stale.
- A platform admin changed this member's role mid-session and you want to react immediately rather than waiting for their next sign-in.

#### Implementation

**1. Prerequisite** - an API key scoped to `rbac:read`.

**2. Send the request** with the Connect `userId` (from `id_token`'s `sub` claim) and the workspace:
```bash
curl "https://connect.cosmoops.com/api/v1/application/rbac/users/USER_ID/roles?workspaceId=WORKSPACE_ID" \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT"
```

**3. Use `role`/`granted`** exactly like `role`/`permissions` at login - `granted` lists this member's permission ids from your Permission Catalog; `role` is `"admin"`, the key of one of your defined roles, or `null` if their granted set doesn't exactly match any single defined role.

#### Errors
`validation_error` (400 - missing `workspaceId`), `forbidden` (403 - key missing `rbac:read`), `rate_limited` (429 - generously limited since this is read-heavy by design, a partner may re-check on every one of its own requests).

**Parameters**

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

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | Resolved role |
| `400` | validation_error - missing workspaceId |
| `403` | forbidden - key missing rbac:read |
| `429` | rate_limited |

**Response body**

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

### GET `/api/v1/application/roles`

List this application's role catalog

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

Lists this application's own grantable-role *definitions* - its Permission Catalog, plus every role that grants a subset of it: a synthesized `"admin"` entry (always full catalog access) and every role you've defined in the Integration tab. This is what `rbac:read` resolves a specific member against, not a specific member's result - use `rbac:read` above for that.

#### Implementation

**1. Prerequisite** - an API key scoped to `roles:read`.

**2. Send the request:**
```bash
curl "https://connect.cosmoops.com/api/v1/application/roles" \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT"
```

**3. Read `permissionCatalog`/`roles`** - `roles` always includes `"admin"` first; `permissionCatalog` is empty until at least one permission is defined in the Integration tab.

#### Errors
`forbidden` (403 - key missing `roles:read`), `rate_limited` (429 - same read-heavy generous limit as `rbac:read`/`subscription:read`).

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | Role catalog |
| `403` | forbidden - key missing roles:read |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `applicationId` | `string` | yes |  |
| `applicationKey` | `any` | yes |  |
| `permissionCatalog` | `array` | yes |  |
| `roles` | `array` | yes |  |

### POST `/api/v1/application/oauth/token`

Get a token to call another application

**Scope** `applications:call` &nbsp;·&nbsp; **Rate limit** 300 requests / min per API key

Ask Connect for a short-lived token (a JWT, five minutes) to call **another application's API** for a workspace, then send it to that application as `Authorization: Bearer`. The provider verifies it locally against `/.well-known/jwks.json` - Connect is on the token path, never the data path.

Authenticate with your API key as `Authorization: Bearer`, exactly like every other endpoint here. The body is RFC 6749 form encoding (`application/x-www-form-urlencoded`; JSON is accepted too).

#### Two grants

| `grant_type` | Use | Token `sub` |
|---|---|---|
| `client_credentials` + `workspace_id` + `resource` | Background work - syncs, crons, webhooks | your application |
| `urn:ietf:params:oauth:grant-type:token-exchange` + `subject_token` (a member's SSO session token, issued to **you**) + `resource` | A member pressed something that needs the other app's data | the member, with `act.sub` = your application |

`resource` is the provider's audience, exactly as it publishes it. `scope` is optional and space-separated; omitted, you get every scope you are entitled to.

#### What you get is bounded four ways

The scopes you declared you consume (Console-reviewed) ∩ what the workspace granted on its connections screen ∩ what the provider exposes - and the workspace must subscribe to **both** applications. A delegated token additionally requires every scope to be delegable and the member to be allowed into both applications.

#### Cache it

Reuse a token until about a minute before `expires_in` runs out. `connect.applications.client(...)` in the SDK does this for you, single-flight.

#### Wire format

RFC 6749 throughout, not this API's usual envelope: success is §5.1 snake_case (`access_token`, `token_type`, `expires_in`, `scope`) with `Cache-Control: no-store`, and a refusal is §5.2's flat `{ "error", "error_description" }`. A bad or under-scoped API key is `invalid_client`. A 429 keeps the shared envelope and its `Retry-After`.

#### Errors
`invalid_request` (400 - malformed body or unknown `grant_type`), `invalid_client` (401/403 - key missing, unknown, revoked, or without `applications:call`), `invalid_target` (`resource` names no provider), `invalid_scope` (asked beyond what you are entitled to), `access_denied` (no active connection, or a subscription is not active), `invalid_grant` (the `subject_token` is unknown, expired, or not issued to you), `unauthorized_client`.

**Request body**

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "oneOf": [
    {
      "type": "object",
      "properties": {
        "grant_type": {
          "type": "string",
          "const": "client_credentials"
        },
        "workspace_id": {
          "type": "string",
          "minLength": 1,
          "maxLength": 128
        },
        "resource": {
          "type": "string",
          "minLength": 1,
          "maxLength": 300
        },
        "scope": {
          "type": "string",
          "maxLength": 2000
        }
      },
      "required": [
        "grant_type",
        "workspace_id",
        "resource"
      ]
    },
    {
      "type": "object",
      "properties": {
        "grant_type": {
          "type": "string",
          "const": "urn:ietf:params:oauth:grant-type:token-exchange"
        },
        "subject_token": {
          "type": "string",
          "minLength": 1,
          "maxLength": 500
        },
        "subject_token_type": {
          "type": "string",
          "const": "urn:ietf:params:oauth:token-type:access_token"
        },
        "resource": {
          "type": "string",
          "minLength": 1,
          "maxLength": 300
        },
        "scope": {
          "type": "string",
          "maxLength": 2000
        }
      },
      "required": [
        "grant_type",
        "subject_token",
        "subject_token_type",
        "resource"
      ]
    }
  ]
}
```

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | A token for the provider |
| `400` | invalid_request / invalid_target / invalid_scope / invalid_grant |
| `401` | invalid_client - no usable API key |
| `403` | invalid_client / access_denied / unauthorized_client |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `access_token` | `string` | yes |  |
| `token_type` | `string` | yes |  |
| `expires_in` | `integer` | yes |  |
| `scope` | `string` | yes |  |
| `issued_token_type` | `string` | no |  |

### POST `/api/v1/application/events/publish`

Publish an event to connected applications

**Scope** `events:publish` &nbsp;·&nbsp; **Rate limit** 300 requests / min per API key

Publish one of the events your application **declared** (Console > Integration > Exposed API) for a workspace. Connect delivers it to every application that workspace connected to you with the event's required scope, and that subscribed its webhook endpoint to `application.event` - signed like every other Connect webhook, retried, and logged.

Not the analytics feed: `POST /events` counts your product events, this one delivers business events to other applications.

#### Idempotency

Send `idempotencyKey` in the body or an `Idempotency-Key` header (if you send both, they must match). A repeat within 24 hours answers `202` with `deduplicated: true` and delivers nothing new, so retrying a timeout is always safe. The event `id` is derived from the key, so a retry names the same event and consumers dedupe on it.

#### `202` means queued

Acceptance means the fan-out is queued, not that any consumer has the event. You never name the recipients - only the workspace's connections decide who receives it.

#### Errors
`validation_error` (400 - malformed body, an undeclared `type`, or two different idempotency keys), `forbidden` (403 - key unknown, missing `events:publish`, or that workspace does not subscribe to your application), `rate_limited` (429).

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | `string` | yes |  |
| `type` | `string` | yes |  |
| `data` | `object` | yes |  |
| `idempotencyKey` | `string` | yes |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `202` | Accepted for delivery, or a deduplicated repeat |
| `400` | validation_error |
| `403` | forbidden |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `deduplicated` | `boolean` | yes |  |

### GET `/api/v1/application/connections`

List a workspace's connections to your application

**Scope** `applications:call` &nbsp;·&nbsp; **Rate limit** 300 requests / min per API key

Every connection in the named workspace that your application is a party to - as the **consumer** (you call the other application) or the **provider** (it calls you). This is what your settings page reads to say "Connected", and what a background job reads to decide whether it can ask for a token.

`scopes` is the **effective** set: what the workspace granted, still declared by the consumer and still exposed by the provider - exactly what a token can carry today. A revoked connection is listed with `status: "revoked"` and no scopes, so a page can say "Disconnected" rather than "never connected".

`pendingScopes` is what a reconnect would add: the scopes the consumer declares and the provider exposes *now*, minus what the workspace granted. It is non-empty when the consumer has widened its declaration since the grant - the connection still works at the old scope, and the page should say "Needs reconnecting" and link the admin to `/apps/connect?consumer=&provider=&mutual=1`. Always empty for a revoked connection and when the grant is up to date.

Each connection is **one direction** (consumer → provider). Two applications that call each other hold two records; a settings page that shows one "Connect" for the pair should read both, and treat the pair as connected only when every direction it needs is active with no `pendingScopes`.

Connections between two *other* applications in the same workspace are never listed.

#### Errors
`validation_error` (400 - `workspaceId` missing), `forbidden` (403 - key unknown or missing `applications:call`), `rate_limited` (429).

**Parameters**

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

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | The caller's connections in that workspace, active first |
| `400` | validation_error |
| `403` | forbidden |
| `429` | rate_limited |

**Response body**

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

### GET `/api/v1/application/connections/providers`

List the applications you consume, with their audiences

**Scope** `applications:call` &nbsp;·&nbsp; **Rate limit** 300 requests / min per API key (shared with GET /connections)

Every application your application declares it **consumes** (Console > Integration > Connections), with the audience a token for it is minted against - the value `resource` must carry on `POST /oauth/token`. It lets you address a provider by its key instead of keeping its URL in your own configuration; `@cosmoops/connect-sdk`'s Products relay resolves `products` this way.

Only your own declarations are listed - an application you do not declare is never named, so this cannot be used to discover another application's audience. `audience` is `null` for a declaration with nothing behind it: the provider no longer exists or exposes no API.

It says what you **could** call, not whether a workspace connected the pair - the token endpoint decides that per workspace on every issuance. The answer changes only when someone edits an application in Console, so cache it for minutes.

#### Errors
`forbidden` (403 - key unknown or missing `applications:call`), `rate_limited` (429).

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | Every application you declare you consume, in declaration order |
| `403` | forbidden |
| `429` | rate_limited |

**Response body**

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