Two questions, two endpoints - plus a third the SDK derives from the second rather than a call of its own:

- **"May this workspace be here at all?"** - `GET /onboarding/workspaces/{workspaceId}`. Returns `ready` plus typed `blockers`, each with a `code`, a `severity`, and a `resolveUrl` into the Connect screen that fixes it. Route a blocked user there rather than refusing them with nowhere to go.
- **"What are they paying for?"** - `GET /subscriptions/workspaces/{workspaceId}`. Returns the live plan, including `plan.features` - the array your backend gates plan-restricted modules on - and `plan.limits`, the numeric ceilings behind the next question.

Branch on `blockers[].code`, never on `message`. Treat an unrecognised code as blocking: the list grows, and a new gate should fail closed.

```ts
const readiness = await connect.entitlements.getReadiness(workspaceId);
if (!readiness.ready) return showBlocked(blockingBlockers(readiness)[0]);

if (await connect.entitlements.hasFeature(workspaceId, "campaign-module")) { /* ... */ }
```

- **"How much of a metered resource may they have?"** - `connect.entitlements.getLimit(workspaceId, key)`, the numeric counterpart of `hasFeature` for a resource your own application counts (sites, projects, pipelines). It makes no separate request - it reads the same subscription response above and folds in whatever add-ons raise the plan's own number, so you never join the plan against the add-on list yourself. `canAdd` is the comparison against a count you already have:

```ts
if (!(await connect.entitlements.canAdd(workspaceId, "sites", sites.length))) {
  return refuse("Your plan's site limit is reached.");
}
```

## Endpoints

### GET `/api/v1/application/subscriptions/workspaces/{workspaceId}`

Re-check a workspace's plan & subscription

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

On-demand counterpart of the SSO token exchange's `subscription` field - same "re-check without another sign-in" shape as `rbac:read` above, for billing instead of roles.

#### Implementation

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

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

**3. Branch on `hasActiveSubscription`** - a ready-to-use boolean so you don't need to know the full subscription status enum just to gate access. `subscription`/`plan` are both `null` if the workspace never subscribed - not an error.

**4. Gate a plan-restricted feature** yourself off `plan.features`, e.g. `plan.features.includes("campaign-module")`. Connect only stores which feature keys a plan unlocks; it never enforces them against your requests, so this check has to live in your own backend.

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

**Parameters**

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

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | Subscription + plan detail |
| `403` | forbidden - key missing subscription:read |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | `string` | yes |  |
| `applicationId` | `string` | yes |  |
| `hasActiveSubscription` | `boolean` | yes |  |
| `subscription` | `any` | yes |  |
| `plan` | `any` | yes |  |

### GET `/api/v1/application/subscriptions`

List the workspaces subscribed to your application

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

The inverse of the per-workspace read above: that one answers about a workspace you already know, this one tells you who your subscribers *are*. It is what a partner dashboard, an MRR total or a churn report is built on.

#### Implementation

**1. Prerequisite** - an API key scoped to `subscription:read` (the same scope as the per-workspace read).

**2. Send the request:**
```bash
curl "https://connect.cosmoops.com/api/v1/application/subscriptions?status=active&limit=50" \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT"
```

**3. Page with `nextBefore`.** Each response carries a cursor; pass it back as `before` for the next page. Stop when it is `null` - that is the only reliable end-of-list signal, since a full page can still be the last one. The SDK's `entitlements.iterateSubscribers()` does this for you.

**4. Your application is resolved from the API key**, so there is no `applicationId` parameter and no way to ask about anyone else's subscribers.

**Not a hot path.** This is rate-limited to 10/min per key against the per-workspace read's 60, because it walks your whole subscriber base. Gate a request on `/subscriptions/workspaces/{workspaceId}`; use this one to build reports.

#### Errors
`validation_error` (400 - unknown `status`, unparseable `before`, `limit` outside 1..100), `forbidden` (403 - key missing `subscription:read`), `rate_limited` (429).

**Parameters**

| Name | In | Required | Notes |
| --- | --- | --- | --- |
| `status` | query | no |  |
| `before` | query | no |  |
| `limit` | query | no |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | One page of subscribers |
| `400` | validation_error |
| `403` | forbidden - key missing subscription:read |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `items` | `array` | yes |  |
| `nextBefore` | `any` | yes |  |

### GET `/api/v1/application/onboarding/workspaces/{workspaceId}`

Check whether a workspace is cleared to use your application

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

Every gate Connect owns between a workspace and your application, resolved in one call: subscription state, KYC/KYB verification, a chargeable payment method, and seat headroom. Broader than `subscription:read`, which answers only the billing half and can't see the compliance or payment-method gates.

#### Implementation

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

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

**3. Gate on `ready`** - a single boolean, true when nothing `blocking` remains. Prefer it over inspecting `blockers` yourself: when Connect adds a new gate, `ready` accounts for it without any change on your side.

**4. Show `blockers` when `ready` is false.** Each carries a `message` safe to display as-is and, where one exists, a `resolveUrl` pointing at the Connect screen that actually fixes it - send the user there rather than dead-ending them. Treat `code` as a growing set: handle the ones you care about and fall back to `message` for the rest, so a newly added code never breaks your integration.

**5. Note `severity`** - `blocking` entries are why `ready` is false; `advisory` ones (e.g. no saved payment method while a subscription is still active) are worth warning about but don't stop anyone working today.

A workspace that doesn't exist comes back as a normal not-ready response carrying `workspace_not_found`, not a 404 - so this endpoint can't be used to probe which workspace ids are real.

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

**Parameters**

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

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | Readiness verdict + blockers |
| `403` | forbidden - key missing onboarding:read |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | `string` | yes |  |
| `applicationId` | `string` | yes |  |
| `ready` | `boolean` | yes |  |
| `readiness` | `object` | yes |  |

### POST `/api/v1/application/usage`

Report usage against your plan limits

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

Tells Connect how many of your own limited resources a workspace currently has, so the customer's Usage page shows "640 of 1,000 AI actions" instead of a bare ceiling. The limits are the ones you declared in your application's limit catalog and valued per plan (`Plan.limits`) - the same numbers `GET /subscriptions/workspaces/{workspaceId}` hands you and `entitlements.getLimit()` reads.

#### Implementation

**1. Prerequisite** - an API key scoped to `usage:report`, a limit catalog with at least one key, and a workspace subscribed to your application.

**2. Send the current count, not a change.** Each value is a gauge: the absolute number the workspace has right now - exactly what you already pass to `entitlements.canAdd(workspaceId, key, currentCount)`. Sending it twice is harmless and a lost report is healed by the next one, so there is no `Idempotency-Key` here. Report after anything that changes a count (create, delete, import), or on a schedule - whichever you already have.
```bash
curl -X POST https://connect.cosmoops.com/api/v1/application/usage \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT" \
  -H "Content-Type: application/json" \
  -d '{"workspaceId":"WORKSPACE_ID","counts":{"records":1840,"ai_actions":640}}'
```

**3. Keys you leave out keep their last value.** Report one dimension without erasing another. The response carries every count on record for that workspace, each with `reportedAt` - the customer is shown how old it is.

**Connect enforces nothing on these numbers.** Your code still refuses the next resource against its live count; this only lets the customer see where they stand without opening your product.

#### Errors
`validation_error` (400 - a key not in your limit catalog, named in the message; an empty `counts`; a cancelled subscription), `forbidden` (403 - key missing `usage:report`), `not_found` (404 - the workspace has no subscription to your application), `rate_limited` (429).

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | `uuid` | yes |  |
| `counts` | `object` | yes |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | Every count on record for this workspace |
| `400` | validation_error - unknown limit key, empty counts, or cancelled subscription |
| `403` | forbidden - key missing usage:report |
| `404` | not_found - no subscription to this application |
| `429` | rate_limited |

**Response body**

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

### GET `/api/v1/application/apps`

List the apps a workspace can open, and the ones it could

**Scope** `apps:read` &nbsp;·&nbsp; **Rate limit** 30 requests / min per IP, 60 requests / min per API key

Every active application on the platform, split by `subscribed` - the data behind a cross-application launcher grid.

`subscribed: true` is what this workspace can open right now. A `paused` subscription (a payment retrying) is still one of them, flagged `needsAttention` rather than hidden - a dunning cycle should not make the product look deleted. Your own application is included and flagged `isCurrent`, so the grid can render it as the active tile.

`subscribed: false` is the rest of the catalogue, minus anything this workspace's type is outside the audience of. Send those to `{connect}/apps/{applicationId}`, where a plan is chosen - not to `launchUrl`.

**This changed in 0.20.0.** It used to answer with subscriptions alone; filter on `subscribed` to get that set back.

`<ConnectAppsGrid>` in `@cosmoops/connect-sdk/client` renders this for you.

**Parameters**

| Name | In | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | query | yes | The workspace to list apps for. |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | Launchable apps for this workspace |
| `400` | validation_error - workspaceId missing |
| `403` | forbidden - key unknown or missing the apps:read scope. key_revoked / key_expired if the key itself is dead |

**Response body**

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