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:
- API key.
Authorization: Bearer sp_live_...(aprefixlooked up againstapi_keys, hashed and matched againstapi_key_secrets). The key is scoped to the workspace it was issued for; there is noX-Workspace-Idheader alongside an API key, and one is rejected withvalidationif sent. - 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_foundwhen the id does not exist in this workspace, or was already deleted, so repeating a delete is safe. - 409
conflictwhile the item is the cover or a channel attachment of an article that is on its way out: the article isscheduled, one of its publications isscheduledorpublishingor has a queued publish job, or its website publication is stillpending(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_idsmakes a later publish of that channel fail with a plain error naming the deleted image, untilmedia_idsis 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 |
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 |
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.
statusone ofpending,scheduled,publishing,published,failed.channelfilters by the connection'splatform(for examplechannel=website), not byconnection_id.article_idfilters to publications of one article.due(trueorfalse, as a query-string literal).due=trueresolves tostatusin (pending,scheduled) andscheduled_foris 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:
Editor update. Any subset of
adapted_title,adapted_body,utm_params,scheduled_for. A newscheduled_foris only accepted for ascheduledpublication whose job has not started; it moves the queued job too (conflictotherwise), and cannot be more than 60 seconds in the past.scheduled_for: nullis avalidationerror: clearing a schedule is a cancellation (3, below), never an edit.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" }urlbecomesplatform_url,published_atis set to the current time, and if this publication's connection is the workspace's website connection,urlalso becomes the article'scanonical_url.statusaccepts 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 */ ] }Cancel a scheduled publication.
{ "status": "pending", "cancel": true }Only for a publication in
scheduledwhose queued job has not started (conflictotherwise). The job is deleted and the publication returns topending, 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_foris cleared); a later cancellation still counts (scheduled_foris 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 pollspendingrows and would publish it; delete the publication instead.cancelcannot 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_idsnames awebsiteconnection, that publication's job runs first. Its returnedurlbecomes the article'scanonical_urlbefore any cross-post job runs, so every cross-post carries the canonical from the start. "First platform becomes canonical" is never the rule; only awebsiteconnection can set it.If no
websiteconnection is included and the article has nocanonical_urlalready, the response carries:{ "data": [ /* ... */ ], "warnings": ["no_canonical"] }Publishing still proceeds; this is a warning, not a block.
If a
websiteconnection is included, and the article still has nocanonical_urlafter 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 createdscheduledand queued (dueimmediately,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/:idbelow) publishes them inline in the same request instead of waiting for the cron.If the article already had a
canonical_urlbefore 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 iswebsite_pendingas 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 pollsGET /api/v1/publications?channel=website&due=trueand reports completion withPATCH /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.