# Billing

7 endpoints.

## Read a workspace's billing summary

```http
GET https://orbitwrite.com/api/v1/billing
```

Plan details (any member) plus the Stripe customer email and recent invoices (owner/admin only — null/empty for plain members). Stripe read failures degrade to defaults rather than erroring.

**Authentication:** session cookie.

### Query parameters

- `organizationId` (string, required)

### Responses

#### 200

The workspace's billing summary

- `canManage` (boolean, required)
- `email` (string | null, required) — Stripe customer email — null for plain members
- `invoices` (BillingInvoice[], required) — Recent invoices — empty for plain members
  - `id` (string, required)
  - `number` (string | null, required)
  - `status` (string, required)
  - `amount` (number, required)
  - `currency` (string, required)
  - `created` (string | null, required)
  - `hostedUrl` (string | null, required)
  - `pdfUrl` (string | null, required)
- `planDetails` (BillingPlanDetails, required)
  - `plan` (string, required)
  - `price` (number | null, required)
  - `currency` (string | null, required)
  - `interval` (string | null, required)
  - `nextBillingDate` (string | null, required)

#### 400

Invalid request

- `error` (string, required)
- `issues` (any[], optional)

#### 401

Not signed in

- `error` (string, required)
- `issues` (any[], optional)

#### 403

Not a member of the organization

- `error` (string, required)
- `issues` (any[], optional)

#### 501

Billing is not configured (STRIPE_SECRET_KEY unset)

- `error` (string, required)
- `issues` (any[], optional)

## Update the workspace's billing email

```http
POST https://orbitwrite.com/api/v1/billing/email
```

Owner/admin only. Updates the Stripe customer's email.

**Authentication:** session cookie.

### Request body (`application/json`)

- `organizationId` (string, required)
- `email` (string, required)

### Responses

#### 200

The mutation was applied

- `ok` (boolean, required, one of `true`)

#### 400

Invalid request

- `error` (string, required)
- `issues` (any[], optional)

#### 401

Not signed in

- `error` (string, required)
- `issues` (any[], optional)

#### 403

Not a member of the organization

- `error` (string, required)
- `issues` (any[], optional)

#### 404

The resource does not exist

- `error` (string, required)
- `issues` (any[], optional)

#### 501

Billing is not configured (STRIPE_SECRET_KEY unset)

- `error` (string, required)
- `issues` (any[], optional)

#### 502

Stripe rejected or failed the customer update

- `error` (string, required)
- `issues` (any[], optional)

## Sync the local subscription row from Stripe

```http
POST https://orbitwrite.com/api/v1/billing/sync
```

Pulls the subscription's status/cancellation/period fields from Stripe into the local row. Best-effort: always returns ok, even when Stripe is unreachable or there is no subscription. Owner/admin only.

**Authentication:** session cookie.

### Request body (`application/json`)

- `organizationId` (string, required)

### Responses

#### 200

The mutation was applied

- `ok` (boolean, required, one of `true`)

#### 400

Invalid request

- `error` (string, required)
- `issues` (any[], optional)

#### 401

Not signed in

- `error` (string, required)
- `issues` (any[], optional)

#### 403

Not a member of the organization

- `error` (string, required)
- `issues` (any[], optional)

#### 501

Billing is not configured (STRIPE_SECRET_KEY unset)

- `error` (string, required)
- `issues` (any[], optional)

## Plan limits, features and current usage

```http
GET https://orbitwrite.com/api/v1/entitlements
```

What the workspace's plan allows and how much of each allowance it has spent this period.

Use `remaining` to pace a batch: a null value is unlimited, and `xPosts` is the monthly X allowance and `xLinkPosts` the much smaller one for posts containing a link; `xPostsPerDay` is null on every listed plan. `storageBytes` is a running total rather than a per-period count.

A quota with a rate in `overage` doesn't block at zero remaining — going over is allowed and billed at that rate. On plans with `overage.channels` set, `remaining.channels` is the remaining *included* channels before per-channel billing starts, not a cap.

Authenticates with a workspace API key or a session cookie.

**Authentication:** bearer token or session cookie.

### Query parameters

- `organizationId` (string, optional) — Session callers only — an API key already determines the workspace.

### Responses

#### 200

The workspace's entitlements

- `plan` (string, required) — Plan name. Never null — a workspace with no plan resolves to `none`, whose limits are all closed.
- `source` (string, required, one of `subscription`, `grant`, `none`) — Where the plan comes from: a paid or trialing `subscription`; a `grant`, a plan held with no one billing it (workspaces that were on the retired free plan were moved onto Standard this way); or `none`.
- `status` (string | null, required) — Stripe subscription status, or null when the plan is a grant or there is none.
- `trialEndsAt` (string | null, required) — When the free trial ends, while the subscription is `trialing`. Null otherwise.
- `unrestricted` (boolean, required) — Billing is not enforced for this deployment (dev / self-hosted): every limit is open and every feature on.
- `period` (object, required)
  - `key` (string, required)
  - `start` (string, required)
  - `end` (string, required)
- `features` (string[], required) — Gated capabilities this plan unlocks.
- `limits` (PlanLimits, required)
  - `members` (integer | null, required)
  - `includedChannels` (integer | null, required)
  - `channelsPerPlatform` (integer | null, required)
  - `postsPerMonth` (integer | null, required)
  - `xPostsPerDay` (integer | null, required)
  - `xPostsPerMonth` (integer | null, required)
  - `xLinkPostsPerMonth` (integer | null, required)
  - `aiAssistsPerMonth` (integer | null, required)
  - `autoEngagementPerMonth` (integer | null, required)
  - `storageBytes` (integer | null, required)
  - `analyticsHistoryDays` (integer | null, required)
- `usage` (PlanUsage, required)
  - `posts` (integer, required)
  - `xPosts` (integer, required)
  - `xPostsToday` (integer, required)
  - `xLinkPosts` (integer, required)
  - `aiAssists` (integer, required)
  - `autoEngagement` (integer, required)
  - `storageBytes` (integer, required)
  - `members` (integer, required)
  - `channels` (integer, required)
- `remaining` (PlanRemaining, required)
  - `members` (integer | null, required)
  - `channels` (integer | null, required)
  - `posts` (integer | null, required)
  - `xPostsPerDay` (integer | null, required)
  - `xPosts` (integer | null, required)
  - `xLinkPosts` (integer | null, required)
  - `aiAssists` (integer | null, required)
  - `autoEngagement` (integer | null, required)
  - `storageBytes` (integer | null, required)
- `issue` (BillingIssue | null, required) — Set when the workspace has a subscription that isn't entitling — a failed payment, an unfinished checkout, or a cancellation. Null on a workspace that simply never subscribed. `plan` is `none` either way; this is what distinguishes 'never chose a plan' from 'card expired'.
  - `kind` (string, required, one of `payment_failed`, `incomplete`, `canceled`)
  - `plan` (string, required)
  - `status` (string, required)
  - `periodEnd` (string | null, required)
- `paused` (object, required) — Members and channels past the plan's limits. Nothing over a limit is removed: a paused member can read but not create, schedule, publish or reply; a paused channel stays connected and its posts are held until it is active again. Choose which stay active with `POST /seats/activate`.
  - `memberIds` (string[], required)
  - `channelIds` (string[], required)
- `overage` (object, required) — Price in USD cents for exceeding a quota. Null means the quota blocks instead of billing — which is the case for every quota on Standard.
  - `xPosts` (integer | null, required)
  - `xLinkPosts` (integer | null, required)
  - `channels` (integer | null, required) — Monthly (not per-unit) price for each channel above `limits.includedChannels`. When set, the included count is a billing threshold, not a cap — connecting more is allowed and billed.

#### 400

Invalid request

- `error` (string, required)
- `issues` (any[], optional)

#### 401

Not signed in

- `error` (string, required)
- `issues` (any[], optional)

#### 403

Not a member of the organization

- `error` (string, required)
- `issues` (any[], optional)

## Keep a member or channel active

```http
POST https://orbitwrite.com/api/v1/seats/activate
```

When the plan covers fewer members or channels than the workspace has, the rest are paused, never removed. This moves one member (`memberId`, needs `member:update`) or channel (`accountId`, needs `account:update`) to the front of the queue for a seat; the most recently activated wins. It changes nothing while the workspace has room. Read the result from `paused` on `GET /entitlements`.

Authenticates with a workspace API key or a session cookie.

**Authentication:** bearer token or session cookie.

### Query parameters

- `organizationId` (string, optional) — Session callers only — an API key already determines the workspace.

### Request body (`application/json`)

### Responses

#### 204

Recorded

#### 400

Invalid request

- `error` (string, required)
- `issues` (any[], optional)

#### 401

Not signed in

- `error` (string, required)
- `issues` (any[], optional)

#### 403

Not a member of the organization

- `error` (string, required)
- `issues` (any[], optional)

#### 404

The resource does not exist

- `error` (string, required)
- `issues` (any[], optional)

## Look at a plan invite before accepting it

```http
POST https://orbitwrite.com/api/v1/plan-invites/preview
```

What the invite offers and which of the caller's own workspaces it can be put on. Changes nothing. An unknown, expired, revoked or used-up link is a 404, whichever it was.

**Authentication:** session cookie.

### Request body (`application/json`)

- `token` (string, required)

### Responses

#### 200

The offer, and where it can go

- `plan` (string, required)
- `expiresAt` (string, required) — When the link stops working. ISO 8601.
- `redemption` (object | any | null, required) — The caller's own earlier acceptance of this invite. `held` means it is waiting for them to have a workspace.
- `workspaces` (object[], required)
  - `id` (string, required)
  - `name` (string, required)
  - `slug` (string, required)
  - `eligible` (boolean, required) — False when the workspace already holds this plan or a higher one.
  - `subscribed` (boolean, required) — The workspace has a subscription, which outranks a granted plan: accepting here takes effect only if that subscription ends.

#### 400

Invalid request

- `error` (string, required)
- `issues` (any[], optional)

#### 401

Not signed in

- `error` (string, required)
- `issues` (any[], optional)

#### 403

Not a member of the organization

- `error` (string, required)
- `issues` (any[], optional)

#### 404

The resource does not exist

- `error` (string, required)
- `issues` (any[], optional)

#### 429

Rate limit exceeded

- `error` (string, required)
- `issues` (any[], optional)

## Accept a plan invite

```http
POST https://orbitwrite.com/api/v1/plan-invites/redeem
```

Puts the invite's plan on `organizationId`, a workspace the caller owns. A caller who owns no workspace omits it: the acceptance is held and applied to the first workspace they create. Accepting twice spends one redemption.

**Authentication:** session cookie.

### Request body (`application/json`)

- `token` (string, required)
- `organizationId` (string | null, optional)

### Responses

#### 200

The invite was accepted

- `plan` (string, required)
- `status` (string, required, one of `applied`, `held`)
- `organizationId` (string | null, required)

#### 400

Invalid request

- `error` (string, required)
- `issues` (any[], optional)

#### 401

Not signed in

- `error` (string, required)
- `issues` (any[], optional)

#### 403

Not a member of the organization

- `error` (string, required)
- `issues` (any[], optional)

#### 404

The resource does not exist

- `error` (string, required)
- `issues` (any[], optional)

#### 409

Conflicts with an existing resource

- `error` (string, required)
- `issues` (any[], optional)

#### 429

Rate limit exceeded

- `error` (string, required)
- `issues` (any[], optional)
