API reference
34 endpoints, served at https://orbitwrite.com/api/v1. Each one lists the credentials it accepts, its parameters and every response it can give.
/api/v1/posts/scheduled-timesGET/api/v1/posts/recent-by-accountGET/api/v1/posts/searchGET/api/v1/posts/{postId}/analyticsGET/api/v1/posts/upcomingPOST/api/v1/posts/discardPOST/api/v1/posts/delete-publishedPOST/api/v1/posts/post-nowPOST/api/v1/posts/unschedulePOST/api/v1/posts/rescheduleGET/api/v1/posts/publish-statusPOST/api/v1/posts/retry-publishPOST/api/v1/posts/split-accountPOST/api/v1/posts/ungroupPOST/api/v1/posts/draft/duplicatePOST/api/v1/posts/infoPOST/api/v1/posts/{groupId}/approvalGET/api/v1/postsPOST/api/v1/postsGET/api/v1/posts/allPOST/api/v1/posts/scoreGET/api/v1/posts/{groupId}/scoreGET/api/v1/posts/{groupId}/coverPUT/api/v1/posts/{groupId}/coverGET/api/v1/posts/draftPOST/api/v1/posts/draftDEL/api/v1/posts/draftPATCH/api/v1/posts/{groupId}/infoPOST/api/v1/posts/reorderPOST/api/v1/posts/planPOST/api/v1/posts/unplanGET/api/v1/posts/{groupId}/approval-requestPOST/api/v1/posts/{groupId}/approval-requestGET/api/v1/approval-requestsEvery 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.
An API key or OAuth access token as a bearer token, or a signed-in session.
organizationIdstringSession callers only — an API key already determines the workspace.
At least 1 character.
200ScheduledPostTimeListUpcoming scheduled times, nearest first
itemsScheduledPostTime[]RequiredaccountIdstringRequiredscheduledAtstringRequiredISO 8601 instant the post is due to fire.
groupIdstring | nullRequiredThe post (composer group) holding the slot, so a caller moving that post can leave its own slots out.
curl -X GET "https://orbitwrite.com/api/v1/posts/scheduled-times" \
-H "Authorization: Bearer $ORBITWRITE_API_KEY"{
"items": [
{
"accountId": "string",
"scheduledAt": "string",
"groupId": "string"
}
]
}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.
An API key or OAuth access token as a bearer token, or a signed-in session.
organizationIdstringRequiredAt least 1 character.
accountIdstringRequiredAt least 1 character.
limitintegerBetween 1 and 24.
200RecentPostsResultThe account's published posts, newest first
itemsRecentPostItem[]RequiredidstringRequiredtextstringRequiredpermalinkstring | nullRequiredThe post's URL on the platform, when known.
publishedAtstring | nullRequiredISO 8601 publish time, or null if the row never recorded one.
curl -X GET "https://orbitwrite.com/api/v1/posts/recent-by-account?organizationId=organizationId&accountId=accountId" \
-H "Authorization: Bearer $ORBITWRITE_API_KEY"{
"items": [
{
"id": "string",
"text": "string",
"permalink": "string",
"publishedAt": "string",
"media": [
{
"url": "string",
"mimeType": "string",
"width": 0,
"height": 0,
"altText": "string"
}
]
}
]
}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.
An API key or OAuth access token as a bearer token, or a signed-in session.
organizationIdstringRequiredAt least 1 character.
qstringRequiredAt least 1 character.
200SearchPostsResultMatching posts, newest first
itemsSearchPostItem[]RequiredidstringRequiredComposer group id, or the post id for an ungrouped post — matches the ?open= deep link param.
titlestring | nullRequiredtextstringRequiredstatusstringRequired"draft""planned""pending_approval""scheduled""publishing""published"approvalStatusstring | nullRequiredThe post's own publish gate. A non-blocking review never writes it, so do NOT derive display state from this — use bucket.
bucketstringRequiredWhich 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.
"drafts""needs_approval""needs_revision""approved""planned""scheduled""posted"lastErrorstring | nullRequiredMost 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.
curl -X GET "https://orbitwrite.com/api/v1/posts/search?organizationId=organizationId&q=q" \
-H "Authorization: Bearer $ORBITWRITE_API_KEY"{
"items": [
{
"id": "string",
"title": "string",
"text": "string",
"status": "draft",
"approvalStatus": "string",
"bucket": "drafts",
"lastError": "string"
}
]
}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.
An API key or OAuth access token as a bearer token, or a signed-in session.
postIdstringRequiredcurl -X GET "https://orbitwrite.com/api/v1/posts/{postId}/analytics" \
-H "Authorization: Bearer $ORBITWRITE_API_KEY"{
"ok": true,
"metrics": [
{
"key": "impressions",
"label": "string",
"value": 0,
"format": "count"
}
],
"fetchedAt": "string",
"expiresAt": "string"
}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.
A signed-in session only. The Orbitwrite app calls this; an API key is refused.
organizationIdstringWorkspace to scope the read to; defaults to the session's active organization.
At least 1 character.
200UpcomingPostListThe caller's upcoming post groups, earliest first
groupsUpcomingPostGroup[]RequiredcomposerGroupIdstringRequiredfiresAtstringRequiredEarliest scheduled_at in this group, ISO 8601
teamSlugstring | nullRequiredWorkspace slug the group belongs to; used to deep-link into the right team's schedule.
curl -X GET "https://orbitwrite.com/api/v1/posts/upcoming" \
-H "Authorization: Bearer $ORBITWRITE_API_KEY"{
"groups": [
{
"composerGroupId": "string",
"firesAt": "string",
"teamSlug": "string",
"accounts": [
{
"id": "string",
"platform": "string",
"handle": "string",
"displayName": "string",
"avatarUrl": "string"
}
]
}
]
}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).
An API key or OAuth access token as a bearer token, or a signed-in session.
groupIdstringRequiredAt least 1 character.
200PostGroupMutationResultThe mutation was applied to every row in the group
curl -X POST "https://orbitwrite.com/api/v1/posts/discard" \
-H "Authorization: Bearer $ORBITWRITE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"groupId": "string"
}'{
"ok": true,
"groupId": "string",
"count": 0
}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.
An API key or OAuth access token as a bearer token, or a signed-in session.
groupIdstringRequiredAt least 1 character.
postIdstringAt least 1 character.
deleteFromAccountIdsstring[]200DeletePublishedResultDelete attempted on every published row; check failures for per-account errors
curl -X POST "https://orbitwrite.com/api/v1/posts/delete-published" \
-H "Authorization: Bearer $ORBITWRITE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"groupId": "string"
}'{
"ok": true,
"groupId": "string",
"deleted": 0,
"failures": [
{
"postId": "string",
"accountId": "string",
"platform": "string",
"reason": "string"
}
]
}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.
An API key or OAuth access token as a bearer token, or a signed-in session.
groupIdstringRequiredAt least 1 character.
200PostGroupMutationResultThe mutation was applied to every row in the group
curl -X POST "https://orbitwrite.com/api/v1/posts/post-now" \
-H "Authorization: Bearer $ORBITWRITE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"groupId": "string"
}'{
"ok": true,
"groupId": "string",
"count": 0
}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.
An API key or OAuth access token as a bearer token, or a signed-in session.
groupIdstringRequiredAt least 1 character.
removeApprovalbooleanAlso dissolve the group's approval request in the same transaction, landing the post as a plain (ungated) draft.
200PostGroupMutationResultThe mutation was applied to every row in the group
curl -X POST "https://orbitwrite.com/api/v1/posts/unschedule" \
-H "Authorization: Bearer $ORBITWRITE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"groupId": "string"
}'{
"ok": true,
"groupId": "string",
"count": 0
}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.
An API key or OAuth access token as a bearer token, or a signed-in session.
groupIdstringRequiredAt least 1 character.
scheduledAtstringRequiredFormat date-time.
200RescheduleResultThe group was rescheduled
okbooleanRequiredgroupIdstringRequiredscheduledAtstringRequiredcountintegerRequiredcurl -X POST "https://orbitwrite.com/api/v1/posts/reschedule" \
-H "Authorization: Bearer $ORBITWRITE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"groupId": "string",
"scheduledAt": "2026-10-01T09:00:00Z"
}'{
"ok": true,
"groupId": "string",
"scheduledAt": "string",
"count": 0
}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.
An API key or OAuth access token as a bearer token, or a signed-in session.
groupsstringRequiredComma-separated composer group ids.
200PublishStatusResultPer-account publish progress
okbooleanRequireddonebooleanRequiredaccountsPublishStatusAccount[]RequiredaccountIdstringRequiredcomposerGroupIdstring | nullRequiredplatformstringRequiredhandlestring | nullRequireddisplayNamestring | nullRequiredavatarUrlstring | nullRequiredstatusstringRequired"publishing""published""failed"errorstring | nullRequiredwarningstring | nullRequiredplatformUrlstring | nullRequiredThe live post on the network, once published — null until then, or when the platform returns no permalink.
platformPostIdstring | nullRequiredThe network's own id for the post, once published.
curl -X GET "https://orbitwrite.com/api/v1/posts/publish-status?groups=groups" \
-H "Authorization: Bearer $ORBITWRITE_API_KEY"{
"ok": true,
"done": true,
"accounts": [
{
"accountId": "string",
"composerGroupId": "string",
"platform": "string",
"handle": "string",
"displayName": "string",
"avatarUrl": "string",
"status": "publishing",
"error": "string",
"warning": "string",
"platformUrl": "string",
"platformPostId": "string"
}
]
}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.
An API key or OAuth access token as a bearer token, or a signed-in session.
200RetryPublishResultThe account's unit was re-queued and the publish re-fired
okbooleanRequiredgroupIdstringRequiredaccountIdstringRequiredpendingApprovalbooleanRequiredTrue 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.
curl -X POST "https://orbitwrite.com/api/v1/posts/retry-publish" \
-H "Authorization: Bearer $ORBITWRITE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"groupId": "string",
"accountId": "string"
}'{
"ok": true,
"groupId": "string",
"accountId": "string",
"pendingApproval": true
}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.
An API key or OAuth access token as a bearer token, or a signed-in session.
groupIdstringRequiredAt least 1 character.
200UngroupResultThe group was split (or a no-op for single-account groups)
curl -X POST "https://orbitwrite.com/api/v1/posts/ungroup" \
-H "Authorization: Bearer $ORBITWRITE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"groupId": "string"
}'{
"ok": true,
"groups": {}
}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.
An API key or OAuth access token as a bearer token, or a signed-in session.
sourceGroupIdstringRequiredAt least 1 character.
200PostGroupMutationResultThe mutation was applied to every row in the group
curl -X POST "https://orbitwrite.com/api/v1/posts/draft/duplicate" \
-H "Authorization: Bearer $ORBITWRITE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"sourceGroupId": "string"
}'{
"ok": true,
"groupId": "string",
"count": 0
}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.
An API key or OAuth access token as a bearer token, or a signed-in session.
groupIdstringRequiredAt least 1 character.
infoobjectRequiredtitlestringAt most 200 characters.
tagColorsobjectcampaignIdstring | nullAt least 1 character.
templateIdstring | nullBetween 1 and 100 characters.
200PostInfoResultThe metadata was updated
okbooleanRequiredcurl -X POST "https://orbitwrite.com/api/v1/posts/info" \
-H "Authorization: Bearer $ORBITWRITE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"groupId": "string",
"info": {
"title": "string",
"tags": [
"string"
],
"tagColors": {},
"campaignId": "string",
"templateId": "string"
}
}'{
"ok": true
}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.
An API key or OAuth access token as a bearer token, or a signed-in session.
groupIdstringRequiredAt least 1 character.
200PostApprovalDecisionResultThe decision was recorded
okbooleanRequireddecisionstringRequired"approved""rejected"requestStatusstring"pending""approved""rejected"postCountintegerpublishingbooleanPresent when the approval sent a publishOnApprove post out: the adapter publish runs detached — poll /posts/publish-status with groupIds.
groupIdsstring[]The groups now publishing. Present with publishing.
curl -X POST "https://orbitwrite.com/api/v1/posts/{groupId}/approval" \
-H "Authorization: Bearer $ORBITWRITE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"decision": "approved"
}'{
"ok": true,
"decision": "approved",
"requestStatus": "pending",
"postCount": 0,
"publishing": true,
"groupIds": [
"string"
]
}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=.
An API key or OAuth access token as a bearer token, or a signed-in session.
organizationIdstringSession callers only — an API key already determines the workspace.
At least 1 character.
limitintegerBetween 1 and 100. Defaults to 50.
cursorstringOpaque cursor from a previous response's nextCursor. Omit for the first page.
sortstringOrder 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.
Defaults to "createdAt".
"createdAt""scheduledAt"directionstringNewest/latest first by default.
Defaults to "desc".
"asc""desc"statusstring | string[]Lifecycle status. Repeat for OR (e.g. ?status=draft&status=scheduled).
bucketstring | string[]Board column. Approval state outranks the lifecycle status, so a scheduled post awaiting review is needs_approval, not scheduled. Repeat for OR.
platformstring | string[]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.
accountIdstring | string[]Connected-account id. Matches a post going out on ANY of its channels. Repeat for OR.
campaignIdstringgroupIdstring | string[]Only these posts, by the id each entry carries. Repeat for OR; at most 100.
scoreGradestring | string[]Post score grade (see scoreGrade on each entry). Repeat for OR, e.g. ?scoreGrade=fair&scoreGrade=needs_work for the posts worth another look.
scheduledAfterstringISO 8601, inclusive, against the group's earliest fire time. Drafts have no fire time and are excluded by either bound.
scheduledBeforestringISO 8601, inclusive.
previewAccountIdstringA 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.
200PostListA page of posts
entriesPostListEntry[]RequiredidstringRequiredThe composer group's id — what every group-addressed route (/posts/draft, /posts/reschedule, /posts/discard) takes.
postIdstringRequiredThe representative row's own post id, for the post-addressed routes (/posts/{postId}/analytics).
titlestring | nullRequiredtextstringRequiredThe 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.
statusstringRequired"draft""planned""pending_approval""scheduled""publishing""published"approvalStatusstringRequiredWhat the post READS as — its active approval request's status whether or not that request blocks. bucket is derived from this.
"not_required""pending""approved""rejected"approvalGateStatusstringRequiredThe post's own approval column: the publish gate, which a non-blocking review never writes. Ask this when the question is 'would this publish?'.
"not_required""pending""approved""rejected"bucketstringRequiredWhich board column the post sits in.
"drafts""needs_approval""needs_revision""approved""planned""scheduled""posted"scheduledAtstring | nullRequiredISO 8601 — the group's earliest fire time. Null for a draft that has never been given one.
publishedAtstring | nullRequiredISO 8601.
createdAtstringRequiredISO 8601.
updatedAtstringRequiredISO 8601.
boardPositionnumber | nullRequiredWhere 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.
hasThreadbooleanRequiredpreviewAccountIdstringRequiredThe 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.
mediaCountintegerRequiredmediaPostListMedia[]RequiredThe 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.
pollobject | any | nullRequiredoptionsstring[]RequireddurationMinutesnumberRequiredquoteUrlstring | nullRequiredThe post this one quotes, if any.
platformUrlstring | nullRequiredThe preview row's permalink once published. Every channel's own is on accounts.
lastErrorstring | nullRequiredMost recent worker error, suppressed once the post published (a retry that eventually succeeded leaves its error behind).
issuesobject[]RequiredEvery 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.
campaignobject | any | nullRequiredThe campaign this post belongs to, if any.
accountsPostListAccount[]RequiredEvery channel this post goes out on.
idstringRequiredplatformstringRequiredhandlestring | nullRequireddisplayNamestring | nullRequiredavatarUrlstring | nullRequiredplatformUrlstring | nullRequiredThis channel's permalink to the published post, once the network returned one.
statusstringRequiredThis 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.
"draft""planned""pending_approval""scheduled""publishing""published"lastErrorstring | nullRequiredThis 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.
isEmptybooleanRequiredThis 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.
charCountintegerRequiredLength 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.
charLimitintegerRequiredThe 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.
scoreinteger | nullRequiredThe 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.
scoreGradestring | nullRequiredThe score's grade: excellent (90+), good (75+), fair (50+) or needs_work.
"excellent""good""fair""needs_work"nullnextCursorstring | nullRequiredPass 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.
curl -X GET "https://orbitwrite.com/api/v1/posts" \
-H "Authorization: Bearer $ORBITWRITE_API_KEY"{
"entries": [
{
"id": "string",
"postId": "string",
"title": "string",
"text": "string",
"status": "draft",
"approvalStatus": "not_required",
"approvalGateStatus": "not_required",
"bucket": "drafts",
"scheduledAt": "string",
"publishedAt": "string",
"createdAt": "string",
"updatedAt": "string",
"boardPosition": 0,
"hasThread": true,
"previewAccountId": "string",
"mediaCount": 0,
"media": [
{
"url": "string",
"mimeType": "string",
"width": 0,
"height": 0,
"altText": "string",
"title": "string"
}
],
"poll": {
"options": [
"string"
],
"durationMinutes": 0
},
"quoteUrl": "string",
"platformUrl": "string",
"author": {
"id": "string",
"name": "string",
"image": "string"
},
"lastError": "string",
"issues": [
{
"kind": "empty",
"accountId": "string",
"message": "string"
}
],
"campaign": {
"id": "string",
"title": "string",
"color": "string"
},
"tags": [
{
"name": "string",
"color": "string"
}
],
"accounts": [
{
"id": "string",
"platform": "string",
"handle": "string",
"displayName": "string",
"avatarUrl": "string",
"platformUrl": "string",
"status": "draft",
"lastError": "string",
"isEmpty": true,
"charCount": 0,
"charLimit": 0
}
],
"score": 0,
"scoreGrade": "excellent"
}
],
"nextCursor": "string"
}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.
An API key or OAuth access token as a bearer token, or a signed-in session.
scheduledAtstringRequiredFormat date-time.
accountsobject[]RequiredAt least 1 item.
accountIdstringRequiredAt least 1 character.
itemsobject[]RequiredidstringRequiredAt least 1 character.
contentstringRequiredpollobjectoptionsstring[]RequiredBetween 2 and 4 items.
durationMinutesintegerRequiredBetween 5 and 10080.
mediaobject[]urlstringRequiredFormat uri.
mimeTypestringRequiredAt least 1 character.
mediaIdstringAt least 1 character.
altTextstringwidthintegerAt least 0.
heightintegerAt least 0.
bytesintegerAt least 0.
durationSecnumberAt least 0.
sourceUrlstringFormat uri.
videoUrlstringFormat uri.
titlestringAt most 300 characters.
pageCountintegerAt least 0.
mediaIdsstring[]At most 10 items.
quotestringFormat uri.
replyTostringFormat uri.
linkPreviewobjectisSyncedbooleanDefaults to true.
scheduledAtstringFormat date-time.
autoEngagementobjectcommentobjectRequiredenabledbooleanRequiredentriesobject[]Defaults to [].
fromAccountIdstringRequiredAt least 1 character.
delayMinutesintegerRequiredBetween 0 and 10080.
itemsobject[]Defaults to [].
idstringRequiredAt least 1 character.
contentstringRequiredmediaobject[]urlstringRequiredFormat uri.
mimeTypestringRequiredAt least 1 character.
mediaIdstringAt least 1 character.
altTextstringwidthintegerAt least 0.
heightintegerAt least 0.
bytesintegerAt least 0.
durationSecnumberAt least 0.
sourceUrlstringFormat uri.
videoUrlstringFormat uri.
titlestringAt most 300 characters.
pageCountintegerAt least 0.
mediaIdsstring[]At most 10 items.
pollobjectoptionsstring[]RequiredBetween 2 and 4 items.
durationMinutesintegerRequiredBetween 5 and 10080.
replyTargetstring"thread_tail""root"delayMinutesintegerBetween 0 and 10080.
fromAccountIdstring | nullAt least 1 character.
itemsobject[]idstringRequiredAt least 1 character.
contentstringRequiredmediaobject[]urlstringRequiredFormat uri.
mimeTypestringRequiredAt least 1 character.
mediaIdstringAt least 1 character.
altTextstringwidthintegerAt least 0.
heightintegerAt least 0.
bytesintegerAt least 0.
durationSecnumberAt least 0.
sourceUrlstringFormat uri.
videoUrlstringFormat uri.
titlestringAt most 300 characters.
pageCountintegerAt least 0.
mediaIdsstring[]At most 10 items.
pollobjectoptionsstring[]RequiredBetween 2 and 4 items.
durationMinutesintegerRequiredBetween 5 and 10080.
textstringAt most 3000 characters.
mediaobject[]urlstringRequiredFormat uri.
mimeTypestringRequiredAt least 1 character.
mediaIdstringAt least 1 character.
altTextstringwidthintegerAt least 0.
heightintegerAt least 0.
bytesintegerAt least 0.
durationSecnumberAt least 0.
sourceUrlstringFormat uri.
videoUrlstringFormat uri.
titlestringAt most 300 characters.
pageCountintegerAt least 0.
pollobjectoptionsstring[]RequiredBetween 2 and 4 items.
durationMinutesintegerRequiredBetween 5 and 10080.
likeobjectRequiredenabledbooleanRequiredentriesobject[]Defaults to [].
fromAccountIdstringRequiredAt least 1 character.
delayMinutesintegerRequiredBetween 0 and 10080.
reactionstring"LIKE""LOVE""CELEBRATE""INSIGHTFUL""CURIOUS""SUPPORT"delayMinutesintegerBetween 0 and 10080.
fromAccountIdsstring[]reactionstring"LIKE""LOVE""CELEBRATE""INSIGHTFUL""CURIOUS""SUPPORT"repostobjectRequiredenabledbooleanRequiredentriesobject[]Defaults to [].
fromAccountIdstringRequiredAt least 1 character.
delayMinutesintegerRequiredBetween 0 and 10080.
delayMinutesintegerBetween 0 and 10080.
fromAccountIdstring | nullAt least 1 character.
slackobjectenabledbooleanRequiredentriesobject[]Defaults to [].
fromAccountIdstringRequiredAt least 1 character.
delayMinutesintegerRequiredBetween 0 and 10080.
itemsobject[]Defaults to [].
idstringRequiredAt least 1 character.
contentstringRequiredmediaobject[]urlstringRequiredFormat uri.
mimeTypestringRequiredAt least 1 character.
mediaIdstringAt least 1 character.
altTextstringwidthintegerAt least 0.
heightintegerAt least 0.
bytesintegerAt least 0.
durationSecnumberAt least 0.
sourceUrlstringFormat uri.
videoUrlstringFormat uri.
titlestringAt most 300 characters.
pageCountintegerAt least 0.
mediaIdsstring[]At most 10 items.
pollobjectoptionsstring[]RequiredBetween 2 and 4 items.
durationMinutesintegerRequiredBetween 5 and 10080.
discordobjectenabledbooleanRequiredentriesobject[]Defaults to [].
fromAccountIdstringRequiredAt least 1 character.
delayMinutesintegerRequiredBetween 0 and 10080.
itemsobject[]Defaults to [].
idstringRequiredAt least 1 character.
contentstringRequiredmediaobject[]urlstringRequiredFormat uri.
mimeTypestringRequiredAt least 1 character.
mediaIdstringAt least 1 character.
altTextstringwidthintegerAt least 0.
heightintegerAt least 0.
bytesintegerAt least 0.
durationSecnumberAt least 0.
sourceUrlstringFormat uri.
videoUrlstringFormat uri.
titlestringAt most 300 characters.
pageCountintegerAt least 0.
mediaIdsstring[]At most 10 items.
pollobjectoptionsstring[]RequiredBetween 2 and 4 items.
durationMinutesintegerRequiredBetween 5 and 10080.
dmobjectenabledbooleanRequiredmessagestringAt most 10000 characters. Defaults to "".
triggersstring[]At most 2 items. Defaults to ["repost","reply"].
"repost""reply"matchstringDefaults to "all".
"all""any"requireFollowbooleanDefaults to false.
replyKeywordstringAt most 100 characters.
durationDaysintegerBetween 1 and 7. Defaults to 4.
replySettingsstring"everyone""following""mentionedUsers""subscribers""verified""accounts_you_follow""mentioned_only""followers_only""parent_post_author_only"postFormatstring"post""story""reel""short"platformOptionsobjecttiktokobjecttitlestringAt most 2200 characters.
privacyLevelstring"PUBLIC_TO_EVERYONE""MUTUAL_FOLLOW_FRIENDS""FOLLOWER_OF_CREATOR""SELF_ONLY"disableCommentbooleandisableDuetbooleandisableStitchbooleanbrandOrganicTogglebooleanbrandContentTogglebooleanisAigcbooleanLabel as AI-generated ("Creator labeled as AI-generated").
videoCoverTimestampMsintegerAt least 0.
photoCoverIndexintegerAt least 0.
autoAddMusicbooleanthreadsobjecttopicTagstringThe 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.
At most 51 characters. Matches ^[^.&]*$.
youtubeobjecttitlestringAt most 100 characters.
privacyStatusstring"public""unlisted""private"categoryIdstringMatches ^\d+$.
madeForKidsbooleancontainsSyntheticMediabooleanAltered or synthetic content: realistic video made or changed with AI (a real person, place or event).
instagramobjectvideoCoverTimestampMsintegerAt least 0.
coverImageUrlstringFormat uri.
isAiGeneratedbooleanLabel as AI-generated ("AI info"). Not applied to stories.
userTagsobject[]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.
At most 200 items.
collaboratorsstring[]Up to 3 usernames invited as collaborators. Not applied to stories.
At most 3 items.
twitterobjectmadeWithAibooleanLabel "Made with AI". Sent only when the first post has media.
pinterestobjectboardIdstringThe board to save the Pin to. Required to publish.
Between 1 and 64 characters. Matches ^\d+$.
boardSectionIdstringA section of that board.
Between 1 and 64 characters. Matches ^\d+$.
titlestringThe Pin's title. The text is its description.
At most 100 characters.
linkstringWhere the Pin opens when tapped.
At most 2048 characters. Format uri.
videoCoverTimestampMsintegerAt least 0.
coverImageUrlstringFormat uri.
isAiModifiedbooleanLabel the Pin "AI modified" (Pinterest's ai_disclosures).
aiGeneratedbooleanLabel as AI-generated where the network has a label (TikTok, Instagram, X, YouTube, Pinterest).
replaceGroupIdstringAt least 1 character.
publishNowbooleaninfoobjecttitlestringAt most 200 characters.
tagColorsobjectcampaignIdstring | nullAt least 1 character.
templateIdstring | nullBetween 1 and 100 characters.
approvalobjectreviewerIdsstring[]RequiredAt least 1 item.
messagestring | nullAt most 4000 characters.
dueDatestring | nullFormat date-time.
requireAllbooleanDefaults to false.
blockingbooleanDefaults to false.
publishOnApprovebooleanDefaults to false.
200objectRows committed — scheduled, or publishing detached
okbooleanRequiredpublishingbooleanRequiredPresent on publish-now: rows are committed and the adapter publish runs detached — poll /posts/publish-status with groupIds.
countintegerRequiredgroupIdsstring[]RequiredvideoProcessingbooleanTrue when an attached video is still processing. The post publishes automatically once it finishes; publish-now waits on it.
warningsstring[]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.
curl -X POST "https://orbitwrite.com/api/v1/posts" \
-H "Authorization: Bearer $ORBITWRITE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"scheduledAt": "2026-10-01T09:00:00Z",
"accounts": [
{
"accountId": "string",
"items": [
{
"id": "string",
"content": "string"
}
]
}
]
}'{
"ok": true,
"publishing": true,
"count": 0,
"groupIds": [
"string"
],
"videoProcessing": true,
"warnings": [
"string"
]
}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.
An API key or OAuth access token as a bearer token, or a signed-in session.
organizationIdstringSession callers only — an API key already determines the workspace.
At least 1 character.
sortstringOrder 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.
Defaults to "createdAt".
"createdAt""scheduledAt"directionstringNewest/latest first by default.
Defaults to "desc".
"asc""desc"statusstring | string[]Lifecycle status. Repeat for OR (e.g. ?status=draft&status=scheduled).
bucketstring | string[]Board column. Approval state outranks the lifecycle status, so a scheduled post awaiting review is needs_approval, not scheduled. Repeat for OR.
platformstring | string[]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.
accountIdstring | string[]Connected-account id. Matches a post going out on ANY of its channels. Repeat for OR.
campaignIdstringgroupIdstring | string[]Only these posts, by the id each entry carries. Repeat for OR; at most 100.
scoreGradestring | string[]Post score grade (see scoreGrade on each entry). Repeat for OR, e.g. ?scoreGrade=fair&scoreGrade=needs_work for the posts worth another look.
scheduledAfterstringISO 8601, inclusive, against the group's earliest fire time. Drafts have no fire time and are excluded by either bound.
scheduledBeforestringISO 8601, inclusive.
previewAccountIdstringA 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.
200PostListAllEvery matching post
entriesPostListEntry[]RequiredidstringRequiredThe composer group's id — what every group-addressed route (/posts/draft, /posts/reschedule, /posts/discard) takes.
postIdstringRequiredThe representative row's own post id, for the post-addressed routes (/posts/{postId}/analytics).
titlestring | nullRequiredtextstringRequiredThe 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.
statusstringRequired"draft""planned""pending_approval""scheduled""publishing""published"approvalStatusstringRequiredWhat the post READS as — its active approval request's status whether or not that request blocks. bucket is derived from this.
"not_required""pending""approved""rejected"approvalGateStatusstringRequiredThe post's own approval column: the publish gate, which a non-blocking review never writes. Ask this when the question is 'would this publish?'.
"not_required""pending""approved""rejected"bucketstringRequiredWhich board column the post sits in.
"drafts""needs_approval""needs_revision""approved""planned""scheduled""posted"scheduledAtstring | nullRequiredISO 8601 — the group's earliest fire time. Null for a draft that has never been given one.
publishedAtstring | nullRequiredISO 8601.
createdAtstringRequiredISO 8601.
updatedAtstringRequiredISO 8601.
boardPositionnumber | nullRequiredWhere 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.
hasThreadbooleanRequiredpreviewAccountIdstringRequiredThe 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.
mediaCountintegerRequiredmediaPostListMedia[]RequiredThe 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.
pollobject | any | nullRequiredoptionsstring[]RequireddurationMinutesnumberRequiredquoteUrlstring | nullRequiredThe post this one quotes, if any.
platformUrlstring | nullRequiredThe preview row's permalink once published. Every channel's own is on accounts.
lastErrorstring | nullRequiredMost recent worker error, suppressed once the post published (a retry that eventually succeeded leaves its error behind).
issuesobject[]RequiredEvery 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.
campaignobject | any | nullRequiredThe campaign this post belongs to, if any.
accountsPostListAccount[]RequiredEvery channel this post goes out on.
idstringRequiredplatformstringRequiredhandlestring | nullRequireddisplayNamestring | nullRequiredavatarUrlstring | nullRequiredplatformUrlstring | nullRequiredThis channel's permalink to the published post, once the network returned one.
statusstringRequiredThis 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.
"draft""planned""pending_approval""scheduled""publishing""published"lastErrorstring | nullRequiredThis 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.
isEmptybooleanRequiredThis 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.
charCountintegerRequiredLength 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.
charLimitintegerRequiredThe 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.
scoreinteger | nullRequiredThe 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.
scoreGradestring | nullRequiredThe score's grade: excellent (90+), good (75+), fair (50+) or needs_work.
"excellent""good""fair""needs_work"nullcurl -X GET "https://orbitwrite.com/api/v1/posts/all" \
-H "Authorization: Bearer $ORBITWRITE_API_KEY"{
"entries": [
{
"id": "string",
"postId": "string",
"title": "string",
"text": "string",
"status": "draft",
"approvalStatus": "not_required",
"approvalGateStatus": "not_required",
"bucket": "drafts",
"scheduledAt": "string",
"publishedAt": "string",
"createdAt": "string",
"updatedAt": "string",
"boardPosition": 0,
"hasThread": true,
"previewAccountId": "string",
"mediaCount": 0,
"media": [
{
"url": "string",
"mimeType": "string",
"width": 0,
"height": 0,
"altText": "string",
"title": "string"
}
],
"poll": {
"options": [
"string"
],
"durationMinutes": 0
},
"quoteUrl": "string",
"platformUrl": "string",
"author": {
"id": "string",
"name": "string",
"image": "string"
},
"lastError": "string",
"issues": [
{
"kind": "empty",
"accountId": "string",
"message": "string"
}
],
"campaign": {
"id": "string",
"title": "string",
"color": "string"
},
"tags": [
{
"name": "string",
"color": "string"
}
],
"accounts": [
{
"id": "string",
"platform": "string",
"handle": "string",
"displayName": "string",
"avatarUrl": "string",
"platformUrl": "string",
"status": "draft",
"lastError": "string",
"isEmpty": true,
"charCount": 0,
"charLimit": 0
}
],
"score": 0,
"scoreGrade": "excellent"
}
]
}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.
An API key or OAuth access token as a bearer token, or a signed-in session.
organizationIdstringSession callers only — an API key already determines the workspace.
At least 1 character.
accountsobject[]RequiredBetween 1 and 50 items.
accountIdstringRequiredAt least 1 character.
itemsobject[]RequiredBetween 1 and 50 items.
firstCommentstringAt most 10000 characters.
destinationLinkstringAt most 2048 characters.
scheduledAtstringFormat date-time.
200PostScoreResponseThe post's score
scorePostScoreRequiredscoreintegerRequiredMean of the channels' scores.
Between 0 and 100.
gradestringRequiredexcellent ≥ 90, good ≥ 75, fair ≥ 50, else needs_work.
"excellent""good""fair""needs_work"channelsChannelPostScore[]RequiredaccountIdstring | any | nullRequiredplatformstringRequiredscoreintegerRequiredWeighted 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.
Between 0 and 100.
gradestringRequired"excellent""good""fair""needs_work"checksPostScoreCheck[]RequiredidstringRequired"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"categorystringRequired"writing""reach""format""brand"labelstringRequireddescriptionstringRequiredWhat the check looks at and why it matters. Fixed per check.
statusstringRequiredpass 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.
"pass""warn""fail""skip"summarystringRequiredOne line. When the check didn't pass it is the action to take ("Add about 850 characters"); when it passed, what was found.
advicestring | any | nullRequiredThe why behind summary. Null when the check passed or was skipped.
contextstring | any | nullRequiredThe 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.
fixstring | any | nullRequiredA 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).
weightnumberRequiredshowWhenPassingbooleanRequiredWhether 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.
curl -X POST "https://orbitwrite.com/api/v1/posts/score" \
-H "Authorization: Bearer $ORBITWRITE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"accounts": [
{
"accountId": "string",
"items": [
{
"content": "string"
}
]
}
]
}'{
"score": {
"score": 0,
"grade": "excellent",
"channels": [
{
"accountId": "string",
"platform": "string",
"score": 0,
"grade": "excellent",
"checks": [
{
"id": "hook",
"category": "writing",
"label": "string",
"description": "string",
"status": "pass",
"summary": "string",
"advice": "string",
"context": "string",
"fix": "enable_link_tracking",
"weight": 0,
"showWhenPassing": true
}
]
}
]
}
}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.
An API key or OAuth access token as a bearer token, or a signed-in session.
groupIdstringRequiredThe post's composer group id.
At least 1 character.
200PostScoreResponseThe post's score
scorePostScoreRequiredscoreintegerRequiredMean of the channels' scores.
Between 0 and 100.
gradestringRequiredexcellent ≥ 90, good ≥ 75, fair ≥ 50, else needs_work.
"excellent""good""fair""needs_work"channelsChannelPostScore[]RequiredaccountIdstring | any | nullRequiredplatformstringRequiredscoreintegerRequiredWeighted 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.
Between 0 and 100.
gradestringRequired"excellent""good""fair""needs_work"checksPostScoreCheck[]RequiredidstringRequired"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"categorystringRequired"writing""reach""format""brand"labelstringRequireddescriptionstringRequiredWhat the check looks at and why it matters. Fixed per check.
statusstringRequiredpass 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.
"pass""warn""fail""skip"summarystringRequiredOne line. When the check didn't pass it is the action to take ("Add about 850 characters"); when it passed, what was found.
advicestring | any | nullRequiredThe why behind summary. Null when the check passed or was skipped.
contextstring | any | nullRequiredThe 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.
fixstring | any | nullRequiredA 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).
weightnumberRequiredshowWhenPassingbooleanRequiredWhether 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.
curl -X GET "https://orbitwrite.com/api/v1/posts/{groupId}/score" \
-H "Authorization: Bearer $ORBITWRITE_API_KEY"{
"score": {
"score": 0,
"grade": "excellent",
"channels": [
{
"accountId": "string",
"platform": "string",
"score": 0,
"grade": "excellent",
"checks": [
{
"id": "hook",
"category": "writing",
"label": "string",
"description": "string",
"status": "pass",
"summary": "string",
"advice": "string",
"context": "string",
"fix": "enable_link_tracking",
"weight": 0,
"showWhenPassing": true
}
]
}
]
}
}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.
An API key or OAuth access token as a bearer token, or a signed-in session.
groupIdstringRequiredThe post's composer group id.
At least 1 character.
200PostCoverThe post's covers
groupIdstringRequiredchannelsPostCoverChannel[]RequiredaccountIdstringRequiredplatformstringRequiredacceptsstring[]RequiredWhat 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.
"frame""image"reasonstring | any | nullRequireddefaultFrameMsinteger | any | nullRequiredThe frame the platform shows when sent no cover (Instagram: the first frame; TikTok: one second in).
videoDurationMsinteger | any | nullRequiredcoverobject | any | nullRequiredThe cover that will publish. Null: the platform's default frame.
frameMsintegerRequirededitablebooleanRequiredFalse once this channel's post has gone out.
curl -X GET "https://orbitwrite.com/api/v1/posts/{groupId}/cover" \
-H "Authorization: Bearer $ORBITWRITE_API_KEY"{
"groupId": "string",
"channels": [
{
"accountId": "string",
"platform": "string",
"accepts": [
"frame"
],
"reason": "string",
"defaultFrameMs": 0,
"videoDurationMs": 0,
"cover": {
"frameMs": 0
},
"editable": true
}
]
}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.
An API key or OAuth access token as a bearer token, or a signed-in session.
groupIdstringRequiredThe post's composer group id.
At least 1 character.
accountIdsstring[]The channels to change. Omit for every channel on the post that takes this kind of cover.
Between 1 and 50 items.
coverobject | any | nullRequired{ frameMs } picks a frame of the video, { imageUrl } or { mediaId } an image (Instagram and Pinterest), null clears it back to the platform's default.
frameMsintegerRequiredWhich frame of the video is the cover, in milliseconds from the start.
At least 0.
200SetPostCoverResultThe post's covers after the change
groupIdstringRequiredchannelsPostCoverChannel[]RequiredaccountIdstringRequiredplatformstringRequiredacceptsstring[]RequiredWhat 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.
"frame""image"reasonstring | any | nullRequireddefaultFrameMsinteger | any | nullRequiredThe frame the platform shows when sent no cover (Instagram: the first frame; TikTok: one second in).
videoDurationMsinteger | any | nullRequiredcoverobject | any | nullRequiredThe cover that will publish. Null: the platform's default frame.
frameMsintegerRequirededitablebooleanRequiredFalse once this channel's post has gone out.
updatedstring[]RequiredThe channels this call changed.
curl -X PUT "https://orbitwrite.com/api/v1/posts/{groupId}/cover" \
-H "Authorization: Bearer $ORBITWRITE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"cover": {
"frameMs": 0
}
}'{
"groupId": "string",
"channels": [
{
"accountId": "string",
"platform": "string",
"accepts": [
"frame"
],
"reason": "string",
"defaultFrameMs": 0,
"videoDurationMs": 0,
"cover": {
"frameMs": 0
},
"editable": true
}
],
"updated": [
"string"
]
}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.
An API key or OAuth access token as a bearer token, or a signed-in session.
groupIdstringRequiredComposer group id.
200LoadDraftResultThe composer state for the group
okbooleanRequiredgroupIdstringRequiredstatusstringRequiredfailed 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.
"draft""planned""scheduled""pending_approval""published""failed""mixed"scheduledAtstring | nullRequiredISO 8601; null for plain drafts (their stored fire time is a far-future sentinel) and mixed groups.
titlestring | nullRequiredtemplateIdstring | nullRequiredThe post template the draft was started from (see GET /post-templates), or null.
tagColorsobjectRequiredcampaignIdstring | nullRequiredapprovalStatusstringRequiredapprovalGateStatusstringRequiredaccountsDraftAccount[]RequiredaccountIdstringRequiredisSyncedbooleanRequireditemsDraftThreadItem[]RequiredidstringRequiredcontentstringRequiredthreadPositionintegerRequiredpollanymediaany[]quotestringreplyTostringlinkPreviewanyreplySettingsstringpostFormatstring"post""story""reel""short"platformUrlstring | nulllastErrorstring | nullcurl -X GET "https://orbitwrite.com/api/v1/posts/draft?groupId=groupId" \
-H "Authorization: Bearer $ORBITWRITE_API_KEY"{
"ok": true,
"groupId": "string",
"status": "draft",
"scheduledAt": "string",
"title": "string",
"templateId": "string",
"tags": [
"string"
],
"tagColors": {},
"campaignId": "string",
"approvalStatus": "string",
"approvalGateStatus": "string",
"accounts": [
{
"accountId": "string",
"isSynced": true,
"items": [
{
"id": "string",
"content": "string",
"threadPosition": 0,
"media": [],
"quote": "string",
"replyTo": "string"
}
],
"replySettings": "string",
"postFormat": "post",
"platformUrl": "string",
"lastError": "string"
}
]
}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.
An API key or OAuth access token as a bearer token, or a signed-in session.
groupIdstringAt least 1 character.
accountsobject[]RequiredAt least 1 item.
accountIdstringRequiredAt least 1 character.
itemsobject[]RequiredidstringRequiredAt least 1 character.
contentstringRequiredpollobjectoptionsstring[]RequiredBetween 2 and 4 items.
durationMinutesintegerRequiredBetween 5 and 10080.
mediaobject[]urlstringRequiredFormat uri.
mimeTypestringRequiredAt least 1 character.
mediaIdstringAt least 1 character.
altTextstringwidthintegerAt least 0.
heightintegerAt least 0.
bytesintegerAt least 0.
durationSecnumberAt least 0.
sourceUrlstringFormat uri.
videoUrlstringFormat uri.
titlestringAt most 300 characters.
pageCountintegerAt least 0.
mediaIdsstring[]At most 10 items.
quotestringFormat uri.
replyTostringFormat uri.
linkPreviewobjectisSyncedbooleanDefaults to false.
autoEngagementobjectcommentobjectRequiredenabledbooleanRequiredentriesobject[]Defaults to [].
fromAccountIdstringRequiredAt least 1 character.
delayMinutesintegerRequiredBetween 0 and 10080.
itemsobject[]Defaults to [].
idstringRequiredAt least 1 character.
contentstringRequiredmediaobject[]urlstringRequiredFormat uri.
mimeTypestringRequiredAt least 1 character.
mediaIdstringAt least 1 character.
altTextstringwidthintegerAt least 0.
heightintegerAt least 0.
bytesintegerAt least 0.
durationSecnumberAt least 0.
sourceUrlstringFormat uri.
videoUrlstringFormat uri.
titlestringAt most 300 characters.
pageCountintegerAt least 0.
mediaIdsstring[]At most 10 items.
pollobjectoptionsstring[]RequiredBetween 2 and 4 items.
durationMinutesintegerRequiredBetween 5 and 10080.
replyTargetstring"thread_tail""root"delayMinutesintegerBetween 0 and 10080.
fromAccountIdstring | nullAt least 1 character.
itemsobject[]idstringRequiredAt least 1 character.
contentstringRequiredmediaobject[]urlstringRequiredFormat uri.
mimeTypestringRequiredAt least 1 character.
mediaIdstringAt least 1 character.
altTextstringwidthintegerAt least 0.
heightintegerAt least 0.
bytesintegerAt least 0.
durationSecnumberAt least 0.
sourceUrlstringFormat uri.
videoUrlstringFormat uri.
titlestringAt most 300 characters.
pageCountintegerAt least 0.
mediaIdsstring[]At most 10 items.
pollobjectoptionsstring[]RequiredBetween 2 and 4 items.
durationMinutesintegerRequiredBetween 5 and 10080.
textstringAt most 3000 characters.
mediaobject[]urlstringRequiredFormat uri.
mimeTypestringRequiredAt least 1 character.
mediaIdstringAt least 1 character.
altTextstringwidthintegerAt least 0.
heightintegerAt least 0.
bytesintegerAt least 0.
durationSecnumberAt least 0.
sourceUrlstringFormat uri.
videoUrlstringFormat uri.
titlestringAt most 300 characters.
pageCountintegerAt least 0.
pollobjectoptionsstring[]RequiredBetween 2 and 4 items.
durationMinutesintegerRequiredBetween 5 and 10080.
likeobjectRequiredenabledbooleanRequiredentriesobject[]Defaults to [].
fromAccountIdstringRequiredAt least 1 character.
delayMinutesintegerRequiredBetween 0 and 10080.
reactionstring"LIKE""LOVE""CELEBRATE""INSIGHTFUL""CURIOUS""SUPPORT"delayMinutesintegerBetween 0 and 10080.
fromAccountIdsstring[]reactionstring"LIKE""LOVE""CELEBRATE""INSIGHTFUL""CURIOUS""SUPPORT"repostobjectRequiredenabledbooleanRequiredentriesobject[]Defaults to [].
fromAccountIdstringRequiredAt least 1 character.
delayMinutesintegerRequiredBetween 0 and 10080.
delayMinutesintegerBetween 0 and 10080.
fromAccountIdstring | nullAt least 1 character.
slackobjectenabledbooleanRequiredentriesobject[]Defaults to [].
fromAccountIdstringRequiredAt least 1 character.
delayMinutesintegerRequiredBetween 0 and 10080.
itemsobject[]Defaults to [].
idstringRequiredAt least 1 character.
contentstringRequiredmediaobject[]urlstringRequiredFormat uri.
mimeTypestringRequiredAt least 1 character.
mediaIdstringAt least 1 character.
altTextstringwidthintegerAt least 0.
heightintegerAt least 0.
bytesintegerAt least 0.
durationSecnumberAt least 0.
sourceUrlstringFormat uri.
videoUrlstringFormat uri.
titlestringAt most 300 characters.
pageCountintegerAt least 0.
mediaIdsstring[]At most 10 items.
pollobjectoptionsstring[]RequiredBetween 2 and 4 items.
durationMinutesintegerRequiredBetween 5 and 10080.
discordobjectenabledbooleanRequiredentriesobject[]Defaults to [].
fromAccountIdstringRequiredAt least 1 character.
delayMinutesintegerRequiredBetween 0 and 10080.
itemsobject[]Defaults to [].
idstringRequiredAt least 1 character.
contentstringRequiredmediaobject[]urlstringRequiredFormat uri.
mimeTypestringRequiredAt least 1 character.
mediaIdstringAt least 1 character.
altTextstringwidthintegerAt least 0.
heightintegerAt least 0.
bytesintegerAt least 0.
durationSecnumberAt least 0.
sourceUrlstringFormat uri.
videoUrlstringFormat uri.
titlestringAt most 300 characters.
pageCountintegerAt least 0.
mediaIdsstring[]At most 10 items.
pollobjectoptionsstring[]RequiredBetween 2 and 4 items.
durationMinutesintegerRequiredBetween 5 and 10080.
dmobjectenabledbooleanRequiredmessagestringAt most 10000 characters. Defaults to "".
triggersstring[]At most 2 items. Defaults to ["repost","reply"].
"repost""reply"matchstringDefaults to "all".
"all""any"requireFollowbooleanDefaults to false.
replyKeywordstringAt most 100 characters.
durationDaysintegerBetween 1 and 7. Defaults to 4.
replySettingsstring"everyone""following""mentionedUsers""subscribers""verified""accounts_you_follow""mentioned_only""followers_only""parent_post_author_only"postFormatstring"post""story""reel""short"platformOptionsobjecttiktokobjecttitlestringAt most 2200 characters.
privacyLevelstring"PUBLIC_TO_EVERYONE""MUTUAL_FOLLOW_FRIENDS""FOLLOWER_OF_CREATOR""SELF_ONLY"disableCommentbooleandisableDuetbooleandisableStitchbooleanbrandOrganicTogglebooleanbrandContentTogglebooleanisAigcbooleanLabel as AI-generated ("Creator labeled as AI-generated").
videoCoverTimestampMsintegerAt least 0.
photoCoverIndexintegerAt least 0.
autoAddMusicbooleanthreadsobjecttopicTagstringThe 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.
At most 51 characters. Matches ^[^.&]*$.
youtubeobjecttitlestringAt most 100 characters.
privacyStatusstring"public""unlisted""private"categoryIdstringMatches ^\d+$.
madeForKidsbooleancontainsSyntheticMediabooleanAltered or synthetic content: realistic video made or changed with AI (a real person, place or event).
instagramobjectvideoCoverTimestampMsintegerAt least 0.
coverImageUrlstringFormat uri.
isAiGeneratedbooleanLabel as AI-generated ("AI info"). Not applied to stories.
userTagsobject[]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.
At most 200 items.
collaboratorsstring[]Up to 3 usernames invited as collaborators. Not applied to stories.
At most 3 items.
twitterobjectmadeWithAibooleanLabel "Made with AI". Sent only when the first post has media.
pinterestobjectboardIdstringThe board to save the Pin to. Required to publish.
Between 1 and 64 characters. Matches ^\d+$.
boardSectionIdstringA section of that board.
Between 1 and 64 characters. Matches ^\d+$.
titlestringThe Pin's title. The text is its description.
At most 100 characters.
linkstringWhere the Pin opens when tapped.
At most 2048 characters. Format uri.
videoCoverTimestampMsintegerAt least 0.
coverImageUrlstringFormat uri.
isAiModifiedbooleanLabel the Pin "AI modified" (Pinterest's ai_disclosures).
aiGeneratedbooleanLabel as AI-generated where the network has a label (TikTok, Instagram, X, YouTube, Pinterest).
plannedAtstringFormat date-time.
infoobjecttitlestringAt most 200 characters.
tagColorsobjectcampaignIdstring | nullAt least 1 character.
templateIdstring | nullBetween 1 and 100 characters.
plannedAtstringFormat date-time.
200SaveDraftResultThe draft was saved
okbooleanRequiredgroupIdstringRequiredcountintegerRequiredgroupIdsstring[]RequiredEvery 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.
warningsstring[]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.
curl -X POST "https://orbitwrite.com/api/v1/posts/draft" \
-H "Authorization: Bearer $ORBITWRITE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"accounts": [
{
"accountId": "string",
"items": [
{
"id": "string",
"content": "string"
}
]
}
]
}'{
"ok": true,
"groupId": "string",
"count": 0,
"groupIds": [
"string"
],
"warnings": [
"string"
]
}Deletes the group's draft rows only — rows in any other status are untouched.
An API key or OAuth access token as a bearer token, or a signed-in session.
groupIdstringRequiredComposer group id.
200DeleteDraftResultThe draft rows were deleted
okbooleanRequiredcurl -X DELETE "https://orbitwrite.com/api/v1/posts/draft?groupId=groupId" \
-H "Authorization: Bearer $ORBITWRITE_API_KEY"{
"ok": true
}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.
An API key or OAuth access token as a bearer token, or a signed-in session.
200UpdatePostInfoResultThe metadata was updated
400ErrorInvalid request
401ErrorNot signed in
403ErrorNot a member of the organization
404ErrorThe resource does not exist
curl -X PATCH "https://orbitwrite.com/api/v1/posts/{groupId}/info" \
-H "Authorization: Bearer $ORBITWRITE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "string",
"tags": [
"string"
],
"tagColors": {},
"campaignId": "string",
"templateId": "string"
}'{
"ok": true
}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.
An API key or OAuth access token as a bearer token, or a signed-in session.
organizationIdstringSession callers only — an API key already determines the workspace.
At least 1 character.
groupIdstringRequiredAt least 1 character.
aboveGroupIdstring | nullAt least 1 character.
belowGroupIdstring | nullAt least 1 character.
200ReorderPostResultThe post was placed
groupIdstringRequiredboardPositionnumberRequiredThe group's new key, in the space boardPosition on GET /posts entries uses.
curl -X POST "https://orbitwrite.com/api/v1/posts/reorder" \
-H "Authorization: Bearer $ORBITWRITE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"groupId": "string"
}'{
"groupId": "string",
"boardPosition": 0
}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).
An API key or OAuth access token as a bearer token, or a signed-in session.
200PlanPostResultThe post is planned
curl -X POST "https://orbitwrite.com/api/v1/posts/plan" \
-H "Authorization: Bearer $ORBITWRITE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"groupId": "string",
"plannedAt": "2026-10-01T09:00:00Z"
}'{
"groupId": "string",
"plannedAt": "string",
"count": 0
}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.
An API key or OAuth access token as a bearer token, or a signed-in session.
groupIdstringRequiredAt least 1 character.
200UnplanPostResultThe post is a draft again
curl -X POST "https://orbitwrite.com/api/v1/posts/unplan" \
-H "Authorization: Bearer $ORBITWRITE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"groupId": "string"
}'{
"groupId": "string",
"count": 0
}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.
An API key or OAuth access token as a bearer token, or a signed-in session.
groupIdstringRequiredAt least 1 character.
200ApprovalRequestResultThe group's approval request, or null when there is none
requestApprovalRequestSummary | any | nullRequiredidstringRequiredstatusstringRequired"pending""approved""rejected"requireAllbooleanRequiredblockingbooleanRequiredpublishOnApprovebooleanRequiredThe author asked for this to go out the moment it's approved, rather than at its scheduled time.
messagestring | nullRequireddueDatestring | nullRequiredISO 8601.
createdBystringRequiredreviewerCountintegerRequiredapprovedCountintegerRequiredrejectedCountintegerRequiredreviewersApprovalReviewerSummary[]RequiredviewerIsReviewerbooleanRequiredviewerDecisionstring | nullAbsent → the viewer isn't a reviewer; null → they haven't decided.
"approved""rejected"nullviewerCanManagebooleanRequiredThe viewer holds approval:decide or is on the roster — what the Info panel offers management controls on.
viewerCanDecidebooleanRequiredThe viewer holds approval:decide, the authority resetting the request or overriding a reviewer actually requires. Narrower than viewerCanManage for a reviewer without it.
curl -X GET "https://orbitwrite.com/api/v1/posts/{groupId}/approval-request" \
-H "Authorization: Bearer $ORBITWRITE_API_KEY"{
"request": {
"id": "string",
"status": "pending",
"requireAll": true,
"blocking": true,
"publishOnApprove": true,
"message": "string",
"dueDate": "string",
"createdBy": "string",
"reviewerCount": 0,
"approvedCount": 0,
"rejectedCount": 0,
"reviewers": [
{
"id": "string",
"userId": "string",
"name": "string",
"image": "string",
"decision": "approved"
}
],
"viewerIsReviewer": true,
"viewerDecision": "approved",
"viewerCanManage": true,
"viewerCanDecide": true
}
}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.
An API key or OAuth access token as a bearer token, or a signed-in session.
groupIdstringRequiredAt least 1 character.
reviewerIdsstring[]RequiredAt least 1 item.
messagestring | nullAt most 4000 characters.
dueDatestring | nullFormat date-time.
requireAllbooleanDefaults to false.
blockingbooleanDefaults to false.
intentstringDefaults to "create".
200ApprovalRequestMutationResultThe intent was applied
curl -X POST "https://orbitwrite.com/api/v1/posts/{groupId}/approval-request" \
-H "Authorization: Bearer $ORBITWRITE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"reviewerIds": [
"string"
]
}'{
"ok": true,
"requestId": "string",
"status": "pending",
"removed": true
}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.
An API key or OAuth access token as a bearer token, or a signed-in session.
organizationIdstringSession callers only — an API key already determines the workspace.
At least 1 character.
limitintegerBetween 1 and 100. Defaults to 50.
cursorstringOpaque cursor from a previous response's nextCursor. Omit for the first page.
statusstring | string[]The request's resolved status. Repeat for OR (e.g. ?status=pending&status=rejected). Omit for all.
200ApprovalRequestListA page of approval requests
entriesApprovalRequestListEntry[]RequiredidstringRequiredstatusstringRequired"pending""approved""rejected"requireAllbooleanRequiredblockingbooleanRequiredpublishOnApprovebooleanRequiredThe author asked for this to go out the moment it's approved, rather than at its scheduled time.
messagestring | nullRequireddueDatestring | nullRequiredISO 8601.
createdBystringRequiredreviewerCountintegerRequiredapprovedCountintegerRequiredrejectedCountintegerRequiredreviewersApprovalReviewerSummary[]RequiredviewerIsReviewerbooleanRequiredviewerDecisionstring | nullAbsent → the viewer isn't a reviewer; null → they haven't decided.
"approved""rejected"nullviewerCanManagebooleanRequiredThe viewer holds approval:decide or is on the roster — what the Info panel offers management controls on.
viewerCanDecidebooleanRequiredThe viewer holds approval:decide, the authority resetting the request or overriding a reviewer actually requires. Narrower than viewerCanManage for a reviewer without it.
groupIdstringRequiredThe composer group the request hangs off — what the group-addressed post routes take.
nextCursorstring | nullRequiredPass back as cursor for the next page; null on the last page.
curl -X GET "https://orbitwrite.com/api/v1/approval-requests" \
-H "Authorization: Bearer $ORBITWRITE_API_KEY"{
"entries": [
{
"id": "string",
"status": "pending",
"requireAll": true,
"blocking": true,
"publishOnApprove": true,
"message": "string",
"dueDate": "string",
"createdBy": "string",
"reviewerCount": 0,
"approvedCount": 0,
"rejectedCount": 0,
"reviewers": [
{
"id": "string",
"userId": "string",
"name": "string",
"image": "string",
"decision": "approved"
}
],
"viewerIsReviewer": true,
"viewerDecision": "approved",
"viewerCanManage": true,
"viewerCanDecide": true,
"groupId": "string"
}
],
"nextCursor": "string"
}