scatterpost public API, v1

Base path: https://api.scatterpost.com/api/v1

This is the contract packages/sdk implements and every route handler in apps/web must satisfy. Field names and enum values match the columns and check constraints in supabase/migrations/0001_foundation.sql exactly, so a route can read a row from Postgres and return it with no remapping.

Note on the website connector's example in docs/BUILD-PLAN.md (GET /api/v1/publications?status=ready&channel=website): the migration has no ready publication status. The migration is the source of truth, so a pull-mode website polls GET /api/v1/publications?channel=website&due=true instead. See "Publications" below for due and the real status enum.

Authentication

Two ways in, both resolve to exactly one workspace:

  1. API key. Authorization: Bearer sp_live_... (a prefix looked up against api_keys, hashed and matched against api_key_secrets). The key is scoped to the workspace it was issued for; there is no X-Workspace-Id header alongside an API key, and one is rejected with validation if sent.
  2. Dashboard session. A Supabase Auth session cookie, plus a required X-Workspace-Id: <uuid> header naming the workspace to act on. The route checks the session's membership in that workspace before touching any row.

Every route resolves the workspace this way before it reads or writes anything. A request that resolves to no workspace, or that names a workspace the caller is not a member of, fails closed with unauthorized or forbidden (see below), never with a 404 that would leak existence.

Error envelope

Every non-2xx response is exactly this shape:

{
  "error": {
    "code": "validation",
    "message": "title must not be empty.",
    "details": { "field": "title" }
  }
}

details is optional and its shape varies by code. Codes and their usual HTTP status:

Code HTTP status Meaning
unauthorized 401 No usable API key or session.
forbidden 403 Authenticated, but not a member of the resolved workspace, or the member's role does not cover the action.
not_found 404 No row with that id in the caller's workspace.
validation 400 The request body or query failed schema validation.
rate_limited 429 Per-key or per-connection rate limit exceeded. Retry-After is set.
conflict 409 The action collides with existing state (for example, a duplicate (article_id, connection_id) publication).
plan_limit 402 The workspace's plan does not allow the action (for example, a free-tier scheduled-post cap).
internal 500 Unexpected server error.
ai_not_configured 503 AI generation has neither the AI Gateway key nor the workspace's own Anthropic key (see "AI generation").

Pagination

Every list route is cursor-paginated with ?cursor=&limit= (limit from 1 to 100, default 20) and returns:

{ "data": [ /* items */ ], "next_cursor": "eyJpZCI6Ii4uLiJ9" }

next_cursor is null on the last page. Pass it back as cursor to fetch the next one. cursor is an opaque, server-generated token; do not parse it.

Rate limiting

Every response carries X-RateLimit-Remaining: <n> for the caller's key or session. A 429 additionally carries Retry-After: <seconds>.

Idempotency

POST /publish accepts an Idempotency-Key header. A second request with the same key (from the same workspace) returns the original response body and status code unchanged, rather than publishing again. Keys are remembered for 24 hours. Sending the same key with a different request body is a conflict. A key is 1 to 200 characters; a longer one is a validation error.

The key is claimed before anything is published, so two concurrent requests with one key never publish twice. The second waits up to about two seconds for the first to finish and then replays its response; if the first is still running, the second gets a 409 conflict whose details are { "reason": "idempotency_in_progress", "retry_after_seconds": 2 }. Retry the same request with the same key after that interval.


Articles

GET /articles

Query: cursor, limit, status (one of draft, scheduled, published, failed).

GET /api/v1/articles?limit=20
Authorization: Bearer sp_live_...
{
  "data": [
    {
      "id": "22222222-2222-2222-2222-222222222222",
      "workspace_id": "11111111-1111-1111-1111-111111111111",
      "title": "Ship it",
      "body_markdown": "# Ship it\n\nContent goes here.",
      "canonical_url": null,
      "tags": ["launch"],
      "cover_image_url": null,
      "content_type": "blog",
      "seo_title": null,
      "seo_description": null,
      "geo_schema": null,
      "status": "draft",
      "created_by": "55555555-5555-5555-5555-555555555555",
      "created_at": "2026-09-27T09:00:00.000Z",
      "updated_at": "2026-09-27T09:00:00.000Z"
    }
  ],
  "next_cursor": null
}

POST /articles

{
  "title": "Ship it",
  "body_markdown": "# Ship it\n\nContent goes here.",
  "tags": ["launch"],
  "content_type": "blog"
}

title is the only required field. status is never accepted in the request body: a new article is always created with status: "draft". On success, 201 with the created article (same shape as one item in the list above).

Images (ledger B20): cover_media_id (a ready media id from Media) sets the cover, and media_ids attaches images per channel ({ "<channel_id>": ["<media_id>", ...] }, at most 4 per channel, at most 10 channels). Every id is checked against the workspace; one that is not found, or still pending, is a validation error. cover_media_id and cover_image_url cannot both be sent. Both new fields appear on every article response (cover_media_id null, media_ids {} when unused).

GET /articles/:id

Returns one article, or not_found.

PATCH /articles/:id

Body: any subset of title, body_markdown, canonical_url, tags, cover_image_url, cover_media_id (null removes a media cover), media_ids (replaces the whole map), content_type, seo_title, seo_description, geo_schema. Setting cover_media_id clears cover_image_url and the reverse. status is not accepted here either: it is set by the publishing engine (see docs/BUILD-PLAN.md, "Publishing engine"). Returns the updated article.

Scope: articles:write. When the article has any publication in scheduled or publishing, an API key or OAuth token also needs publications:write: the edit changes what goes out. Without it the request is forbidden (403) with details.required_scope.

DELETE /articles/:id

204, no body. Cascades to the article's publications (per the migration's on delete cascade).

GET /articles/:id/analytics

Scope: publications:read. Per-channel latest platform-native metrics (docs/BUILD-PLAN.md, "Tracking") for this article, plus each channel's short-link click count and totals across every channel. views, reactions, reposts and comments are null for a channel the cron (apps/web/src/app/api/cron/analytics/route.ts) has not polled yet, or whose platform does not expose that field (packages/adapters/src/types.ts's Metrics); clicks is always a number, since it comes from short_links, not the platform.

{
  "article_id": "22222222-2222-2222-2222-222222222222",
  "channels": [
    {
      "publication_id": "44444444-4444-4444-4444-444444444444",
      "connection_id": "33333333-3333-3333-3333-333333333333",
      "platform": "devto",
      "views": 128,
      "reactions": 9,
      "reposts": null,
      "comments": 2,
      "clicks": 14,
      "captured_at": "2026-09-27T09:00:00.000Z"
    }
  ],
  "totals": { "views": 128, "reactions": 9, "reposts": 0, "comments": 2, "clicks": 14 }
}

POST /articles/:id/optimise

docs/BUILD-PLAN.md Phase 4 item 1. Pure: no network call, no write, no LLM call. Scope articles:read. Pro and Team only: a Free workspace gets plan_limit.

{ "mode": "both", "focus_keyword": "canonical URL" }

mode is seo, geo or both. focus_keyword is optional; every keyword-placement check is skipped (and reported as a single info issue) without it.

{
  "mode": "both",
  "seo": { "score": 90, "issues": [ /* @scatterpost/optimise's SeoReport */ ] },
  "geo": { "score": 80, "issues": [ /* @scatterpost/optimise's GeoReport */ ] },
  "json_ld": [
    { "@context": "https://schema.org", "@type": "Article", "headline": "Ship it" }
  ],
  "llms_txt_entry": "- [Ship it](https://example-founder.com/blog/ship-it): A short summary."
}

seo and geo are null for the mode not requested. json_ld always includes one Article entry; it also includes a FAQPage entry when the GEO check finds FAQ candidates and a HowTo entry when the body has a "how to" or "steps" section with an ordered list, regardless of mode (both checks read the body directly, not the requested report). llms_txt_entry is the one Markdown "file list" line (- [title](url): notes) a founder can append to their own site's llms.txt; url is empty until the article has a canonical_url.

POST /articles/:id/adapt

docs/BUILD-PLAN.md Phase 4 item 2 ("adapt_content"). Pure: no network call, no write. Scope articles:read. Pro and Team only: a Free workspace gets plan_limit.

{ "platform": "bluesky", "short_url": "https://go.scatterpost.com/abc" }

platform is one of linkedin, bluesky, mastodon, devto, hashnode (the platforms packages/adapters ships; X, Reddit and Threads are Phase 5 and have no adapter to adapt for yet). short_url is optional and preferred over the article's canonical_url when present, the same preference composeShortPost (packages/adapters/src/compose.ts) uses.

{
  "constraints": { "max_chars": 300, "supports_markdown": false, "link_style": "plain URL in the text" },
  "draft": "Ship it\n\nA short summary.\n\nhttps://go.scatterpost.com/abc",
  "notes": ["Bluesky's limit is 300 grapheme clusters, not characters or codepoints."]
}

For LinkedIn, Bluesky and Mastodon (short-form), draft is built with composeShortPost: the title, a one-line summary, then the link, trimmed to fit the platform's limit. For Dev.to and Hashnode (long-form), draft is the article's body_markdown verbatim: their adapters send the body as written, canonical handled through their own native field. max_chars is null for the long-form platforms (no adapter-enforced body length cap). notes always carries at least one platform-specific caveat: LinkedIn's 3,000-character commentary limit, Bluesky's 300-grapheme limit, Mastodon's 500-character default (the real limit is confirmed against the connected instance only at publish time), Dev.to's 4-tag cap, or Hashnode's Pro-publication requirement for publishPost.


Media

Images for article covers and per-channel attachments (ledger B20, B22). PNG, JPEG, WebP and GIF, decided by the file's own bytes, never by a name or a declared type. Every PNG, JPEG and WebP image is decoded and re-encoded before it is stored, which strips EXIF and all other metadata, and its real width, height and size are recorded. A GIF (GIF87a or GIF89a, up to 10 MB) is not re-encoded, so its animation survives: its comment and application extensions (other than the loop count) are removed, its width, height and frame count are read, and the item carries animated: true when it has more than one frame. GIFs go to the website, Dev.to and Hashnode as a cover URL, and to LinkedIn and Mastodon as attachments, unchanged. Bluesky does not accept GIFs: it gets the first frame as a PNG, and the publish response carries the warning bluesky_gif_still (Bluesky shows GIFs as a still image). X does not attach images yet. alt_text (1 to 300 characters) is required on every image. Media is private to the workspace: stored in a private bucket under the workspace's id, with no public URL, until it is used as the cover of an article that has been published at least once. From that point its cover URL (<APP_URL>/api/media/<id>.<ext>) is reachable by anyone who has the media id, which is how a cross-posted platform keeps fetching it; see "How images reach each platform" below. Everything else about a media item (listing, per-channel images, unpublished covers) stays private. Editor or owner role; tokens need articles:write to upload and articles:read to read (media is article content and reuses those scopes). Uploads are also limited per workspace (a burst of 20, then 60 an hour) on top of the per-key limit above.

A media item:

{
  "id": "uuid",
  "workspace_id": "uuid",
  "mime": "image/png",
  "width": 1200,
  "height": 630,
  "bytes": 48213,
  "alt_text": "A bar chart of weekly signups",
  "status": "ready",
  "created_by": "uuid or null",
  "created_at": "2026-10-01T09:00:00Z"
}

POST /media

Inline upload: { "data_base64": "...", "alt_text": "...", "filename": "optional" }. Up to 3 MB decoded (about 4 MB of base64, which keeps the request under the 4.5 MB body limit of the hosting platform); a data:image/...;base64, prefix is accepted and its declared type ignored. 201 with the ready media item. SVG, HEIC and anything else is a validation error: "Only PNG, JPEG, WebP and GIF images are accepted."

POST /media/upload-url

For an image up to 10 MB: { "mime": "image/png", "bytes": 5000000, "alt_text": "..." }. 201 with { media, upload_url, upload_method: "PUT", upload_headers, expires_at }; media.status is pending. PUT the raw bytes to upload_url with upload_headers. The URL accepts one upload only and lasts 2 hours (a fixed lifetime of the storage provider's signed upload URLs). Then call:

POST /media/:id/complete

Checks what was uploaded: present, at most 10 MB, and a real PNG, JPEG, WebP or GIF by content, whatever was declared. Re-encodes it (a GIF is only stripped of metadata, never re-encoded), deletes the raw upload and returns the ready item. An upload that is not an allowed image is deleted and refused with validation, and its pending item is released at once, so it no longer counts toward the workspace's 1 GB (calling complete on it again is a 404; ask for a new upload URL). Nothing uploaded yet is a validation error ("No image was found at the upload URL yet") and the item stays pending until its URL expires; after that it is released the same way. Calling it again on a ready item returns it unchanged.

GET /media and GET /media/:id

Cursor-paginated list, newest first, and one item. GET /media/:id on a ready item adds url, a signed link to the image (or video) that expires after 10 minutes (for previews, and for a pull-mode website to fetch a cover). Every item carries kind (image or video); a ready video also carries duration_seconds.

DELETE /media/:id

Deletes one item for good (ledger B29): editor or owner, and a token needs articles:write, like every other media write. The stored file goes first (the image in Storage, or the video in Blob under this environment's prefix), then the item, which frees its share of the workspace's 1 GB. Works on a pending item as well as a ready one. 204 with no body.

  • 404 not_found when the id does not exist in this workspace, or was already deleted, so repeating a delete is safe.
  • 409 conflict while the item is the cover or a channel attachment of an article that is on its way out: the article is scheduled, one of its publications is scheduled or publishing or has a queued publish job, or its website publication is still pending (a pull-mode site reads the article when it next polls). Remove it from the article, or wait for publishing to finish or cancel it, then delete it.
  • Deleting media a draft article uses clears that article's cover; an id left in a draft's media_ids makes a later publish of that channel fail with a plain error naming the deleted image, until media_ids is updated.

Each delete is written to the audit log as media.delete (type, size and status only). The MCP tool is scatterpost_media with action: "delete".

Video (ledger B23)

Short video for Mastodon, Bluesky and LinkedIn:

Formats MP4 (video/mp4: H.264 video, AAC audio or none) and WebM (video/webm: VP8, VP9 or AV1 video, Opus or Vorbis audio or none)
Size up to 100 MB
Length up to 3 minutes (180 seconds)
Per workspace 1 GB for images and video together, uploads still pending included
Upload POST /media/upload-url only; there is no inline video upload

POST /media/upload-url with { "mime": "video/mp4", "bytes": 48000000, "alt_text": "..." } returns a one-time upload URL to private storage (Vercel Blob), valid for 1 hour, bound to that one file name, that content type and the declared bytes; it cannot overwrite anything. Every Blob object name is namespaced by environment (<prefix>/<workspace_id>/video/<media_id>; see docs/setup/video-storage.md). PUT the raw file to upload_url with every header in upload_headers (they include x-vercel-blob-access: private). Without video storage configured the call is a validation error: "Video uploads are not enabled."

POST /media/:id/complete then reads the file itself: it must be stored privately, be at most 100 MB, start with an MP4 ftyp box or a WebM EBML header, and have a duration, a frame size and codecs it can read from the container (MP4 moov/mvhd and trak/tkhd; WebM Segment/Info and Tracks). Anything it cannot read, a WebM recorded without a duration, a codec outside the list above, or a video over 3 minutes, is deleted and refused with validation, and so is a file larger than the bytes declared for its upload URL. The type recorded is the one the bytes show, whatever was declared. The duration, frame size and codecs come from the container's own headers, not from decoding the video, so a file can claim less than it holds; each platform checks the video again itself when it processes it, and refuses what breaks its own limits.

A refused video's pending item is released at the same time as its file, so it stops counting toward the 1 GB straight away; calling complete on it again is a 404, so ask for a new upload URL. Calling complete before anything was uploaded (or after the upload itself was refused, for example for being larger than declared) is a validation error: "No video was found at the upload URL yet". The item stays pending while its URL is valid, so the upload can still be made, and is released once the URL has expired.

Attaching (media_ids on POST /articles or PATCH /articles/:id): a video is the only media item on its channel (a video plus images on one channel is a validation error), never a cover, LinkedIn takes MP4 only, and Dev.to, Hashnode, the website and X take no video; each of these is refused with a plain validation error when it is attached.

Publishing: a request never waits on a video upload. A channel with a video is handed to the publish queue, due at once: its publication comes back scheduled and the response carries the warning video_queued. The queue reads the video from storage on the server, uploads it to the platform, saves its progress on the publication (a platform media id, a processing job id or a video urn; never a credential or an upload URL), and while the platform is still processing it checks again on a later run (about every 30 seconds, for up to about 30 minutes) without spending one of the job's attempts. A failed attempt is retried under the usual rules and resumes from the saved progress. One video is uploaded in full at most 3 times per publication, across every run and retry; after that the publication fails with a plain error.

How images reach each platform

Platform Cover (cover_media_id) Per-channel images (media_ids)
Website (push) coverImageUrl plus coverImageAlt in the signed payload not sent
Dev.to main_image URL not sent
Hashnode coverImage URL not sent
LinkedIn uploaded (initializeUpload, binary PUT), alt text set uploaded; two or more as a multi-image post
Bluesky uploaded with uploadBlob as the link card thumbnail uploaded with uploadBlob, app.bsky.embed.images with alt text (replaces the link card; the link stays in the text). Shrunk to Bluesky's 1,000,000-byte limit if needed
Mastodon not sent uploaded to /api/v2/media with the alt text as description; the access token needs write:media
X not yet not yet

Video (media_ids, one per channel, uploaded by the publish queue):

Platform Video
Mastodon POST /api/v2/media with the alt text as description, then GET /api/v1/media/:id until processed; the access token needs write:media; an instance may set a lower size limit
Bluesky Bluesky's video service (app.bsky.video.uploadVideo, then getJobStatus), posted as app.bsky.embed.video with alt text and aspect ratio; 3 minutes at most, and Bluesky's own daily upload limit per account applies
LinkedIn Videos API (initializeUpload, one PUT per part, finalizeUpload), posted once LinkedIn reports it AVAILABLE; MP4 only
Website, Dev.to, Hashnode refused when attached
X not built yet; refused when attached

The cover URL a URL platform receives is <APP_URL>/api/media/<id>.<ext>?t=<token>. It is minted for that publish and redirects to a short-lived signed link. It works while its token is unexpired (30 minutes) or once the media is the cover of an article with at least one published publication; for anything else, including media that was never published, it is a plain 404. Detaching or deleting the media stops it working.

Publications

A publication is one (article, connection) pairing and the state of publishing that article to that connection.

GET /publications

Query: cursor, limit, status, channel, article_id, due.

  • status one of pending, scheduled, publishing, published, failed.
  • channel filters by the connection's platform (for example channel=website), not by connection_id.
  • article_id filters to publications of one article.
  • due (true or false, as a query-string literal). due=true resolves to status in (pending, scheduled) and scheduled_for is either null or already at or before now. This is the pull-mode website connector's poll: GET /api/v1/publications?channel=website&due=true.
GET /api/v1/publications?channel=website&due=true
{
  "data": [
    {
      "id": "44444444-4444-4444-4444-444444444444",
      "article_id": "22222222-2222-2222-2222-222222222222",
      "connection_id": "33333333-3333-3333-3333-333333333333",
      "workspace_id": "11111111-1111-1111-1111-111111111111",
      "adapted_title": null,
      "adapted_body": null,
      "utm_params": null,
      "scheduled_for": null,
      "published_at": null,
      "platform_post_id": null,
      "platform_url": null,
      "status": "pending",
      "error": null,
      "idempotency_key": "b6e...",
      "created_at": "2026-09-27T09:00:00.000Z",
      "updated_at": "2026-09-27T09:00:00.000Z"
    }
  ],
  "next_cursor": null
}

GET /publications/:id

Returns one publication, or not_found.

PATCH /publications/:id

Three uses of the same route:

  1. Editor update. Any subset of adapted_title, adapted_body, utm_params, scheduled_for. A new scheduled_for is only accepted for a scheduled publication whose job has not started; it moves the queued job too (conflict otherwise), and cannot be more than 60 seconds in the past. scheduled_for: null is a validation error: clearing a schedule is a cancellation (3, below), never an edit.

  2. Pull-mode completion. The website connector's poll-and-report flow (docs/BUILD-PLAN.md, "Website connector") calls this with:

    { "status": "published", "url": "https://founder.example.com/blog/ship-it" }
    

    url becomes platform_url, published_at is set to the current time, and if this publication's connection is the workspace's website connection, url also becomes the article's canonical_url. status accepts no value other than "published" here: every other status transition belongs to the publishing engine, not a client.

    For the website case, this also publishes inline any other publication on the same article that POST /publish's canonical-first defer (awaiting_canonical, above) had queued and is now due, up to 10 at once, rather than leaving them for the cron. The response is the completed publication plus, only when at least one was published this way:

    { "...": "the completed publication's own fields", "published_dependents": [ /* publications, same shape as GET /publications */ ] }
    
  3. Cancel a scheduled publication.

    { "status": "pending", "cancel": true }
    

    Only for a publication in scheduled whose queued job has not started (conflict otherwise). The job is deleted and the publication returns to pending, so nothing publishes it. Monthly scheduled-post count (the Free plan's 5 a month): a cancellation within 24 hours of scheduling gives that post back to the month's budget (scheduled_for is cleared); a later cancellation still counts (scheduled_for is kept), so scheduling and cancelling cannot be cycled to reset the budget. A pull-mode website publication cannot be cancelled this way, because the site polls pending rows and would publish it; delete the publication instead. cancel cannot be combined with any other field.

Sending status: "published" without url is a validation error, as is status: "pending" without cancel: true (or the reverse).

POST /publications/:id/preview

Dry run. Runs the same adapter mapping publish() would, using the connection's real credentials, but returns the payload instead of sending it, with any credential-derived field (auth headers, HMAC signatures) replaced by "[redacted]". For the short-form platforms (linkedin, bluesky, mastodon), payload also carries text: the actual composed post (title, summary and link for a blog article; the body itself, no title, for a social article), the same string publish() would send. Mastodon's text is composed against its documented default character limit, not the connected instance's real one, since reading that would call the instance and this endpoint never has a side effect.

POST /api/v1/publications/44444444-4444-4444-4444-444444444444/preview
{
  "platform": "devto",
  "payload": {
    "title": "Ship it",
    "bodyMarkdown": "# Ship it\n\nContent goes here.",
    "tags": ["launch"],
    "canonicalUrl": "https://founder.example.com/blog/ship-it",
    "apiKey": "[redacted]"
  }
}

Publish

POST /publish

{
  "article_id": "22222222-2222-2222-2222-222222222222",
  "connection_ids": [
    "33333333-3333-3333-3333-333333333333",
    "66666666-6666-6666-6666-666666666666"
  ],
  "scheduled_for": "2026-09-28T09:00:00.000Z"
}

scheduled_for is optional; when absent, publishing starts immediately (subject to the usual queue and retry behaviour in docs/BUILD-PLAN.md, "Publishing engine").

Creates one publication per connection_id (conflict if a (article_id, connection_id) pair already exists) and returns them:

{
  "data": [ /* the created publications, same shape as GET /publications */ ]
}

Canonical-first ordering, enforced server-side:

  • If any of connection_ids names a website connection, that publication's job runs first. Its returned url becomes the article's canonical_url before any cross-post job runs, so every cross-post carries the canonical from the start. "First platform becomes canonical" is never the rule; only a website connection can set it.

  • If no website connection is included and the article has no canonical_url already, the response carries:

    { "data": [ /* ... */ ], "warnings": ["no_canonical"] }
    

    Publishing still proceeds; this is a warning, not a block.

  • If a website connection is included, and the article still has no canonical_url after its job runs (its push failed, or it is a pull-mode website whose completion is still pending), the other channels in this same request are never sent with no canonical. Instead their publications are created scheduled and queued (due immediately, scheduled_for = now()), and the response carries:

    { "data": [ /* the website's publication and the others, now "scheduled" */ ], "warnings": ["awaiting_canonical"] }
    

    The queued cron drain picks them up once a canonical exists (docs/BUILD-PLAN.md, "Publishing engine"); a pull-mode website's own completion (PATCH /publications/:id below) publishes them inline in the same request instead of waiting for the cron.

  • If the article already had a canonical_url before this request (the usual case for every publish after the first), the channels run immediately regardless of a pull-mode website connection's own status; a still-pending pull-mode website in the same batch is website_pending as before.

Idempotency: send Idempotency-Key (see "Idempotency" above) so a retried call after a dropped connection returns the original set of publications instead of creating duplicates.

Approvals (Team): when the workspace requires approval, a publish or schedule from anyone who is not an owner (for an API key or OAuth token, the user behind it) is held: every publication is created scheduled with approval: "pending", nothing is sent, and the response carries "awaiting_approval" in warnings; an owner then calls POST /publications/:id/approve or /reject below.


Approvals

Wave 3, Team plan. Every publication carries approval: not_required, pending, approved or rejected, plus approved_by, approved_at and rejection_reason. A pending or rejected publication is never claimed by the queue, never run inline, and never offered to a pull-mode website's due=true poll. GET /publications?approval=pending lists what is waiting.

POST /publications/:id/approve

Owner only: the session user, or the user behind an API key or OAuth token, must be an owner of the workspace now. A token also needs the publications:write and publish scopes. Anyone else gets forbidden; a publication that is not pending gets conflict. When the job is already due (a held "publish now", or a schedule whose time has passed) it runs in the same request, with the same canonical-first rules as POST /publish; a future schedule waits for its time. Returns the publication, plus published_dependents and warnings when relevant.

Body (required): { "publication_updated_at": "...", "article_updated_at": "..." }, the updated_at of the publication and of its article exactly as the reviewer saw them. If either has changed since, nothing is approved and the response is 409 with code stale_content: fetch both again, review the current version, and approve with the new values.

POST /publications/:id/reject

Owner only, needs publications:write for a token. Body { "reason": "..." } (optional, at most 500 characters). The publication becomes approval: "rejected", status: "failed", and its queued job is removed.

POST /publications/:id/retry

Editor or owner, needs publish for a token. No body. Re-sends ONE publication whose status is failed; never a published one, and never any other publication of the article. Refused with conflict when the publication is not failed, is still held for approval (a rejected one included), already carries a platform_post_id (it reached the platform at least in part), still has an automatic retry queued, or its channel is cooling down after a rate limit.

Canonical-first: for a cross-post whose article has no canonical yet, a FAILED website publication refuses the retry with conflict and details: { "next_step": "retry_website_publication", "website_publication_id": "..." }: retry the website first. A website publication still on its way queues the retry instead (awaiting_canonical). With approvals on, a non-owner's retry is held (awaiting_approval). Before re-sending, the adapter checks for an existing post with the publication's idempotency key. Response: the publication, plus warnings when it was queued rather than sent.

GET /approvals

Ledger B10. Any member, needs publications:read. Query: state (pending, the default, oldest first; or rejected, newest decision first), requested_by (a user id, narrows to one author's own requests), limit (1-200, default 100). Not cursor-paginated. Returns { "approvals": [...] }, one row per publication with its article title, platform, connection label, who requested review and, for rejected, who decided and why; has_snapshot says whether GET /approvals/:id/diff has a baseline to diff against.

GET /approvals/:id/diff

Ledger B10. Any member, needs publications:read. :id is the publication id. What changed since it entered review: every field in fields (title, body_markdown, seo_title, seo_description, content_type, canonical_url, tags, cover_image_url, geo_schema, adapted_title, adapted_body, utm_params) as submitted for review against as it stands now, each with changed, plus body_diff and adapted_body_diff, a line-level diff ({ "op": "equal" | "add" | "remove", "text": "..." }) of the article body and the channel's adapted body. 404 when the publication is not in this workspace or has never been in review since migration 0019.

GET /workspaces/:id/settings and PATCH /workspaces/:id/settings

{ "require_approval": true }. Reading needs any member. Changing it needs an owner's dashboard session (no API key or OAuth token is ever an owner), and turning it on needs the Team plan (plan_limit otherwise).

Approval reset on edit

Wave 3, item 16. When a workspace requires approval, an edit by anyone but an owner resubmits whatever that edit touched: PATCH /articles/:id with title, body_markdown, a seo_title, seo_description or geo_schema, canonical_url, cover_image_url, tags or content_type resets every publication of that article that is not yet published or publishing (whatever its approval, not_required included) back to pending, clearing approved_by, approved_at and rejection_reason; PATCH /publications/:id with adapted_title or adapted_body does the same for that one publication. A rejected publication resubmitted this way is the resubmission path, no new row is created. An owner's own edit never resets anything. Because these columns also carry a direct client grant, database triggers apply the identical reset to a write that bypasses the API, so the two never disagree.


Channels

A channel is a connection: one connected platform account.

GET /channels

Query: cursor, limit. Never returns credentials.

{
  "data": [
    {
      "id": "33333333-3333-3333-3333-333333333333",
      "workspace_id": "11111111-1111-1111-1111-111111111111",
      "platform": "devto",
      "label": "Main Dev.to account",
      "platform_account_id": null,
      "next_allowed_at": null,
      "token_expires_at": null,
      "status": "active",
      "created_at": "2026-09-27T09:00:00.000Z",
      "updated_at": "2026-09-27T09:00:00.000Z"
    }
  ],
  "next_cursor": null
}

POST /channels

{
  "platform": "devto",
  "label": "Main Dev.to account",
  "credentials": { "apiKey": "..." }
}

platform is one of website, devto, hashnode, linkedin, bluesky, mastodon, x, reddit, threads. credentials is platform-specific (for example a single apiKey for Dev.to, an OAuth token set for LinkedIn) and is encrypted with AES-256-GCM before it is written to connections.credentials. The response is the created connection, same shape as one item in the list above: credentials is never echoed back, on this route or any other.

Website credentials

A website connection's credentials has an explicit shape (WebsiteChannelCredentialsSchema in packages/sdk/src/schemas.ts):

Field Type Notes
endpointUrl string (url) Where scatterpost POSTs a push-mode publish.
secret string (min 32 characters) Signs the push POST (X-Scatterpost-Signature) and authenticates the pull-mode poll and completion.
mode "push" | "pull" Defaults to "push" when omitted.

Push mode (scatterpost calls the founder's endpoint):

{
  "platform": "website",
  "credentials": {
    "endpointUrl": "https://founder.example.com/api/scatterpost",
    "secret": "a-random-string-at-least-32-characters-long",
    "mode": "push"
  }
}

Pull mode (the founder's site polls this API instead, see "Website connector reference" below):

{
  "platform": "website",
  "credentials": {
    "endpointUrl": "https://founder.example.com",
    "secret": "a-random-string-at-least-32-characters-long",
    "mode": "pull"
  }
}

endpointUrl is still required in pull mode: it identifies the site for display and audit even though scatterpost never calls it.

DELETE /channels/:id

204, no body. Cascades to the connection's publications.

POST /channels/:id/test

Editor or owner, needs channels:write for a token. No body. A read-only check of the channel's saved credentials; nothing is posted. Response { "ok": true, "message": "Connected to Dev.to as @founder." }, or ok: false with a plain reason. A push-mode website channel gets a signed { "type": "ping", "sentAt": "..." } with X-Scatterpost-Event: ping: a 2xx, or a 400 from a receiver that does not handle pings, counts as ok (the signature was accepted); a 401 or 403 means the signing secret does not match. A pull-mode website reports ok with a note. A rejected credential moves an active channel to error; a pass moves error back to active. At most one test per channel every 30 seconds (rate_limited otherwise).


Website connector reference

For the website connector's own push and pull flows (not part of this public API surface, documented here because both call into it), see docs/BUILD-PLAN.md, "Website connector":

  • Push (credentials.mode: "push", the default): scatterpost calls the founder's endpoint with an HMAC-signed request; the endpoint replies { "url": "..." }.
  • Pull (credentials.mode: "pull"): the founder's site polls GET /api/v1/publications?channel=website&due=true and reports completion with PATCH /api/v1/publications/:id, as documented above.

Workspaces

Data subject rights for a workspace. Both routes are owner-only: an API key or OAuth token never resolves to the owner role, so in practice they are called from a signed-in dashboard session (Settings > Danger zone). The :id must be the workspace the request resolved to (X-Workspace-Id or the workspace cookie); any other id is forbidden.

DELETE /workspaces/:id

Request body:

{ "confirm": "acme-blog" }

confirm must equal the workspace's slug or its name exactly, otherwise validation (400) and nothing happens. If the workspace has a Stripe subscription that is not already cancelled, it is cancelled with immediate effect first; if Stripe refuses, or billing is not configured on the deployment, the response is internal (502 or 503) and nothing is deleted. Then every row of the workspace is deleted: articles, media records, publications, the publish queue, short links, analytics snapshots, channels and their encrypted credentials, API keys, OAuth grants, idempotency records, AI credit balance, audit log and memberships. Posts already live on other platforms are not touched.

204, no body.

GET /workspaces/:id/export

200 with Content-Type: application/json and Content-Disposition: attachment. One JSON document:

{
  "format": "scatterpost.workspace_export.v1",
  "exported_at": "2026-09-29T10:00:00Z",
  "workspace": { "id": "...", "name": "...", "slug": "...", "timezone": "...", "plan": "...", "created_at": "..." },
  "members": [{ "user_id": "...", "email": "...", "role": "owner", "created_at": "..." }],
  "articles": [],
  "publications": [],
  "channels": [{ "id": "...", "platform": "devto", "label": "...", "platform_account_id": "...", "status": "active", "token_expires_at": null, "created_at": "...", "updated_at": "..." }],
  "short_links": [{ "id": "...", "slug": "...", "publication_id": "...", "target_url": "...", "clicks": 12, "created_at": "..." }],
  "audit_log": [],
  "media": [{ "id": "...", "mime": "image/png", "alt_text": "...", "status": "ready", "download_url": "https://...", "download_url_expires_in_seconds": 86400 }]
}

media lists every image as its metadata (the media item shape above) plus download_url, a signed link valid for 24 hours (null for an upload that never completed), so the export stays one JSON document. Deleting a workspace also deletes every stored image, before any database row is removed; if that fails the delete stops and can be retried.

Channel credentials, API key hashes, OAuth tokens and idempotency records are never included.

Workspace invites

Owner only. An API key never acts as owner, so these routes answer 403 forbidden to every API key and are used from a dashboard session. The :id in the path is the workspace; on the session path it selects the membership (no X-Workspace-Id needed), and a caller who is not an owner of that workspace gets 403 forbidden.

Seats: Free 1, Pro 1, Team the billed seat count (3 included, more can be added). Members plus pending, unexpired invitations may not exceed it; an invitation over the cap is 403 plan_limit. The cap is enforced again in the database when an invitation is accepted.

POST /workspaces/:id/invites

{ "email": "ada@example.com", "role": "editor" }

role is editor or viewer (an owner is made by promoting a member in Settings > Members). The email is stored lower-cased. 201:

{
  "id": "22222222-2222-2222-2222-222222222222",
  "workspace_id": "11111111-1111-1111-1111-111111111111",
  "email": "ada@example.com",
  "role": "editor",
  "invited_by": "44444444-4444-4444-4444-444444444444",
  "expires_at": "2026-10-06T09:00:00.000Z",
  "created_at": "2026-09-29T09:00:00.000Z",
  "status": "pending",
  "token": "base64url, 43 characters",
  "accept_url": "https://app.scatterpost.com/invite/<token>",
  "email_sent": true
}

token and accept_url appear in this response only. scatterpost stores only the SHA-256 of the token and can never show it again. When email is not configured (email_sent: false), share accept_url yourself. An invitation expires after 7 days and works once.

Errors: 409 conflict when the address already belongs to a member or has a pending invitation (revoke it first), 403 plan_limit when no seat is free.

GET /workspaces/:id/invites

Every invitation not yet accepted, newest first. status is pending or expired. Never includes the token or its hash.

{ "data": [ { "id": "...", "email": "ada@example.com", "role": "editor", "status": "pending", "expires_at": "..." } ] }

DELETE /workspaces/:id/invites/:inviteId

204, no body. The link stops working immediately. 404 not_found for an invitation that does not exist, belongs to another workspace, or was already accepted.

Accepting

The invitee opens accept_url (/invite/<token>), signs in if needed (they are sent to /login?next=/invite/<token> and back), and presses Accept. They join with the invitation's role only if their signed-in, confirmed email matches the invited address (case-insensitive). Accepting is a button, never the page load, so a link scanner cannot accept.

Audit log

Owner only, Team plan only. An API key never resolves to owner (403 forbidden for every API key), and below Team the request is 403 forbidden with a message naming the upgrade, the same shape as any other plan gate in this API.

Written to on channel connect and disconnect, API key create and revoke, publish and schedule, member invite, role change and removal, workspace settings changes, approvals, billing plan changes and workspace delete. Metadata never carries a credential, a token or an article body: anything shaped like a secret is stripped before the row is written.

GET /audit

Cursor paginated like every other list route (see "Pagination" above), 50 rows a page, newest first. No :id in the path: the session resolves the workspace, the same as /publications.

{
  "data": [
    {
      "id": "33333333-3333-3333-3333-333333333333",
      "action": "channel.create",
      "target_type": "connection",
      "target_id": "55555555-5555-5555-5555-555555555555",
      "actor_kind": "user",
      "actor_user_id": "44444444-4444-4444-4444-444444444444",
      "actor_api_key_id": null,
      "actor_oauth_token_id": null,
      "metadata": { "platform": "devto" },
      "created_at": "2026-09-29T09:00:00.000Z"
    }
  ],
  "next_cursor": null
}

actor_kind is user, api_key, oauth or system (a cron job or a webhook, which has no actor id at all).

AI generation

In-dashboard AI generation with metered credits, Pro and Team only (a Free workspace gets 403 plan_limit). This is the dashboard's own feature for founders without an agent open; there is no MCP tool for it, since an agent host already writes. Requests go through the Vercel AI Gateway (default model anthropic/claude-sonnet-5) and are paid for with the workspace's AI credits, unless the workspace owner has set their own Anthropic key in Settings > AI, in which case Anthropic is called with that key and no credits are used. The key is stored encrypted and is never returned by any route.

Credits: 1 credit per 1,000 input tokens plus 1 credit per 1,000 output tokens, each rounded up, at least 1 credit per request in total. The worst case the call can cost, input estimated from the prompt and output at the mode's maximum, is reserved before the model is called; a workspace with no credits at all is refused (402 plan_limit, details.reason: "insufficient_credits") without a model call, and a workspace with fewer credits than the worst case still gets a reservation of what is left, with the output capped so the call cannot cost more than that. After the call the actual cost is computed from the usage the provider reports, any unused credit is refunded and any shortfall is settled, and the balance never goes below zero. A failed model call refunds the whole reservation.

With neither the gateway key (AI_GATEWAY_API_KEY) nor the workspace's own key configured, generation answers 503 ai_not_configured.

POST /articles/:id/ai

Editor or owner; an API key needs articles:write.

{ "mode": "draft", "instruction": "A post on canonical URLs for founders", "apply": false }
Field Notes
mode draft (the instruction is the brief; returns title, body_markdown, seo_description), rewrite (the article body rewritten to the instruction; returns body_markdown), or social (returns variants.linkedin, variants.bluesky, variants.x, within 3,000, 300 and 280 characters).
instruction Up to 4,000 characters. Required for draft and rewrite, optional for social.
apply When true, a draft or rewrite is written to the article (the same rules as PATCH /articles/:id) and the updated article is returned as article. Otherwise the text is only returned. social cannot be applied (400 validation).
{
  "result": { "mode": "rewrite", "body_markdown": "..." },
  "applied": false,
  "credits": { "source": "credits", "charged": 2, "balance": 318 }
}

credits.source is byok (with charged: 0 and balance: null) when the workspace's own key was used. A model failure is 502 internal with no credits charged.

GET /workspaces/:id/ai-credits

Any member. The balance and the last 50 transactions, newest first. reason is topup, included (Team's pool), generation or refund.

{ "balance": 318, "transactions": [ { "id": "...", "delta": -2, "reason": "generation", "created_at": "..." } ] }

POST /billing/ai-credits/checkout

Owner, signed-in dashboard session only (an API key or OAuth token gets 401). Pro and Team only. Starts a one-off Stripe Checkout for one pack of the STRIPE_PRICE_AI_CREDITS Price and returns { "url": "..." }. The pack size is the Price's metadata.credits (a positive whole number set in Stripe), or 500 when the Price has none. Credits are added by the Stripe webhook (checkout.session.completed, or checkout.session.async_payment_succeeded for a delayed payment method), keyed on the Checkout Session id, so a replayed webhook credits once.

Team's included pool: AI_TEAM_INCLUDED_CREDITS credits are added once per Stripe billing period when the Team subscription is applied, keyed on the period end. Unused credits roll over. A yearly Team subscription has one period a year, so it receives the pool once a year.

Bulk scheduling

POST /publications/bulk

Team only. Session or API key with the publications:write and publish scopes. Schedules up to 50 publications in one call; see "Publish" above for the canonical-first, idempotency and approval rules each one still goes through individually.

Either an explicit list of items:

{
  "items": [
    {
      "article_id": "22222222-2222-2222-2222-222222222222",
      "channel_ids": ["33333333-3333-3333-3333-333333333333"],
      "scheduled_for": "2026-10-01T09:00:00.000Z"
    }
  ]
}

or one article staggered across several channels, with slots instead of an explicit scheduled_for per channel:

{
  "article_id": "22222222-2222-2222-2222-222222222222",
  "channel_ids": [
    "33333333-3333-3333-3333-333333333333",
    "66666666-6666-6666-6666-666666666666"
  ],
  "slots": {
    "start": "2026-10-01T09:00:00.000Z",
    "interval_minutes": 60
  }
}

slots expands to one time per channel_id, in order, either interval_minutes apart from start, or cycling through local_times (an ascending list of HH:mm wall-clock times, for example ["09:00", "13:00"]), one calendar day at a time, in timezone (defaults to the workspace's own timezone). Exactly one of interval_minutes or local_times is required. Whatever start says, no slot ever lands earlier than one minute from now; a local_times cycle whose first slot would be too soon starts the following day instead, keeping the same order and spacing.

The plan gate and the monthly scheduled-post budget (see assertCanSchedule, docs/BUILD-PLAN.md "Pricing and packaging") are both checked once, up front, against the total the whole request would add, before anything is written. Each channel then still runs through the same publish() path a single POST /publish or POST /schedule call does, so a website connection among the channels still goes first and still sets the canonical, an awaiting_approval workspace still holds every one of them, and one channel's failure never stops or rolls back another's.

Response, one row per channel:

{
  "results": [
    {
      "article_id": "22222222-2222-2222-2222-222222222222",
      "channel_id": "33333333-3333-3333-3333-333333333333",
      "publication_id": "77777777-7777-7777-7777-777777777777"
    },
    {
      "article_id": "22222222-2222-2222-2222-222222222222",
      "channel_id": "66666666-6666-6666-6666-666666666666",
      "error": "A publication already exists for this article and one or more of these connections."
    }
  ]
}

Status is 201 when every row succeeded, 207 (Multi-Status) when the results are mixed; a caller always reads results rather than inferring success from the status code alone, so a partial failure is never silent.