# Posts

34 endpoints.

## Upcoming scheduled times per connected account

```http
GET https://orbitwrite.com/api/v1/posts/scheduled-times
```

Every future `scheduled_at` in the workspace, one row per account, nearest first. Thread roots only — sibling items in a thread chain off the root rather than holding a slot of their own. Plain drafts park a far-future sentinel `scheduled_at` and so fall outside the horizon; a rejected draft that still carries a real date is reported, matching what the calendar shows as occupying its slot. Used to skip slots that already hold a post when picking the next one.

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

### Query parameters

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

### Responses

#### 200

Upcoming scheduled times, nearest first

- `items` (ScheduledPostTime[], required)
  - `accountId` (string, required)
  - `scheduledAt` (string, required) — ISO 8601 instant the post is due to fire.
  - `groupId` (string | null, required) — The post (composer group) holding the slot, so a caller moving that post can leave its own slots out.

#### 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)

## List an account's most recent published posts

```http
GET https://orbitwrite.com/api/v1/posts/recent-by-account
```

Published posts for one connected account, newest first — the imported real-profile history that backs the composer's Instagram grid preview. Scoped to the organization and account; returns at most 24.

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

### Query parameters

- `organizationId` (string, required)
- `accountId` (string, required)
- `limit` (integer, optional)

### Responses

#### 200

The account's published posts, newest first

- `items` (RecentPostItem[], required)
  - `id` (string, required)
  - `text` (string, required)
  - `permalink` (string | null, required) — The post's URL on the platform, when known.
  - `publishedAt` (string | null, required) — ISO 8601 publish time, or null if the row never recorded one.
  - `media` (RecentPostMedia[], required)
    - `url` (string, required)
    - `mimeType` (string, required)
    - `width` (number | null, required)
    - `height` (number | null, required)
    - `altText` (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)

#### 429

Rate limit exceeded

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

## Search posts by title or content

```http
GET https://orbitwrite.com/api/v1/posts/search
```

Case-insensitive substring match against the post text (any thread position) and its composer group's title, scoped to the organization. LIKE metacharacters in the query match literally. Powers the command menu's post search. Returns at most 20 composer groups, newest first.

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

### Query parameters

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

### Responses

#### 200

Matching posts, newest first

- `items` (SearchPostItem[], required)
  - `id` (string, required) — Composer group id, or the post id for an ungrouped post — matches the `?open=` deep link param.
  - `title` (string | null, required)
  - `text` (string, required)
  - `status` (string, required, one of `draft`, `planned`, `pending_approval`, `scheduled`, `publishing`, `published`)
  - `approvalStatus` (string | null, required) — The post's own publish gate. A non-blocking review never writes it, so do NOT derive display state from this — use `bucket`.
  - `bucket` (string, required, one of `drafts`, `needs_approval`, `needs_revision`, `approved`, `planned`, `scheduled`, `posted`) — Which board column this post belongs in, derived from its *display* approval status — the same rule the planner and schedule board use (@orbitwrite/core/post-buckets). Approval state outranks the lifecycle status, so a scheduled post awaiting approval reads `needs_approval` rather than `scheduled`.
  - `lastError` (string | null, required) — Most recent worker error for the matched row, when it hasn't yet succeeded. Lets result rows render the same has-issues state as the schedule.

#### 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)

#### 429

Rate limit exceeded

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

## Get post-level analytics for a published post

```http
GET https://orbitwrite.com/api/v1/posts/{postId}/analytics
```

Returns normalised, platform-agnostic metrics for a single published post. `ok:false` distinguishes an unsupported platform, a post with no captured platform id yet, and auth/rate-limit/transient errors. Results are cached ~1h server-side; `expiresAt` says when a fresh fetch would hit the platform.

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

### Path parameters

- `postId` (string, required)

### Responses

#### 200

Normalised analytics (or a typed `ok:false` reason).

#### 401

Not signed in

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

#### 403

Not a member of the organization

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

#### 404

Post not found

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

## List the caller's imminently-firing posts in a workspace

```http
GET https://orbitwrite.com/api/v1/posts/upcoming
```

Returns one entry per composer group whose earliest scheduled_at falls in `[now, now + 30min]`, scoped to the signed-in user's own posts within the named workspace (falling back to the session's active one). Polled by the workspace shell's upcoming-posts toaster. Synced rows from the same composer share a group; each unsynced account is its own group.

**Authentication:** session cookie.

### Query parameters

- `organizationId` (string, optional) — Workspace to scope the read to; defaults to the session's active organization.

### Responses

#### 200

The caller's upcoming post groups, earliest first

- `groups` (UpcomingPostGroup[], required)
  - `composerGroupId` (string, required)
  - `firesAt` (string, required) — Earliest scheduled_at in this group, ISO 8601
  - `teamSlug` (string | null, required) — Workspace slug the group belongs to; used to deep-link into the right team's schedule.
  - `accounts` (UpcomingPostAccount[], required)
    - `id` (string, required)
    - `platform` (string, required)
    - `handle` (string | null, required)
    - `displayName` (string | null, required)
    - `avatarUrl` (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)

## Discard a draft / scheduled / pending-approval group

```http
POST https://orbitwrite.com/api/v1/posts/discard
```

Deletes every row in a composer group that is still in draft, scheduled, or pending_approval. Refuses anything mid-flight or terminal (publishing/published/failed).

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

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

- `groupId` (string, required)

### Responses

#### 200

The mutation was applied to every row in the group

- `ok` (boolean, required, one of `true`)
- `groupId` (string, required)
- `count` (integer, 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)

## Delete a published group from the networks

```http
POST https://orbitwrite.com/api/v1/posts/delete-published
```

Removes a published post (or a single thread row via `postId`) from Orbit. For each account id in `deleteFromAccountIds` it also deletes that account's live post from its platform via the platform API first — partial failures keep those rows so the delete can be retried. Accounts left out (or an empty list) are removed from Orbit only; their live posts stay up. Failed-to-publish siblings are dropped with the group since nothing exists remotely.

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

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

- `groupId` (string, required)
- `postId` (string, optional)
- `deleteFromAccountIds` (string[], optional)

### Responses

#### 200

Delete attempted on every published row; check `failures` for per-account errors

- `ok` (boolean, required) — True only when every network delete succeeded
- `groupId` (string, required)
- `deleted` (integer, required)
- `failures` (DeletePublishedFailure[], required)
  - `postId` (string, required)
  - `accountId` (string, required)
  - `platform` (string | null, required)
  - `reason` (string, 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)

## Fire a draft, planned or scheduled group as soon as possible

```http
POST https://orbitwrite.com/api/v1/posts/post-now
```

Sets every row in the group to scheduled with scheduled_at = now so the worker claims it immediately. Refuses (409) a group whose blocking approval is still pending or rejected — the worker would never claim it. Does not re-validate content beyond a non-empty check on drafts and planned posts.

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

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

- `groupId` (string, required)

### Responses

#### 200

The mutation was applied to every row in the group

- `ok` (boolean, required, one of `true`)
- `groupId` (string, required)
- `count` (integer, 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)

## Move a scheduled/pending group back to draft

```http
POST https://orbitwrite.com/api/v1/posts/unschedule
```

Returns every scheduled or pending_approval row in the group to draft. An unresolved (pending/rejected) blocking approval request keeps gating the draft; a resolved (approved) request is dissolved so the post lands as a plain draft. `removeApproval` dissolves the request regardless.

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

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

- `groupId` (string, required)
- `removeApproval` (boolean, optional) — Also dissolve the group's approval request in the same transaction, landing the post as a plain (ungated) draft.

### Responses

#### 200

The mutation was applied to every row in the group

- `ok` (boolean, required, one of `true`)
- `groupId` (string, required)
- `count` (integer, 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)

## Move a group to a new fire time

```http
POST https://orbitwrite.com/api/v1/posts/reschedule
```

Reschedules every row in the group. draft → scheduled and planned → scheduled validate non-empty content (this is how a planned post is scheduled); scheduled/pending → same status shifts the whole group by the delta so per-account staggered times stay relative.

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

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

- `groupId` (string, required)
- `scheduledAt` (string, required)

### Responses

#### 200

The group was rescheduled

- `ok` (boolean, required, one of `true`)
- `groupId` (string, required)
- `scheduledAt` (string, required)
- `count` (integer, 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)

## Poll per-account publish progress for composer groups

```http
GET https://orbitwrite.com/api/v1/posts/publish-status
```

Returns per-account status (publishing/published/failed) for the root rows of the given composer groups. Polled by the composer's publishing toast after a Publish-now submit until every account reaches a terminal state.

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

### Query parameters

- `groups` (string, required) — Comma-separated composer group ids.

### Responses

#### 200

Per-account publish progress

- `ok` (boolean, required, one of `true`)
- `done` (boolean, required)
- `accounts` (PublishStatusAccount[], required)
  - `accountId` (string, required)
  - `composerGroupId` (string | null, required)
  - `platform` (string, required)
  - `handle` (string | null, required)
  - `displayName` (string | null, required)
  - `avatarUrl` (string | null, required)
  - `status` (string, required, one of `publishing`, `published`, `failed`)
  - `error` (string | null, required)
  - `warning` (string | null, required)
  - `platformUrl` (string | null, required) — The live post on the network, once published — null until then, or when the platform returns no permalink.
  - `platformPostId` (string | null, required) — The network's own id for the post, once published.

#### 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)

## Retry publishing a single failed account in a composer group

```http
POST https://orbitwrite.com/api/v1/posts/retry-publish
```

Resets one account's failed unit (its draft-with-error / failed rows, plus any un-posted thread siblings) back to scheduled=now and re-fires the detached inline publish. Powers the publishing toast's per-network Retry. Refuses (409) an account with nothing to retry — still mid-flight, or already on-platform (including a partially published thread, whose root can no longer be re-claimed). A caller without post:publish gets the author approval gate applied first, so the unit is held for review rather than published; `pendingApproval` says so.

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

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

- `groupId` (string, required)
- `accountId` (string, required)

### Responses

#### 200

The account's unit was re-queued and the publish re-fired

- `ok` (boolean, required, one of `true`)
- `groupId` (string, required)
- `accountId` (string, required)
- `pendingApproval` (boolean, required) — True when the caller holds no post:publish and the unit was re-queued behind a blocking approval request instead of being published: it goes out once a reviewer approves it.

#### 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)

## Split one failed account out of a shared composer group

```http
POST https://orbitwrite.com/api/v1/posts/split-account
```

Moves a single failed account's rows into their own composer group (converting the failed unit back to an editable draft) so it can be rescheduled on its own. Powers the publishing toast's per-network Schedule: the account is opened in the composer scoped to just itself. A no-op that returns the same group when the account is already alone. Refuses (409) an account that isn't in a failed/draft state.

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

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

- `groupId` (string, required)
- `accountId` (string, required)

### Responses

#### 200

The account was split into (or already owned) its own group

- `ok` (boolean, required, one of `true`)
- `groupId` (string, required)
- `accountId` (string, 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)

## Split a synced multi-account group into per-account groups

```http
POST https://orbitwrite.com/api/v1/posts/ungroup
```

Detaches a synced composer group so each account's rows get a fresh composer_group_id (the loader then surfaces them separately). Only drafts and scheduled rows can be ungrouped.

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

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

- `groupId` (string, required)

### Responses

#### 200

The group was split (or a no-op for single-account groups)

- `ok` (boolean, required, one of `true`)
- `groups` (object, required) — Map of accountId → the new composer_group_id its rows moved to. Empty when the source group had a single account (no-op).

#### 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)

## Duplicate a group into a fresh editable draft

```http
POST https://orbitwrite.com/api/v1/posts/draft/duplicate
```

Copies every row in a draft/scheduled/published/pending group into a new group as drafts (fresh ids and threadIds, no scheduledAt/campaignId). publishing/failed rows are refused.

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

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

- `sourceGroupId` (string, required)

### Responses

#### 200

The mutation was applied to every row in the group

- `ok` (boolean, required, one of `true`)
- `groupId` (string, required)
- `count` (integer, 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)

## Update a composer group's Info-panel metadata

```http
POST https://orbitwrite.com/api/v1/posts/info
```

Metadata-only update — touches composer_group.title / composer_group.tags (and, when supplied, the group's posts' campaign_id). Safe on scheduled/published groups; refused only mid-publish. Note: the composer now folds this into the draft-save autosave, so this endpoint is retained for compatibility.

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

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

- `groupId` (string, required)
- `info` (object, required)
  - `title` (string, optional)
  - `tags` (string[], optional)
  - `tagColors` (object, optional)
  - `campaignId` (string | null, optional)
  - `templateId` (string | null, optional)

### Responses

#### 200

The metadata was updated

- `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)

#### 409

Conflicts with an existing resource

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

## Approve or reject a composer group

```http
POST https://orbitwrite.com/api/v1/posts/{groupId}/approval
```

Records an approve/reject on the group. Reviewer requests resolve on the require-all / any-one rule; otherwise anyone holding approval:decide green-lights directly. Fans out to Slack and rolls the parent campaign forward — the same operation the Slack interactivity handler runs.

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

### Path parameters

- `groupId` (string, required)

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

- `decision` (string, required, one of `approved`, `rejected`)
- `comment` (string | null, optional)

### Responses

#### 200

The decision was recorded

- `ok` (boolean, required, one of `true`)
- `decision` (string, required, one of `approved`, `rejected`)
- `requestStatus` (string, optional, one of `pending`, `approved`, `rejected`)
- `postCount` (integer, optional)
- `publishing` (boolean, optional, one of `true`) — Present when the approval sent a `publishOnApprove` post out: the adapter publish runs detached — poll /posts/publish-status with `groupIds`.
- `groupIds` (string[], optional) — The groups now publishing. Present with `publishing`.

#### 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)

## List a workspace's posts

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

Every post in the workspace — drafts, awaiting approval, scheduled and published — one entry per composer group, so a post synced to eight channels is one row carrying eight accounts. Cursor-paged rather than offset-paged: a workspace gains posts at the head, so an offset would re-show rows while a client pages. Filters combine with AND; repeat a parameter for OR within it. For one post's full content — threads, per-channel bodies, media, engagement — read it back with `GET /posts/draft?groupId=`.

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

### Query parameters

- `organizationId` (string, optional) — Session callers only — an API key already determines the workspace.
- `limit` (integer, optional)
- `cursor` (string, optional) — Opaque cursor from a previous response's `nextCursor`. Omit for the first page.
- `sort` (string, optional) — Order by the group's creation time (default) or its earliest fire time. Drafts have no fire time and sort as if scheduled indefinitely far out.
- `direction` (string, optional) — Newest/latest first by default.
- `status` (string | string[], optional) — Lifecycle status. Repeat for OR (e.g. `?status=draft&status=scheduled`).
- `bucket` (string | string[], optional) — Board column. Approval state outranks the lifecycle status, so a scheduled post awaiting review is `needs_approval`, not `scheduled`. Repeat for OR.
- `platform` (string | string[], optional) — Platform id as returned by `/connected-accounts` (e.g. `twitter`, `linkedin_personal`). Matches a post going out on ANY of its channels. Repeat for OR.
- `accountId` (string | string[], optional) — Connected-account id. Matches a post going out on ANY of its channels. Repeat for OR.
- `campaignId` (string, optional)
- `groupId` (string | string[], optional) — Only these posts, by the `id` each entry carries. Repeat for OR; at most 100.
- `scoreGrade` (string | string[], optional) — Post score grade (see `scoreGrade` on each entry). Repeat for OR, e.g. `?scoreGrade=fair&scoreGrade=needs_work` for the posts worth another look.
- `scheduledAfter` (string, optional) — ISO 8601, inclusive, against the group's earliest fire time. Drafts have no fire time and are excluded by either bound.
- `scheduledBefore` (string, optional) — ISO 8601, inclusive.
- `previewAccountId` (string, optional) — A connected-account id whose copy to show: for a post that goes out on that channel, `text`, `media` and `platformUrl` come from its row. Not a filter — other posts are listed unchanged, and the ordering and cursor are unaffected. Use the workspace's primary channel to see each post the way the app does.

### Responses

#### 200

A page of posts

- `entries` (PostListEntry[], required)
  - `id` (string, required) — The composer group's id — what every group-addressed route (`/posts/draft`, `/posts/reschedule`, `/posts/discard`) takes.
  - `postId` (string, required) — The representative row's own post id, for the post-addressed routes (`/posts/{postId}/analytics`).
  - `title` (string | null, required)
  - `text` (string, required) — The preview row's body — the representative row's, or `previewAccountId`'s when the post goes out on that channel. Per-channel bodies can differ; `GET /posts/draft?groupId=` returns them all, with threads and media.
  - `status` (string, required, one of `draft`, `planned`, `pending_approval`, `scheduled`, `publishing`, `published`)
  - `approvalStatus` (string, required, one of `not_required`, `pending`, `approved`, `rejected`) — What the post READS as — its active approval request's status whether or not that request blocks. `bucket` is derived from this.
  - `approvalGateStatus` (string, required, one of `not_required`, `pending`, `approved`, `rejected`) — The post's own approval column: the publish gate, which a non-blocking review never writes. Ask this when the question is 'would this publish?'.
  - `bucket` (string, required, one of `drafts`, `needs_approval`, `needs_revision`, `approved`, `planned`, `scheduled`, `posted`) — Which board column the post sits in.
  - `scheduledAt` (string | null, required) — ISO 8601 — the group's earliest fire time. Null for a draft that has never been given one.
  - `publishedAt` (string | null, required) — ISO 8601.
  - `createdAt` (string, required) — ISO 8601.
  - `updatedAt` (string, required) — ISO 8601.
  - `boardPosition` (number | null, required) — Where a person placed the post in a hand-ordered board column (Drafts, Needs approval, Needs revision, Approved), higher first — set by `POST /posts/reorder`. Null until someone moves it; it then sorts by `createdAt` in epoch milliseconds, so the two share one key space. Scheduled and Posted are ordered by time instead.
  - `hasThread` (boolean, required)
  - `previewAccountId` (string, required) — The channel whose row supplied `text`, `media` and `platformUrl`: the `previewAccountId` asked for when the post goes out on it, otherwise the representative row's channel.
  - `mediaCount` (integer, required)
  - `media` (PostListMedia[], required) — The preview row's attachments, enough to draw thumbnails. When the post has no row on `previewAccountId` (or none was asked for) and the representative row has no attachments, the first channel's row that has some, so a card never shows no images for a post that has them.
    - `url` (string, required)
    - `mimeType` (string, required)
    - `width` (number | null, required)
    - `height` (number | null, required)
    - `altText` (string | null, required)
    - `title` (string | null, required) — A document's (PDF's) title.
  - `poll` (object | any | null, required)
  - `quoteUrl` (string | null, required) — The post this one quotes, if any.
  - `platformUrl` (string | null, required) — The preview row's permalink once published. Every channel's own is on `accounts`.
  - `author` (object | any | null, required) — Who wrote the post; null if their account is gone.
  - `lastError` (string | null, required) — Most recent worker error, suppressed once the post published (a retry that eventually succeeded leaves its error behind).
  - `issues` (object[], required) — Every reason the post won't publish as it stands, from the channels that can still go out (draft, scheduled, pending approval): `empty` (nothing to post, on the whole post or one channel), `reconnect` (the channel's credentials are dead), `over_limit`, `media_too_large`, `capability` (a format, attachment or option the network refuses), `auto_dm` (an enabled Auto-DM that isn't complete), `invalid_poll`, `template_field` (a post template's `{{field}}` or `[Attach …]` note left unfilled), and `carousel_crop` (slides Instagram will crop to the first slide's shape: a notice that never stops the post publishing). The same list the app's composer shows for the post. Empty when there is nothing to fix; a worker's publish error is `lastError`, not one of these.
    - `kind` (string, required, one of `empty`, `reconnect`, `over_limit`, `media_too_large`, `capability`, `auto_dm`, `invalid_poll`, `template_field`, `carousel_crop`)
    - `accountId` (string | null, required) — The channel it concerns, or null when it is the whole post's.
    - `message` (string, required)
  - `campaign` (object | any | null, required) — The campaign this post belongs to, if any.
  - `tags` (object[], required)
    - `name` (string, required)
    - `color` (string, required)
  - `accounts` (PostListAccount[], required) — Every channel this post goes out on.
    - `id` (string, required)
    - `platform` (string, required)
    - `handle` (string | null, required)
    - `displayName` (string | null, required)
    - `avatarUrl` (string | null, required)
    - `platformUrl` (string | null, required) — This channel's permalink to the published post, once the network returned one.
    - `status` (string, required, one of `draft`, `planned`, `pending_approval`, `scheduled`, `publishing`, `published`) — This channel's own row, where a group's rows can differ (a partial publish leaves one channel published and another scheduled). The entry's `status` is the representative row's.
    - `lastError` (string | null, required) — This channel's most recent worker error, suppressed once its row published. When the post repeats the channel across variants, the first variant's error that survives that suppression.
    - `isEmpty` (boolean, required) — This channel's row has no text, media, poll or quote — a reserved slot or a blank draft, which the worker holds at fire time rather than publishing nothing. Per channel: a board flags the post when ANY channel's row is empty, not only the preview's. True when any of the channel's variants is empty.
    - `charCount` (integer, required) — Length of this channel's copy, in UTF-16 code units (what the composer counts). When the post repeats the channel across variants, `status`, `charCount` and `charLimit` come together from one variant: the first that is over its limit and can still fire (draft, scheduled or pending approval), otherwise the first.
    - `charLimit` (integer, required) — The platform's character limit for this channel, premium-aware (X Premium raises it) and media-aware (a row with attachments is held to the caption limit where the platform has one, as Telegram does). `charCount > charLimit` means the row won't publish as-is. Per channel because one synced body meets a different limit on each platform.
  - `score` (integer | null, required) — The post's health-check score, 0–100 — the checklist `GET /posts/{groupId}/score` returns, kept per post and refreshed whenever the post changes. Null for a post with none of its channels still connected. Read `scoreGrade` for what it means; the number is a heuristic, not a measurement.
  - `scoreGrade` (string | null, required, one of `excellent`, `good`, `fair`, `needs_work`, `null`) — The score's grade: excellent (90+), good (75+), fair (50+) or needs_work.
- `nextCursor` (string | null, required) — Pass back as `cursor` for the next page; null on the last page. A cursor is tied to the `sort`/`direction` it was cut for — change either and start again without it.

#### 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)

## Create scheduled posts (or publish immediately)

```http
POST https://orbitwrite.com/api/v1/posts
```

The composer's schedule/publish submit, and the way an integration creates posts. Validates platform capabilities, quote/reply targets, media URL safety, and engagement accounts; splits accounts into composer groups (synced accounts share one, each unsynced account gets its own); optionally replaces an existing group (re-schedule) or attaches a reviewer approval to each new group. With `publishNow` the adapter publish runs detached after the rows commit.

Media can be attached inline via an item's `media` (`{url, mimeType, …}`), or by id via `mediaIds` for files uploaded through `/media/create-upload-url`. Ids are resolved server-side and appended to that item's `media`; both forms can be used together, and up to 10 ids are accepted per item.

Authenticates with a workspace API key or a session cookie. The accounts posted to must all belong to the key's workspace; the posts are attributed to the user who created the key.

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

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

- `scheduledAt` (string, required)
- `accounts` (object[], required)
  - `accountId` (string, required)
  - `items` (object[], required)
    - `id` (string, required)
    - `content` (string, required)
    - `poll` (object, optional)
      - `options` (string[], required)
      - `durationMinutes` (integer, required)
    - `media` (object[], optional)
      - `url` (string, required)
      - `mimeType` (string, required)
      - `mediaId` (string, optional)
      - `altText` (string, optional)
      - `width` (integer, optional)
      - `height` (integer, optional)
      - `bytes` (integer, optional)
      - `durationSec` (number, optional)
      - `sourceUrl` (string, optional)
      - `videoUrl` (string, optional)
      - `title` (string, optional)
      - `pageCount` (integer, optional)
    - `mediaIds` (string[], optional)
    - `quote` (string, optional)
    - `replyTo` (string, optional)
    - `linkPreview` (object, optional)
      - `url` (string, required)
      - `matchUrl` (string, required)
      - `title` (string | null, required)
      - `description` (string | null, required)
      - `image` (string | null, required)
      - `siteName` (string | null, required)
  - `isSynced` (boolean, optional)
  - `scheduledAt` (string, optional)
  - `autoEngagement` (object, optional)
    - `comment` (object, required)
      - `enabled` (boolean, required)
      - `entries` (object[], optional)
        - `fromAccountId` (string, required)
        - `delayMinutes` (integer, required)
        - `items` (object[], optional)
          - `id` (string, required)
          - `content` (string, required)
          - `media` (object[], optional)
            - `url` (string, required)
            - `mimeType` (string, required)
            - `mediaId` (string, optional)
            - `altText` (string, optional)
            - `width` (integer, optional)
            - `height` (integer, optional)
            - `bytes` (integer, optional)
            - `durationSec` (number, optional)
            - `sourceUrl` (string, optional)
            - `videoUrl` (string, optional)
            - `title` (string, optional)
            - `pageCount` (integer, optional)
          - `mediaIds` (string[], optional)
          - `poll` (object, optional)
            - `options` (string[], required)
            - `durationMinutes` (integer, required)
        - `replyTarget` (string, optional, one of `thread_tail`, `root`)
      - `delayMinutes` (integer, optional)
      - `fromAccountId` (string | null, optional)
      - `items` (object[], optional)
        - `id` (string, required)
        - `content` (string, required)
        - `media` (object[], optional)
          - `url` (string, required)
          - `mimeType` (string, required)
          - `mediaId` (string, optional)
          - `altText` (string, optional)
          - `width` (integer, optional)
          - `height` (integer, optional)
          - `bytes` (integer, optional)
          - `durationSec` (number, optional)
          - `sourceUrl` (string, optional)
          - `videoUrl` (string, optional)
          - `title` (string, optional)
          - `pageCount` (integer, optional)
        - `mediaIds` (string[], optional)
        - `poll` (object, optional)
          - `options` (string[], required)
          - `durationMinutes` (integer, required)
      - `text` (string, optional)
      - `media` (object[], optional)
        - `url` (string, required)
        - `mimeType` (string, required)
        - `mediaId` (string, optional)
        - `altText` (string, optional)
        - `width` (integer, optional)
        - `height` (integer, optional)
        - `bytes` (integer, optional)
        - `durationSec` (number, optional)
        - `sourceUrl` (string, optional)
        - `videoUrl` (string, optional)
        - `title` (string, optional)
        - `pageCount` (integer, optional)
      - `poll` (object, optional)
        - `options` (string[], required)
        - `durationMinutes` (integer, required)
    - `like` (object, required)
      - `enabled` (boolean, required)
      - `entries` (object[], optional)
        - `fromAccountId` (string, required)
        - `delayMinutes` (integer, required)
        - `reaction` (string, optional, one of `LIKE`, `LOVE`, `CELEBRATE`, `INSIGHTFUL`, `CURIOUS`, `SUPPORT`)
      - `delayMinutes` (integer, optional)
      - `fromAccountIds` (string[], optional)
      - `reaction` (string, optional, one of `LIKE`, `LOVE`, `CELEBRATE`, `INSIGHTFUL`, `CURIOUS`, `SUPPORT`)
    - `repost` (object, required)
      - `enabled` (boolean, required)
      - `entries` (object[], optional)
        - `fromAccountId` (string, required)
        - `delayMinutes` (integer, required)
      - `delayMinutes` (integer, optional)
      - `fromAccountId` (string | null, optional)
    - `slack` (object, optional)
      - `enabled` (boolean, required)
      - `entries` (object[], optional)
        - `fromAccountId` (string, required)
        - `delayMinutes` (integer, required)
        - `items` (object[], optional)
          - `id` (string, required)
          - `content` (string, required)
          - `media` (object[], optional)
            - `url` (string, required)
            - `mimeType` (string, required)
            - `mediaId` (string, optional)
            - `altText` (string, optional)
            - `width` (integer, optional)
            - `height` (integer, optional)
            - `bytes` (integer, optional)
            - `durationSec` (number, optional)
            - `sourceUrl` (string, optional)
            - `videoUrl` (string, optional)
            - `title` (string, optional)
            - `pageCount` (integer, optional)
          - `mediaIds` (string[], optional)
          - `poll` (object, optional)
            - `options` (string[], required)
            - `durationMinutes` (integer, required)
    - `discord` (object, optional)
      - `enabled` (boolean, required)
      - `entries` (object[], optional)
        - `fromAccountId` (string, required)
        - `delayMinutes` (integer, required)
        - `items` (object[], optional)
          - `id` (string, required)
          - `content` (string, required)
          - `media` (object[], optional)
            - `url` (string, required)
            - `mimeType` (string, required)
            - `mediaId` (string, optional)
            - `altText` (string, optional)
            - `width` (integer, optional)
            - `height` (integer, optional)
            - `bytes` (integer, optional)
            - `durationSec` (number, optional)
            - `sourceUrl` (string, optional)
            - `videoUrl` (string, optional)
            - `title` (string, optional)
            - `pageCount` (integer, optional)
          - `mediaIds` (string[], optional)
          - `poll` (object, optional)
            - `options` (string[], required)
            - `durationMinutes` (integer, required)
    - `dm` (object, optional)
      - `enabled` (boolean, required)
      - `message` (string, optional)
      - `triggers` (string[], optional)
      - `match` (string, optional, one of `all`, `any`)
      - `requireFollow` (boolean, optional)
      - `replyKeyword` (string, optional)
      - `durationDays` (integer, optional)
  - `replySettings` (string, optional, one of `everyone`, `following`, `mentionedUsers`, `subscribers`, `verified`, `accounts_you_follow`, `mentioned_only`, `followers_only`, `parent_post_author_only`)
  - `postFormat` (string, optional, one of `post`, `story`, `reel`, `short`)
  - `platformOptions` (object, optional)
    - `tiktok` (object, optional)
      - `title` (string, optional)
      - `privacyLevel` (string, optional, one of `PUBLIC_TO_EVERYONE`, `MUTUAL_FOLLOW_FRIENDS`, `FOLLOWER_OF_CREATOR`, `SELF_ONLY`)
      - `disableComment` (boolean, optional)
      - `disableDuet` (boolean, optional)
      - `disableStitch` (boolean, optional)
      - `brandOrganicToggle` (boolean, optional)
      - `brandContentToggle` (boolean, optional)
      - `isAigc` (boolean, optional) — Label as AI-generated ("Creator labeled as AI-generated").
      - `videoCoverTimestampMs` (integer, optional)
      - `photoCoverIndex` (integer, optional)
      - `autoAddMusic` (boolean, optional)
    - `threads` (object, optional)
      - `shareToInstagram` (boolean, optional)
      - `topicTag` (string, optional) — The post's topic (Threads' `topic_tag`): 1 to 50 characters, no periods or ampersands. A leading # is ignored. Set on the first post of a thread only.
    - `youtube` (object, optional)
      - `title` (string, optional)
      - `privacyStatus` (string, optional, one of `public`, `unlisted`, `private`)
      - `categoryId` (string, optional)
      - `tags` (string[], optional)
      - `madeForKids` (boolean, optional)
      - `containsSyntheticMedia` (boolean, optional) — Altered or synthetic content: realistic video made or changed with AI (a real person, place or event).
    - `instagram` (object, optional)
      - `videoCoverTimestampMs` (integer, optional)
      - `coverImageUrl` (string, optional)
      - `isAiGenerated` (boolean, optional) — Label as AI-generated ("AI info"). Not applied to stories.
      - `userTags` (object[], optional) — People tagged in the media, not caption @mentions. x/y: 0-1 from the photo's top left (default centre). mediaUrl: which attachment (default the first). 20 per item max, public accounts only.
        - `username` (string, required)
        - `x` (number, optional)
        - `y` (number, optional)
        - `mediaUrl` (string, optional)
      - `collaborators` (string[], optional) — Up to 3 usernames invited as collaborators. Not applied to stories.
    - `twitter` (object, optional)
      - `madeWithAi` (boolean, optional) — Label "Made with AI". Sent only when the first post has media.
    - `pinterest` (object, optional)
      - `boardId` (string, optional) — The board to save the Pin to. Required to publish.
      - `boardSectionId` (string, optional) — A section of that board.
      - `title` (string, optional) — The Pin's title. The text is its description.
      - `link` (string, optional) — Where the Pin opens when tapped.
      - `videoCoverTimestampMs` (integer, optional)
      - `coverImageUrl` (string, optional)
      - `isAiModified` (boolean, optional) — Label the Pin "AI modified" (Pinterest's ai_disclosures).
  - `aiGenerated` (boolean, optional) — Label as AI-generated where the network has a label (TikTok, Instagram, X, YouTube, Pinterest).
- `replaceGroupId` (string, optional)
- `publishNow` (boolean, optional)
- `info` (object, optional)
  - `title` (string, optional)
  - `tags` (string[], optional)
  - `tagColors` (object, optional)
  - `campaignId` (string | null, optional)
  - `templateId` (string | null, optional)
- `approval` (object, optional)
  - `reviewerIds` (string[], required)
  - `message` (string | null, optional)
  - `dueDate` (string | null, optional)
  - `requireAll` (boolean, optional)
  - `blocking` (boolean, optional)
  - `publishOnApprove` (boolean, optional)

### Responses

#### 200

Rows committed — scheduled, or publishing detached

#### 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)

## List every post in a workspace, unpaged

```http
GET https://orbitwrite.com/api/v1/posts/all
```

The entries `GET /posts` pages through, in the same order and with the same filters, in one response and with no cursor — for a client that renders the whole workspace at once (a board, a calendar). Paging `GET /posts` to the end costs the server a full pass over the workspace per page, so read this instead when you want everything; page `GET /posts` when you want the first few.

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

### Query parameters

- `organizationId` (string, optional) — Session callers only — an API key already determines the workspace.
- `sort` (string, optional) — Order by the group's creation time (default) or its earliest fire time. Drafts have no fire time and sort as if scheduled indefinitely far out.
- `direction` (string, optional) — Newest/latest first by default.
- `status` (string | string[], optional) — Lifecycle status. Repeat for OR (e.g. `?status=draft&status=scheduled`).
- `bucket` (string | string[], optional) — Board column. Approval state outranks the lifecycle status, so a scheduled post awaiting review is `needs_approval`, not `scheduled`. Repeat for OR.
- `platform` (string | string[], optional) — Platform id as returned by `/connected-accounts` (e.g. `twitter`, `linkedin_personal`). Matches a post going out on ANY of its channels. Repeat for OR.
- `accountId` (string | string[], optional) — Connected-account id. Matches a post going out on ANY of its channels. Repeat for OR.
- `campaignId` (string, optional)
- `groupId` (string | string[], optional) — Only these posts, by the `id` each entry carries. Repeat for OR; at most 100.
- `scoreGrade` (string | string[], optional) — Post score grade (see `scoreGrade` on each entry). Repeat for OR, e.g. `?scoreGrade=fair&scoreGrade=needs_work` for the posts worth another look.
- `scheduledAfter` (string, optional) — ISO 8601, inclusive, against the group's earliest fire time. Drafts have no fire time and are excluded by either bound.
- `scheduledBefore` (string, optional) — ISO 8601, inclusive.
- `previewAccountId` (string, optional) — A connected-account id whose copy to show: for a post that goes out on that channel, `text`, `media` and `platformUrl` come from its row. Not a filter — other posts are listed unchanged, and the ordering and cursor are unaffected. Use the workspace's primary channel to see each post the way the app does.

### Responses

#### 200

Every matching post

- `entries` (PostListEntry[], required)
  - `id` (string, required) — The composer group's id — what every group-addressed route (`/posts/draft`, `/posts/reschedule`, `/posts/discard`) takes.
  - `postId` (string, required) — The representative row's own post id, for the post-addressed routes (`/posts/{postId}/analytics`).
  - `title` (string | null, required)
  - `text` (string, required) — The preview row's body — the representative row's, or `previewAccountId`'s when the post goes out on that channel. Per-channel bodies can differ; `GET /posts/draft?groupId=` returns them all, with threads and media.
  - `status` (string, required, one of `draft`, `planned`, `pending_approval`, `scheduled`, `publishing`, `published`)
  - `approvalStatus` (string, required, one of `not_required`, `pending`, `approved`, `rejected`) — What the post READS as — its active approval request's status whether or not that request blocks. `bucket` is derived from this.
  - `approvalGateStatus` (string, required, one of `not_required`, `pending`, `approved`, `rejected`) — The post's own approval column: the publish gate, which a non-blocking review never writes. Ask this when the question is 'would this publish?'.
  - `bucket` (string, required, one of `drafts`, `needs_approval`, `needs_revision`, `approved`, `planned`, `scheduled`, `posted`) — Which board column the post sits in.
  - `scheduledAt` (string | null, required) — ISO 8601 — the group's earliest fire time. Null for a draft that has never been given one.
  - `publishedAt` (string | null, required) — ISO 8601.
  - `createdAt` (string, required) — ISO 8601.
  - `updatedAt` (string, required) — ISO 8601.
  - `boardPosition` (number | null, required) — Where a person placed the post in a hand-ordered board column (Drafts, Needs approval, Needs revision, Approved), higher first — set by `POST /posts/reorder`. Null until someone moves it; it then sorts by `createdAt` in epoch milliseconds, so the two share one key space. Scheduled and Posted are ordered by time instead.
  - `hasThread` (boolean, required)
  - `previewAccountId` (string, required) — The channel whose row supplied `text`, `media` and `platformUrl`: the `previewAccountId` asked for when the post goes out on it, otherwise the representative row's channel.
  - `mediaCount` (integer, required)
  - `media` (PostListMedia[], required) — The preview row's attachments, enough to draw thumbnails. When the post has no row on `previewAccountId` (or none was asked for) and the representative row has no attachments, the first channel's row that has some, so a card never shows no images for a post that has them.
    - `url` (string, required)
    - `mimeType` (string, required)
    - `width` (number | null, required)
    - `height` (number | null, required)
    - `altText` (string | null, required)
    - `title` (string | null, required) — A document's (PDF's) title.
  - `poll` (object | any | null, required)
  - `quoteUrl` (string | null, required) — The post this one quotes, if any.
  - `platformUrl` (string | null, required) — The preview row's permalink once published. Every channel's own is on `accounts`.
  - `author` (object | any | null, required) — Who wrote the post; null if their account is gone.
  - `lastError` (string | null, required) — Most recent worker error, suppressed once the post published (a retry that eventually succeeded leaves its error behind).
  - `issues` (object[], required) — Every reason the post won't publish as it stands, from the channels that can still go out (draft, scheduled, pending approval): `empty` (nothing to post, on the whole post or one channel), `reconnect` (the channel's credentials are dead), `over_limit`, `media_too_large`, `capability` (a format, attachment or option the network refuses), `auto_dm` (an enabled Auto-DM that isn't complete), `invalid_poll`, `template_field` (a post template's `{{field}}` or `[Attach …]` note left unfilled), and `carousel_crop` (slides Instagram will crop to the first slide's shape: a notice that never stops the post publishing). The same list the app's composer shows for the post. Empty when there is nothing to fix; a worker's publish error is `lastError`, not one of these.
    - `kind` (string, required, one of `empty`, `reconnect`, `over_limit`, `media_too_large`, `capability`, `auto_dm`, `invalid_poll`, `template_field`, `carousel_crop`)
    - `accountId` (string | null, required) — The channel it concerns, or null when it is the whole post's.
    - `message` (string, required)
  - `campaign` (object | any | null, required) — The campaign this post belongs to, if any.
  - `tags` (object[], required)
    - `name` (string, required)
    - `color` (string, required)
  - `accounts` (PostListAccount[], required) — Every channel this post goes out on.
    - `id` (string, required)
    - `platform` (string, required)
    - `handle` (string | null, required)
    - `displayName` (string | null, required)
    - `avatarUrl` (string | null, required)
    - `platformUrl` (string | null, required) — This channel's permalink to the published post, once the network returned one.
    - `status` (string, required, one of `draft`, `planned`, `pending_approval`, `scheduled`, `publishing`, `published`) — This channel's own row, where a group's rows can differ (a partial publish leaves one channel published and another scheduled). The entry's `status` is the representative row's.
    - `lastError` (string | null, required) — This channel's most recent worker error, suppressed once its row published. When the post repeats the channel across variants, the first variant's error that survives that suppression.
    - `isEmpty` (boolean, required) — This channel's row has no text, media, poll or quote — a reserved slot or a blank draft, which the worker holds at fire time rather than publishing nothing. Per channel: a board flags the post when ANY channel's row is empty, not only the preview's. True when any of the channel's variants is empty.
    - `charCount` (integer, required) — Length of this channel's copy, in UTF-16 code units (what the composer counts). When the post repeats the channel across variants, `status`, `charCount` and `charLimit` come together from one variant: the first that is over its limit and can still fire (draft, scheduled or pending approval), otherwise the first.
    - `charLimit` (integer, required) — The platform's character limit for this channel, premium-aware (X Premium raises it) and media-aware (a row with attachments is held to the caption limit where the platform has one, as Telegram does). `charCount > charLimit` means the row won't publish as-is. Per channel because one synced body meets a different limit on each platform.
  - `score` (integer | null, required) — The post's health-check score, 0–100 — the checklist `GET /posts/{groupId}/score` returns, kept per post and refreshed whenever the post changes. Null for a post with none of its channels still connected. Read `scoreGrade` for what it means; the number is a heuristic, not a measurement.
  - `scoreGrade` (string | null, required, one of `excellent`, `good`, `fair`, `needs_work`, `null`) — The score's grade: excellent (90+), good (75+), fair (50+) or needs_work.

#### 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)

## Score unsaved post content

```http
POST https://orbitwrite.com/api/v1/posts/score
```

A health check for content you're about to post: send each channel's thread (and its first comment, if any) and get back a 0–100 score with a pass/warn/fail checklist. Nothing is stored. Each channel gets its own checklist — hook strength, length, readability, spelling slips, leftover placeholders, AI-writing tells, call to action, engagement bait, hashtags, mentions, link placement (body vs first comment), link safety, link tracking, the link card's image, visuals, alt text, posting time and brand-voice rules — because what works differs by network. The ranges are published platform guidance, not this workspace's analytics. Hard limits (character caps, required media) aren't scored; the create call refuses those. Requires `post:read`; link-tracking and brand-voice checks are skipped for a credential that can't read those settings.

**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`)

- `accounts` (object[], required)
  - `accountId` (string, required)
  - `items` (object[], required)
    - `content` (string, required)
    - `media` (object[], optional)
      - `mimeType` (string, required)
      - `altText` (string, optional)
    - `poll` (any, optional)
    - `quote` (string, optional)
    - `replyTo` (string, optional)
    - `linkPreview` (object | null, optional)
      - `url` (string | null, optional)
      - `image` (string | null, required)
  - `firstComment` (string, optional)
  - `destinationLink` (string, optional)
- `scheduledAt` (string, optional)

### Responses

#### 200

The post's score

- `score` (PostScore, required)
  - `score` (integer, required) — Mean of the channels' scores.
  - `grade` (string, required, one of `excellent`, `good`, `fair`, `needs_work`) — `excellent` ≥ 90, `good` ≥ 75, `fair` ≥ 50, else `needs_work`.
  - `channels` (ChannelPostScore[], required)
    - `accountId` (string | any | null, required)
    - `platform` (string, required)
    - `score` (integer, required) — Weighted share of the checks that applied. A failed `placeholders` or `link_safety` check caps it at 49 (`needs_work`): a post that would publish "[INSERT LINK]" or a look-alike domain can't read as fair.
    - `grade` (string, required, one of `excellent`, `good`, `fair`, `needs_work`)
    - `checks` (PostScoreCheck[], required)
      - `id` (string, required, one of `hook`, `length`, `readability`, `spelling`, `placeholders`, `ai_writing`, `call_to_action`, `engagement_bait`, `hashtags`, `mentions`, `link_placement`, `link_safety`, `link_tracking`, `link_preview`, `visuals`, `alt_text`, `posting_time`, `brand_voice`)
      - `category` (string, required, one of `writing`, `reach`, `format`, `brand`)
      - `label` (string, required)
      - `description` (string, required) — What the check looks at and why it matters. Fixed per check.
      - `status` (string, required, one of `pass`, `warn`, `fail`, `skip`) — `pass` and `fail` count fully either way, `warn` counts half, and `skip` (nothing to judge — no links, too short to measure, a destination with no algorithm) is left out of the score. The checks that only hunt mistakes — `spelling`, `placeholders`, `engagement_bait`, `mentions`, `link_safety` — count only against a post: their `pass` is left out like a `skip`.
      - `summary` (string, required) — One line. When the check didn't pass it is the action to take ("Add about 850 characters"); when it passed, what was found.
      - `advice` (string | any | null, required) — The why behind `summary`. Null when the check passed or was skipped.
      - `context` (string | any | null, required) — The rule this network (or the workspace's settings) judges the check by — "Instagram allows five hashtags per post…". Present whether or not the check passed; null when skipped.
      - `fix` (string | any | null, required) — A one-step fix, when there is one. `enable_link_tracking`: turn tracking on (`PUT /link-tracking`, or clear the channel's override with `PUT /link-tracking/account`).
      - `weight` (number, required)
      - `showWhenPassing` (boolean, required) — Whether a `pass` is worth showing. False for checks that only matter when they find something — `placeholders`, `engagement_bait`, `mentions`, `link_safety`, `hashtags`: hide them while they pass, show them once they warn or fail.

#### 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)

## Score a saved post

```http
GET https://orbitwrite.com/api/v1/posts/{groupId}/score
```

The same health check the composer shows, for a stored draft, scheduled or published post. The first comment is read from the channel's own auto-engagement comments, and a scheduled post's fire time is judged in the workspace's zone. Each channel gets its own checklist — hook strength, length, readability, spelling slips, leftover placeholders, AI-writing tells, call to action, engagement bait, hashtags, mentions, link placement (body vs first comment), link safety, link tracking, the link card's image, visuals, alt text, posting time and brand-voice rules — because what works differs by network. The ranges are published platform guidance, not this workspace's analytics. Hard limits (character caps, required media) aren't scored; the create call refuses those. Requires `post:read`; link-tracking and brand-voice checks are skipped for a credential that can't read those settings.

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

### Path parameters

- `groupId` (string, required) — The post's composer group id.

### Responses

#### 200

The post's score

- `score` (PostScore, required)
  - `score` (integer, required) — Mean of the channels' scores.
  - `grade` (string, required, one of `excellent`, `good`, `fair`, `needs_work`) — `excellent` ≥ 90, `good` ≥ 75, `fair` ≥ 50, else `needs_work`.
  - `channels` (ChannelPostScore[], required)
    - `accountId` (string | any | null, required)
    - `platform` (string, required)
    - `score` (integer, required) — Weighted share of the checks that applied. A failed `placeholders` or `link_safety` check caps it at 49 (`needs_work`): a post that would publish "[INSERT LINK]" or a look-alike domain can't read as fair.
    - `grade` (string, required, one of `excellent`, `good`, `fair`, `needs_work`)
    - `checks` (PostScoreCheck[], required)
      - `id` (string, required, one of `hook`, `length`, `readability`, `spelling`, `placeholders`, `ai_writing`, `call_to_action`, `engagement_bait`, `hashtags`, `mentions`, `link_placement`, `link_safety`, `link_tracking`, `link_preview`, `visuals`, `alt_text`, `posting_time`, `brand_voice`)
      - `category` (string, required, one of `writing`, `reach`, `format`, `brand`)
      - `label` (string, required)
      - `description` (string, required) — What the check looks at and why it matters. Fixed per check.
      - `status` (string, required, one of `pass`, `warn`, `fail`, `skip`) — `pass` and `fail` count fully either way, `warn` counts half, and `skip` (nothing to judge — no links, too short to measure, a destination with no algorithm) is left out of the score. The checks that only hunt mistakes — `spelling`, `placeholders`, `engagement_bait`, `mentions`, `link_safety` — count only against a post: their `pass` is left out like a `skip`.
      - `summary` (string, required) — One line. When the check didn't pass it is the action to take ("Add about 850 characters"); when it passed, what was found.
      - `advice` (string | any | null, required) — The why behind `summary`. Null when the check passed or was skipped.
      - `context` (string | any | null, required) — The rule this network (or the workspace's settings) judges the check by — "Instagram allows five hashtags per post…". Present whether or not the check passed; null when skipped.
      - `fix` (string | any | null, required) — A one-step fix, when there is one. `enable_link_tracking`: turn tracking on (`PUT /link-tracking`, or clear the channel's override with `PUT /link-tracking/account`).
      - `weight` (number, required)
      - `showWhenPassing` (boolean, required) — Whether a `pass` is worth showing. False for checks that only matter when they find something — `placeholders`, `engagement_bait`, `mentions`, `link_safety`, `hashtags`: hide them while they pass, show them once they warn or fail.

#### 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)

## Read a post's video cover

```http
GET https://orbitwrite.com/api/v1/posts/{groupId}/cover
```

Each channel's cover — the still shown before the video plays — with what that channel takes (`accepts`), why it takes none (`reason`), and the frame the platform falls back to. Requires `post:read`.

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

### Path parameters

- `groupId` (string, required) — The post's composer group id.

### Responses

#### 200

The post's covers

- `groupId` (string, required)
- `channels` (PostCoverChannel[], required)
  - `accountId` (string, required)
  - `platform` (string, required)
  - `accepts` (string[], required) — What this channel's post takes: Instagram Reels and Pinterest video Pins take a frame or an image, TikTok videos a frame. Empty when it takes no cover — `reason` says why.
  - `reason` (string | any | null, required)
  - `defaultFrameMs` (integer | any | null, required) — The frame the platform shows when sent no cover (Instagram: the first frame; TikTok: one second in).
  - `videoDurationMs` (integer | any | null, required)
  - `cover` (object | any | null, required) — The cover that will publish. Null: the platform's default frame.
  - `editable` (boolean, required) — False once this channel's post has gone out.

#### 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)

## Set a post's video cover

```http
PUT https://orbitwrite.com/api/v1/posts/{groupId}/cover
```

Pick the cover for a saved post that isn't out yet: `{ frameMs }` for a frame of the video, `{ imageUrl }` or `{ mediaId }` for an image (Instagram Reels and Pinterest only — a JPEG; upload it with `/media` first), or `null` for the platform's default. Without `accountIds` it lands on every channel that takes that kind of cover; a named channel that can't is a 400. A frame past the end of the video is a 400, and a post that has already gone out is a 409. Requires `post:update`.

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

### Path parameters

- `groupId` (string, required) — The post's composer group id.

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

- `accountIds` (string[], optional) — The channels to change. Omit for every channel on the post that takes this kind of cover.
- `cover` (object | any | null, required) — `{ frameMs }` picks a frame of the video, `{ imageUrl }` or `{ mediaId }` an image (Instagram and Pinterest), `null` clears it back to the platform's default.

### Responses

#### 200

The post's covers after the change

- `groupId` (string, required)
- `channels` (PostCoverChannel[], required)
  - `accountId` (string, required)
  - `platform` (string, required)
  - `accepts` (string[], required) — What this channel's post takes: Instagram Reels and Pinterest video Pins take a frame or an image, TikTok videos a frame. Empty when it takes no cover — `reason` says why.
  - `reason` (string | any | null, required)
  - `defaultFrameMs` (integer | any | null, required) — The frame the platform shows when sent no cover (Instagram: the first frame; TikTok: one second in).
  - `videoDurationMs` (integer | any | null, required)
  - `cover` (object | any | null, required) — The cover that will publish. Null: the platform's default frame.
  - `editable` (boolean, required) — False once this channel's post has gone out.
- `updated` (string[], required) — The channels this call changed.

#### 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)

## Load a draft / scheduled / published group for the composer

```http
GET https://orbitwrite.com/api/v1/posts/draft
```

Loads the whole composer state for a group (accounts, threads, media, engagement, Info-panel metadata) — the full-content read-back for a post. Also performs on-open recovery: an all-failed group converts back to an editable draft, and a scheduled group stuck long past its fire time (worker down, attempts exhausted) does too.

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

### Query parameters

- `groupId` (string, required) — Composer group id.

### Responses

#### 200

The composer state for the group

- `ok` (boolean, required, one of `true`)
- `groupId` (string, required)
- `status` (string, required, one of `draft`, `planned`, `scheduled`, `pending_approval`, `published`, `failed`, `mixed`) — `failed` is only returned to a caller holding post:read but not post:update — the on-open recovery that would convert a failed group into an editable draft is a write, so it is skipped and the real state reported. Every workspace role holds post:update, so the app never sees it.
- `scheduledAt` (string | null, required) — ISO 8601; null for plain drafts (their stored fire time is a far-future sentinel) and mixed groups.
- `title` (string | null, required)
- `templateId` (string | null, required) — The post template the draft was started from (see GET /post-templates), or null.
- `tags` (string[], required)
- `tagColors` (object, required)
- `campaignId` (string | null, required)
- `approvalStatus` (string, required)
- `approvalGateStatus` (string, required)
- `accounts` (DraftAccount[], required)
  - `accountId` (string, required)
  - `isSynced` (boolean, required)
  - `items` (DraftThreadItem[], required)
    - `id` (string, required)
    - `content` (string, required)
    - `threadPosition` (integer, required)
    - `poll` (any, optional)
    - `media` (any[], optional)
    - `quote` (string, optional)
    - `replyTo` (string, optional)
    - `linkPreview` (any, optional)
  - `autoEngagement` (any, optional)
  - `replySettings` (string, optional)
  - `postFormat` (string, optional, one of `post`, `story`, `reel`, `short`)
  - `platformOptions` (any, optional)
  - `platformUrl` (string | null, optional)
  - `lastError` (string | null, optional)

#### 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)

## Save (or replace) a draft composer group

```http
POST https://orbitwrite.com/api/v1/posts/draft
```

The composer's autosave, and the way an integration creates or edits an unscheduled draft. Replace-on-save: with `groupId` every editable row in the group is deleted and re-inserted, preserving the group's status (a scheduled group stays scheduled), per-account fire times, createdAt, and approval gate. Without `groupId` a fresh group is created. Refuses groups past the editable lifecycle. Items accept `mediaIds` for files uploaded through /media/create-upload-url, resolved server-side like POST /posts.

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

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

- `groupId` (string, optional)
- `accounts` (object[], required)
  - `accountId` (string, required)
  - `items` (object[], required)
    - `id` (string, required)
    - `content` (string, required)
    - `poll` (object, optional)
      - `options` (string[], required)
      - `durationMinutes` (integer, required)
    - `media` (object[], optional)
      - `url` (string, required)
      - `mimeType` (string, required)
      - `mediaId` (string, optional)
      - `altText` (string, optional)
      - `width` (integer, optional)
      - `height` (integer, optional)
      - `bytes` (integer, optional)
      - `durationSec` (number, optional)
      - `sourceUrl` (string, optional)
      - `videoUrl` (string, optional)
      - `title` (string, optional)
      - `pageCount` (integer, optional)
    - `mediaIds` (string[], optional)
    - `quote` (string, optional)
    - `replyTo` (string, optional)
    - `linkPreview` (object, optional)
      - `url` (string, required)
      - `matchUrl` (string, required)
      - `title` (string | null, required)
      - `description` (string | null, required)
      - `image` (string | null, required)
      - `siteName` (string | null, required)
  - `isSynced` (boolean, optional)
  - `autoEngagement` (object, optional)
    - `comment` (object, required)
      - `enabled` (boolean, required)
      - `entries` (object[], optional)
        - `fromAccountId` (string, required)
        - `delayMinutes` (integer, required)
        - `items` (object[], optional)
          - `id` (string, required)
          - `content` (string, required)
          - `media` (object[], optional)
            - `url` (string, required)
            - `mimeType` (string, required)
            - `mediaId` (string, optional)
            - `altText` (string, optional)
            - `width` (integer, optional)
            - `height` (integer, optional)
            - `bytes` (integer, optional)
            - `durationSec` (number, optional)
            - `sourceUrl` (string, optional)
            - `videoUrl` (string, optional)
            - `title` (string, optional)
            - `pageCount` (integer, optional)
          - `mediaIds` (string[], optional)
          - `poll` (object, optional)
            - `options` (string[], required)
            - `durationMinutes` (integer, required)
        - `replyTarget` (string, optional, one of `thread_tail`, `root`)
      - `delayMinutes` (integer, optional)
      - `fromAccountId` (string | null, optional)
      - `items` (object[], optional)
        - `id` (string, required)
        - `content` (string, required)
        - `media` (object[], optional)
          - `url` (string, required)
          - `mimeType` (string, required)
          - `mediaId` (string, optional)
          - `altText` (string, optional)
          - `width` (integer, optional)
          - `height` (integer, optional)
          - `bytes` (integer, optional)
          - `durationSec` (number, optional)
          - `sourceUrl` (string, optional)
          - `videoUrl` (string, optional)
          - `title` (string, optional)
          - `pageCount` (integer, optional)
        - `mediaIds` (string[], optional)
        - `poll` (object, optional)
          - `options` (string[], required)
          - `durationMinutes` (integer, required)
      - `text` (string, optional)
      - `media` (object[], optional)
        - `url` (string, required)
        - `mimeType` (string, required)
        - `mediaId` (string, optional)
        - `altText` (string, optional)
        - `width` (integer, optional)
        - `height` (integer, optional)
        - `bytes` (integer, optional)
        - `durationSec` (number, optional)
        - `sourceUrl` (string, optional)
        - `videoUrl` (string, optional)
        - `title` (string, optional)
        - `pageCount` (integer, optional)
      - `poll` (object, optional)
        - `options` (string[], required)
        - `durationMinutes` (integer, required)
    - `like` (object, required)
      - `enabled` (boolean, required)
      - `entries` (object[], optional)
        - `fromAccountId` (string, required)
        - `delayMinutes` (integer, required)
        - `reaction` (string, optional, one of `LIKE`, `LOVE`, `CELEBRATE`, `INSIGHTFUL`, `CURIOUS`, `SUPPORT`)
      - `delayMinutes` (integer, optional)
      - `fromAccountIds` (string[], optional)
      - `reaction` (string, optional, one of `LIKE`, `LOVE`, `CELEBRATE`, `INSIGHTFUL`, `CURIOUS`, `SUPPORT`)
    - `repost` (object, required)
      - `enabled` (boolean, required)
      - `entries` (object[], optional)
        - `fromAccountId` (string, required)
        - `delayMinutes` (integer, required)
      - `delayMinutes` (integer, optional)
      - `fromAccountId` (string | null, optional)
    - `slack` (object, optional)
      - `enabled` (boolean, required)
      - `entries` (object[], optional)
        - `fromAccountId` (string, required)
        - `delayMinutes` (integer, required)
        - `items` (object[], optional)
          - `id` (string, required)
          - `content` (string, required)
          - `media` (object[], optional)
            - `url` (string, required)
            - `mimeType` (string, required)
            - `mediaId` (string, optional)
            - `altText` (string, optional)
            - `width` (integer, optional)
            - `height` (integer, optional)
            - `bytes` (integer, optional)
            - `durationSec` (number, optional)
            - `sourceUrl` (string, optional)
            - `videoUrl` (string, optional)
            - `title` (string, optional)
            - `pageCount` (integer, optional)
          - `mediaIds` (string[], optional)
          - `poll` (object, optional)
            - `options` (string[], required)
            - `durationMinutes` (integer, required)
    - `discord` (object, optional)
      - `enabled` (boolean, required)
      - `entries` (object[], optional)
        - `fromAccountId` (string, required)
        - `delayMinutes` (integer, required)
        - `items` (object[], optional)
          - `id` (string, required)
          - `content` (string, required)
          - `media` (object[], optional)
            - `url` (string, required)
            - `mimeType` (string, required)
            - `mediaId` (string, optional)
            - `altText` (string, optional)
            - `width` (integer, optional)
            - `height` (integer, optional)
            - `bytes` (integer, optional)
            - `durationSec` (number, optional)
            - `sourceUrl` (string, optional)
            - `videoUrl` (string, optional)
            - `title` (string, optional)
            - `pageCount` (integer, optional)
          - `mediaIds` (string[], optional)
          - `poll` (object, optional)
            - `options` (string[], required)
            - `durationMinutes` (integer, required)
    - `dm` (object, optional)
      - `enabled` (boolean, required)
      - `message` (string, optional)
      - `triggers` (string[], optional)
      - `match` (string, optional, one of `all`, `any`)
      - `requireFollow` (boolean, optional)
      - `replyKeyword` (string, optional)
      - `durationDays` (integer, optional)
  - `replySettings` (string, optional, one of `everyone`, `following`, `mentionedUsers`, `subscribers`, `verified`, `accounts_you_follow`, `mentioned_only`, `followers_only`, `parent_post_author_only`)
  - `postFormat` (string, optional, one of `post`, `story`, `reel`, `short`)
  - `platformOptions` (object, optional)
    - `tiktok` (object, optional)
      - `title` (string, optional)
      - `privacyLevel` (string, optional, one of `PUBLIC_TO_EVERYONE`, `MUTUAL_FOLLOW_FRIENDS`, `FOLLOWER_OF_CREATOR`, `SELF_ONLY`)
      - `disableComment` (boolean, optional)
      - `disableDuet` (boolean, optional)
      - `disableStitch` (boolean, optional)
      - `brandOrganicToggle` (boolean, optional)
      - `brandContentToggle` (boolean, optional)
      - `isAigc` (boolean, optional) — Label as AI-generated ("Creator labeled as AI-generated").
      - `videoCoverTimestampMs` (integer, optional)
      - `photoCoverIndex` (integer, optional)
      - `autoAddMusic` (boolean, optional)
    - `threads` (object, optional)
      - `shareToInstagram` (boolean, optional)
      - `topicTag` (string, optional) — The post's topic (Threads' `topic_tag`): 1 to 50 characters, no periods or ampersands. A leading # is ignored. Set on the first post of a thread only.
    - `youtube` (object, optional)
      - `title` (string, optional)
      - `privacyStatus` (string, optional, one of `public`, `unlisted`, `private`)
      - `categoryId` (string, optional)
      - `tags` (string[], optional)
      - `madeForKids` (boolean, optional)
      - `containsSyntheticMedia` (boolean, optional) — Altered or synthetic content: realistic video made or changed with AI (a real person, place or event).
    - `instagram` (object, optional)
      - `videoCoverTimestampMs` (integer, optional)
      - `coverImageUrl` (string, optional)
      - `isAiGenerated` (boolean, optional) — Label as AI-generated ("AI info"). Not applied to stories.
      - `userTags` (object[], optional) — People tagged in the media, not caption @mentions. x/y: 0-1 from the photo's top left (default centre). mediaUrl: which attachment (default the first). 20 per item max, public accounts only.
        - `username` (string, required)
        - `x` (number, optional)
        - `y` (number, optional)
        - `mediaUrl` (string, optional)
      - `collaborators` (string[], optional) — Up to 3 usernames invited as collaborators. Not applied to stories.
    - `twitter` (object, optional)
      - `madeWithAi` (boolean, optional) — Label "Made with AI". Sent only when the first post has media.
    - `pinterest` (object, optional)
      - `boardId` (string, optional) — The board to save the Pin to. Required to publish.
      - `boardSectionId` (string, optional) — A section of that board.
      - `title` (string, optional) — The Pin's title. The text is its description.
      - `link` (string, optional) — Where the Pin opens when tapped.
      - `videoCoverTimestampMs` (integer, optional)
      - `coverImageUrl` (string, optional)
      - `isAiModified` (boolean, optional) — Label the Pin "AI modified" (Pinterest's ai_disclosures).
  - `aiGenerated` (boolean, optional) — Label as AI-generated where the network has a label (TikTok, Instagram, X, YouTube, Pinterest).
  - `plannedAt` (string, optional)
- `info` (object, optional)
  - `title` (string, optional)
  - `tags` (string[], optional)
  - `tagColors` (object, optional)
  - `campaignId` (string | null, optional)
  - `templateId` (string | null, optional)
- `plannedAt` (string, optional)

### Responses

#### 200

The draft was saved

- `ok` (boolean, required, one of `true`)
- `groupId` (string, required)
- `count` (integer, required)
- `groupIds` (string[], required) — Every group the save wrote, `groupId` first. More than one only when a plan put channels at different times: each other time is a post of its own, carved out of `groupId`.
- `warnings` (string[], optional) — What a network will do to the post that you didn't choose, such as Instagram cropping carousel slides to the first slide's shape. The post was saved as sent. Omitted when empty.

#### 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)

## Discard a draft group

```http
DELETE https://orbitwrite.com/api/v1/posts/draft
```

Deletes the group's draft rows only — rows in any other status are untouched.

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

### Query parameters

- `groupId` (string, required) — Composer group id.

### Responses

#### 200

The draft rows were deleted

- `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)

## Update a group's Info-panel metadata (title / tags / campaign)

```http
PATCH https://orbitwrite.com/api/v1/posts/{groupId}/info
```

Updates organizational metadata for a composer group regardless of lifecycle status — including published posts, which the draft save refuses. Never touches post content, scheduling, or approval. Only the fields present in the body are changed.

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

### Path parameters

- `groupId` (string, required)

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

- `title` (string | null, optional)
- `tags` (string[], optional)
- `tagColors` (object, optional)
- `campaignId` (string | null, optional)
- `templateId` (string | null, optional)

### Responses

#### 200

The metadata was updated

- `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)

## Move a post within its board column

```http
POST https://orbitwrite.com/api/v1/posts/reorder
```

Places a post in a hand-ordered board column — Drafts, Needs approval, Needs revision or Approved. Name the post it now sits directly below (`aboveGroupId`), or, when it went to the top, the one it now sits directly above (`belowGroupId`); if both are given, `aboveGroupId` wins. The order is shared by the whole workspace. Scheduled and Posted are ordered by time, so a position there has no effect; published posts are refused with 409.

**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`)

- `groupId` (string, required)
- `aboveGroupId` (string | null, optional)
- `belowGroupId` (string | null, optional)

### Responses

#### 200

The post was placed

- `groupId` (string, required)
- `boardPosition` (number, required) — The group's new key, in the space `boardPosition` on `GET /posts` entries uses.

#### 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)

## Plan a post for a time (or replan it)

```http
POST https://orbitwrite.com/api/v1/posts/plan
```

Gives a draft a slot without scheduling it: the post becomes `planned`, shows on the calendar and takes the slot in the posting queue, but nothing publishes it until it is scheduled (`POST /posts/reschedule`) or posted now. Replanning an already-planned post moves the whole group, keeping per-channel staggered times relative. Empty content is allowed. `plannedAt` must be in the future; a scheduled post must be unscheduled first (409).

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

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

- `groupId` (string, required)
- `plannedAt` (string, required)

### Responses

#### 200

The post is planned

- `groupId` (string, required)
- `plannedAt` (string, required) — ISO 8601. The time the group's earliest channel is planned for.
- `count` (integer, 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)

## Return a planned post to drafts

```http
POST https://orbitwrite.com/api/v1/posts/unplan
```

Moves a planned post back to draft, freeing its slot. An unresolved blocking approval request keeps gating the draft; a resolved one is dissolved, as an unschedule does. Anything not planned is refused with 409.

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

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

- `groupId` (string, required)

### Responses

#### 200

The post is a draft again

- `groupId` (string, required)
- `count` (integer, 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)

## Get a composer group's approval request

```http
GET https://orbitwrite.com/api/v1/posts/{groupId}/approval-request
```

Returns the live reviewer-based approval request on the group (or null). Self-heals: a post held for approval with no request gets one with its author as sole reviewer, so older campaign posts render the same as manually-requested ones.

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

### Path parameters

- `groupId` (string, required)

### Responses

#### 200

The group's approval request, or null when there is none

- `request` (ApprovalRequestSummary | any | null, required)

#### 401

Not signed in

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

#### 403

Not a member of the organization

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

## Create, edit, or manage a composer group's approval request

```http
POST https://orbitwrite.com/api/v1/posts/{groupId}/approval-request
```

One intent-dispatched endpoint for the Info panel's approval row: create/edit the request (reviewer roster, message, due date, require-all, blocking), delete it, resend a reviewer's email, remove a reviewer, override a reviewer's decision, or reset all decisions. A missing intent defaults to create.

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

### Path parameters

- `groupId` (string, required)

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

### Responses

#### 200

The intent was applied

- `ok` (boolean, required, one of `true`)
- `requestId` (string, optional) — Present on create/edit.
- `status` (string, optional, one of `pending`, `approved`, `rejected`) — Present on override-reviewer/reset.
- `removed` (boolean, optional) — True when removing the last reviewer dissolved the request.

#### 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)

#### 405

Method not allowed

- `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)

## List a workspace's approval requests

```http
GET https://orbitwrite.com/api/v1/approval-requests
```

One entry per post (composer group) that carries an approval request — its active one, the same request `GET /posts/{groupId}/approval-request` returns — newest first. Each entry has the reviewer roster and the caller's own reviewer state, so a client can tell which posts it may approve, manage or toggle without a read per post. For an API key the viewer fields reflect only its scopes: a key is never on a roster. Cursor-paged.

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

### Query parameters

- `organizationId` (string, optional) — Session callers only — an API key already determines the workspace.
- `limit` (integer, optional)
- `cursor` (string, optional) — Opaque cursor from a previous response's `nextCursor`. Omit for the first page.
- `status` (string | string[], optional) — The request's resolved status. Repeat for OR (e.g. `?status=pending&status=rejected`). Omit for all.

### Responses

#### 200

A page of approval requests

- `entries` (ApprovalRequestListEntry[], required)
  - `id` (string, required)
  - `status` (string, required, one of `pending`, `approved`, `rejected`)
  - `requireAll` (boolean, required)
  - `blocking` (boolean, required)
  - `publishOnApprove` (boolean, required) — The author asked for this to go out the moment it's approved, rather than at its scheduled time.
  - `message` (string | null, required)
  - `dueDate` (string | null, required) — ISO 8601.
  - `createdBy` (string, required)
  - `reviewerCount` (integer, required)
  - `approvedCount` (integer, required)
  - `rejectedCount` (integer, required)
  - `reviewers` (ApprovalReviewerSummary[], required)
    - `id` (string, required)
    - `userId` (string, required)
    - `name` (string, required)
    - `image` (string | null, required)
    - `decision` (string | null, required, one of `approved`, `rejected`, `null`) — null → the reviewer hasn't decided yet.
  - `viewerIsReviewer` (boolean, required)
  - `viewerDecision` (string | null, optional, one of `approved`, `rejected`, `null`) — Absent → the viewer isn't a reviewer; null → they haven't decided.
  - `viewerCanManage` (boolean, required) — The viewer holds `approval:decide` or is on the roster — what the Info panel offers management controls on.
  - `viewerCanDecide` (boolean, required) — The viewer holds `approval:decide`, the authority resetting the request or overriding a reviewer actually requires. Narrower than `viewerCanManage` for a reviewer without it.
  - `groupId` (string, required) — The composer group the request hangs off — what the group-addressed post routes take.
- `nextCursor` (string | null, required) — Pass back as `cursor` for the next page; null on the last page.

#### 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)
