cometpost API v1

The cometpost HTTP API v1: authentication, channels, posts, media, webhooks, errors, rate limits and the MCP surface.

Start with the connect → draft → human review → status walkthrough.

The public API is the product. The web dashboard and the MCP server are both clients of it, over HTTP, with no privileged path — if the dashboard ever needs something this document doesn't describe, the API is wrong, not the dashboard.

Vocabulary is CONTEXT.md's: Workspace, Channel, Post, Delivery, Attempt, Media Asset, Credential. Publishing semantics are ADR-0001; this document does not restate them.

Base URL: https://api.cometpost.app. Everything below is under /v1 except the OAuth callbacks, which platforms require at a fixed path and which answer on app.cometpost.app. /v1 is deliberately not proxied through the dashboard (ADR-0012).

Machine-readable description. An OpenAPI 3.1 document for the surface a Credential or OAuth grant can call is served, without authentication, at GET https://api.cometpost.app/v1/openapi.json (cached an hour, with an ETag). Feed it to a code generator, an API client or an agent. Request bodies and query parameters in it are generated from the same zod schemas the routes parse with; response shapes are hand-written, and this document stays the prose contract. Routes that only a signed-in person may call (publishing a draft, deleting, billing, Credentials) are deliberately absent from it. The operation set is pinned by apps/api/src/openapi.test.ts: a /v1 route that is mounted but neither described nor listed as excluded fails the build. Source: apps/api/src/openapi.ts.


Authentication #

Two callers, one Workspace context. A human carries a session cookie issued by Better Auth (Google OAuth, ADR-0008); an agent carries a Credential, a pasted key or an OAuth access token (below):

Authorization: Bearer cp_live_7f3a…

Every /v1 route accepts either, and resolves both to { workspaceId, memberId?, credential? }. The dashboard is a client of this API like any other (ADR-0006) — it does not get a privileged path, and it does not hold a secret Credential to talk to its own backend.

This is also what makes may_schedule coherent: the constraint lives on the Credential, so a request carrying a human session is simply not constrained by it. A human publishing their own draft is not an agent publishing.

Credentials are stored hashed. The response that creates one is the only time its secret is ever readable. The visible prefix (cp_live_7f3a) is stored in clear for display and for support to identify a key without seeing it.

A Credential in a URL is refused, always — not accepted-and-warned. Query strings land in access logs, browser history, and Referer headers, and one competitor shipping key-in-URL as its default is not a precedent worth following.

A bearer token can also come from an assistant the human connected by OAuth — see OAuth agents. /v1 accepts both kinds of bearer on every route, and treats them identically once they resolve.

What a Credential constrains #

Two constraints, both enforced server-side on every request:

ConstraintValuesEffect
may_scheduletrue / false, default falsefalse means Posts this Credential creates are draft. It cannot schedule, cannot publish, and cannot reschedule — including a draft a human wrote. A human reviews the drafts and publishes them from the dashboard.
channel_idslist, or null for allThe Channels this Credential may reach, on reads as well as writes. A Post naming any other Channel is refused whole — never silently narrowed — and a Channel, Post or Delivery outside the scope is not listed, not readable, and answers 404.
manage_webhooks (manageWebhooks on POST /v1/credentials)true / false, default falseMay register, change, delete, rotate, test and redeliver webhooks. Accepted only together with may_schedule: true and every Channel: an endpoint receives events for the whole Workspace, so a drafts-only or narrowed key cannot choose where they go. An OAuth assistant never holds it.
expires_inseconds, or null — no defaultOffered because Better Auth expires keys natively, and deliberately not defaulted: a key that expires needs a warning before it does, and ADR-0005 emails about exactly two things on purpose. An unattended agent should not die silently at 3am because a form had a default. Between one day and one year when set.

may_schedule gates every transition toward publication regardless of who authored the Post — POST /v1/posts/{id}/schedule and POST /v1/deliveries/{id}/reschedule alike. Cancelling is not gated by it: a key able to start something it cannot stop would be worse.

Authorization happens as an object is loaded, not in a check each route remembers to make — the routes have no way to reach a row that was not authorized. ADR-0004's 2026-08-16 amendment records why, and what the earlier shape cost.

This is the entire gate on agent publishing, and it is chosen once, when the key is minted. There is no runtime confirmation and no cancellation window: a schedule-capable Credential publishes with nothing further in its way. ADR-0004 records why the gate sits here rather than at publish time, and what that trade costs.

Per-Channel scoping is the thing no competitor offers, and it is what makes "give the agent your test account, not your brand account" a sentence we can say. It also bounds cost: a Credential scoped to Threads cannot spend X money.

Because there is no runtime gate, this scope is the only bound on a leaked Credential. Treat it accordingly.

OAuth agents #

Claude and ChatGPT add a connector by URL and sign the human in with OAuth, so nobody pastes a key. An OAuth access token is a second kind of Credential (ADR-0021): /v1 and the MCP server accept it exactly where they accept a cp_live_ key, in the same Authorization: Bearer header, and both end up as the same Workspace context with the same two constraints.

Location
Issuerhttps://app.cometpost.app/api/auth — the dashboard origin, because the Google session cookie lives there (ADR-0012)
Authorization-server metadatahttps://app.cometpost.app/.well-known/oauth-authorization-server/api/auth
Protected resourcehttps://mcp.cometpost.app/mcp
Protected-resource metadatahttps://mcp.cometpost.app/.well-known/oauth-protected-resource
/v1unchanged, https://api.cometpost.app

A client that knows only the MCP URL finds everything else itself: /mcp without a token answers 401 with a WWW-Authenticate header naming the protected-resource metadata, which names the issuer, whose metadata lists the endpoints. Scopes are cometpost and offline_access; the authority comes from the human's consent, never from scopes.

What the human grants. The consent page asks for the same two things a Credential has:

  • Channels — specific Channels, or all of them including ones connected later. This is channel_ids above: it applies to reads as well as writes, and a Post naming a Channel outside it is refused whole.
  • "Let it schedule posts without my approval" — may_schedule, off by default. Off, the assistant creates drafts and a human publishes them.

The page also shows the client's own name and the host it will return to (claude.ai, chatgpt.com, localhost), because a name is self-declared and a redirect host is not. The pair of choices is stored as the assistant's grant. Consent is remembered: connecting an assistant that is already connected skips the page and keeps its existing grant, unless the assistant asks for the page again (prompt=consent), in which case the new choices replace the grant. To change the Channels or the scheduling switch yourself, revoke the assistant under Settings → Connected assistants and connect it again.

Lifetimes. An access token lasts 1 hour. A refresh token lasts 30 days and is rotated on use. A token request that includes resource=https://mcp.cometpost.app/mcp receives a JWT access token; without it, an opaque one. Both are accepted. Every request checks the grant, so revoking takes effect on the next request, not when the token expires.

Registration. Clients register themselves (Dynamic Client Registration), which is open only while OAUTH_REGISTRATION_ENABLED=true on the API. It is hardened rather than trusted:

  • public clients only (token_endpoint_auth_method: "none"), PKCE required; a request carrying a cookie must say none, and a cookie-less request asking for another method is registered as a public client anyway
  • redirect URIs must be https or loopback
  • grant types are authorization_code and refresh_token — client_credentials is refused
  • 300 registrations per IP per hour
  • a client no human has ever consented to is deleted 24 hours after it registered; one that was connected and later revoked is kept

The session-authenticated create, update, delete and rotate-secret client endpoints are disabled; registration is the only way a client comes to exist.

Allowance. Each connected assistant gets 1000 requests per UTC day, the same as a cp_live_ key, and is refused with 429 after. Metered allowances that are per Workspace (media intake, Slots) are unchanged, so connecting more assistants does not multiply them.

What stays human. Releasing a draft, retrying a failed delivery, billing, managing Credentials, consent, and revoking an assistant all need a signed-in human, whatever the grant says. An OAuth token asking for one gets 401, the same answer a key gets.

SituationResponse
No token at /mcp401 with WWW-Authenticate: Bearer resource_metadata="https://mcp.cometpost.app/.well-known/oauth-protected-resource"
Token expired, revoked or unknown401 with the same header plus , error="invalid_token". A client refreshes; if the refresh token is revoked too, the human has to reconnect
Channel outside the grant403, the same body a scoped key gets
Human-only action401, the same body a key gets
Over 1000 requests in a UTC day429 with Retry-After set to the time to the next UTC midnight

Resources #

Channels #

GET /v1/channels
{
  "channels": [
    {
      "id": "chan_8f21…",
      "platform": "threads",
      "handle": "cometpost",
      "displayName": "cometpost",
      "status": "connected"
    }
  ]
}

status is connected, needs_reconnect, or disconnected.

This lists only the Channels the caller may reach. A Credential scoped to two Channels sees those two, and a human sees the Workspace's. There is no usable_by_this_credential flag, because every Channel here is usable by definition: scope that bounded writes but not reads was a promise the product did not keep, and an out-of-scope id listed for convenience is the one ingredient a write bypass needs (ADR-0004's amendment).

GET /v1/channels/{id}/prepare
{
  "options": [],
  "problems": [
    {
      "code": "instagram_page_publishing_authorization",
      "message": "The Facebook Page behind this Instagram account requires Page Publishing Authorization…"
    }
  ],
  "quota": { "used": 12, "total": 50 }
}

What this Channel may do right now, which is a different question from what its Platform demands of everybody (GET /v1/platforms). options are the Publish Options legal for this Channel; problems are the things standing between it and publishing — Instagram's Page Publishing Authorization, an account Instagram has restricted, a publishing quota already spent. quota is present only where the Platform volunteers what this account has spent of its own ceiling, which today means Instagram.

A problem is not an error, so this answers 200 with a body: the request succeeded and the Channel is what is not ready.

An empty problems means "nothing known", and never "ready". Meta states that there is no way to determine in advance whether a Page requires Page Publishing Authorization — no field, no edge, no probe — so this route reports what the Platform volunteered and cannot report what it did not. Rendering a green light from an empty list is a promise no answer here supports. Every Threads Channel answers empty for a second reason: Threads has nothing per-account to ask about at all.

Nothing calls this at publish, deliberately. It was once to run twice, at compose for UX and again at publish as the guarantee, mirroring the Publish Option check. That pattern does not transfer: the option check reads a static, knowable spec, while this reports whatever the Platform happened to volunteer. The publish call is its own guarantee — container creation refuses with the same error code and the same sentence — so a second call per Delivery would buy a duplicate answer that is less current than the one arriving next.

It costs a call to the Platform, so ask it when a human is choosing Channels rather than on every render.

Connecting a Channel is not in the API. It requires a human at a browser completing a platform's consent screen. The dashboard links to:

GET /api/connect/{platform}            →  302 to the platform
GET /api/connect/callback/{platform}   →  302 back to the dashboard

The flow is bound to the browser that started it: starting sets a short-lived HttpOnly cp_connect_{platform} cookie, and the callback refuses with connect_error=connect_session_mismatch unless the same browser presents it. A consent link opened in another browser connects nothing.

Not /api/auth/*. That prefix belongs to Better Auth, including its own social-login callbacks at /api/auth/callback/{provider} (ADR-0008). These paths are also the more honest name: this flow connects a Channel, it does not authenticate a human. The redirect URI registered with each platform must match /api/connect/callback/{platform} byte-for-byte.

Reconnecting a needs_reconnect Channel repairs it in place, so existing Deliveries may still run within their window. Deliberate disconnect cancels pending/unscheduled Deliveries, clears stored credentials and cached profile data, and frees the Slot. Cancelled Deliveries do not resume on reconnect; history is kept and an already-issued platform request may finish. Local disconnect does not revoke platform consent: remove CometPost in the platform's connected-app settings to do that too.

POST /v1/channels/{id}/disconnect

Disconnect needs a signed-in person: a key or an OAuth assistant gets 401, whatever its scope. DELETE /v1/media/{id} is the same. A key or assistant narrowed to some Channels also gets preview_url: null in GET /v1/media.

A social identity belongs to one Workspace globally, including while disconnected. Connecting it elsewhere redirects with connect=social_account_already_connected, without identifying the owner. Transfers require support. Claim and Slot accounting are transactional, so concurrent callbacks cannot exceed capacity or assign two owners.

Connecting is where Slots are counted (ADR-0003). A Workspace with no Slot left is refused with 402 slots_exhausted, before the person is sent to the platform and again when the callback would create the row. Reconnecting an identity the Workspace already holds is never refused: it occupies the Slot it always did.

A Workspace created after ADR-0003's free-signup cap has no Slots at all rather than the free tier's three, and is refused at the same point with 402 free_signups_closed. It is a separate code because it asks something different of the reader: slots_exhausted means disconnect one or upgrade, which is advice for someone who has Slots and filled them. This one means the subsidy is closed and the only way forward is a plan. The distinction is a stamp written at Workspace creation and never recomputed, so an early Workspace keeps the free tier it was promised however long ago it signed up.

When a connect is refused for a browser, the callback redirects to the dashboard with connect_error=<code>. The codes: slots_exhausted, free_signups_closed, platform_unavailable, unknown_platform, consent_denied, state_expired, social_account_already_connected, connect_session_mismatch, connect_failed, no_pages_granted (a Facebook login that granted no Pages) and connection_expired (the Page picker's pending connection expired, was used, or is not this person's).

Connecting several Channels from one login (Facebook Pages) #

A platform whose one login can grant many accounts (Facebook: one Channel per Page) does not create a Channel in the callback. It seals the candidates into a pending connection that lasts 15 minutes and redirects to the dashboard's picker at /connect/facebook?pending={id}. Both routes need a signed-in person of the Workspace that started the flow; a key or an OAuth assistant gets 401.

GET  /v1/connect/pending/{id}
POST /v1/connect/pending/{id}/select

GET returns { platform, expiresAt, slots: { occupied, limit }, candidates: [{ platformAccountId, name, handle, canPost, alreadyConnected }] } and never a token. POST takes { "platformAccountIds": ["..."] }, a non-empty array of strings, and returns 200 { connected: [{ id, handle }], skipped: [{ platformAccountId, reason }] }. A reason is not_offered, cannot_post, slots_exhausted, free_signups_closed, social_account_already_connected or connect_failed. Each Page goes through the same Slot and ownership checks as any connect, so Pages beyond the Slots are skipped, not half-created.

A malformed body is 400 invalid_selection and the pending connection is kept. A missing, expired, used or other-Workspace id is 404 connection_expired. The pending connection is deleted once something connected or no skip was a Slot reason; if the Slots were full it is kept, so the person can free one and choose again within the 15 minutes.

Human data export #

GET /v1/account/export requires a signed-in human; Credentials are refused. It downloads a JSON snapshot of the Workspace, profiles, Channel identities, Posts, variants, Delivery/Attempt history, media metadata, Credential summaries and webhook endpoints (URL, events, enabled; never the signing secret). Available media have temporary downloadUrl links so the person can save the files separately. Tokens, secret keys/hashes, login sessions and object storage keys are excluded. The response is Cache-Control: no-store and has an attachment filename. The dashboard exposes it under Settings → Account. Exporting does not request deletion.

Account deletion #

Both routes are for a signed-in human. A Credential or an OAuth agent gets 401: a key that could erase the account it belongs to would be a leaked key with no floor (ADR-0004, ADR-0022).

GET /v1/account/deletion-summary is what the confirm screen shows before anything is destroyed: the account email to type, and counts of channels, posts, media (stored files), scheduledDeliveries (cancelled, not published), credentials and assistants (access that ends), plus subscription: { willCancel, status, currentPeriodEnd }. It deletes nothing and needs no recent sign-in.

POST /v1/account/delete with { "confirmEmail": "…" } starts the deletion and answers 202 { "status": "deleting" }; a repeat answers 202 again. It is refused, with nothing started, unless:

RefusalCodeStatus
Not a signed-in humanunauthorized401
Origin is not the dashboard'sorigin_mismatch403
The session was created more than 15 minutes ago (sliding renewal does not count)reauthentication_required403
confirmEmail is not the account's email (case and padding ignored)confirm_email_mismatch400

Deletion is immediate and irreversible. A resumable workflow cancels Deliveries that have not frozen, revokes platform tokens where the platform documents an endpoint (YouTube) and deletes them everywhere, cancels an active Stripe subscription at once with no proration or final invoice (the Stripe Customer is kept as the billing record), deletes the Workspace's stored files and every row it and the User own, and sends one confirmation email. From the moment it starts, every request as that person, with any session, key or token, answers 403 account_deleting.

Billing #

Two routes, both returning a Stripe URL for a human to open. There is no plan-change, cancellation, card or invoice endpoint, and there will not be one — the Customer Portal owns all of it (ADR-0006, ADR-0015).

POST /v1/billing/checkout   { "plan": "growth", "interval": "monthly" }
POST /v1/billing/portal
{ "url": "https://billing.stripe.com/session/…" }

Both need a signed-in human: a Credential cannot commit its Workspace to a monthly charge. plan is starter, growth or scale; interval is monthly or annual and changes nothing but the Price. portal answers 409 no_subscription for a Workspace that has never subscribed.

Upgrades prorate immediately; downgrades take effect at the period end. A Workspace whose unpaid grace period has ended is refused at scheduling with 402 subscription_unpaid, and its still-pending Deliveries end cancelled — never failed, because nothing was attempted.

Posts #

POST /v1/posts
{
  "body": "Shipping the first version today.",
  "channelIds": ["chan_8f21…"],
  "mediaIds": [],
  "scheduledFor": "2026-10-01T16:00:00Z",
  "variants": { "threads": { "body": "A caption for Threads." } },
  "publishOptions": {}
}

scheduledFor omitted means now. Always send an ISO instant with Z or an explicit offset, never a timezone-less wall clock. A draft-only Credential's Posts land as drafts regardless of time; publication requires human approval. Plan scheduling horizon and attached asset expiry apply to creation and rescheduling. Platform variants detach captions from the base; Publish Options override Channel defaults and must match /v1/platforms constraints.

Publish Options are not a free-form extension point. A key the target platform does not list under /v1/platforms is rejected at schedule time (create, and any edit that supplies that platform's options) with 400, error code publish_options_unknown on create (content_rejected on edit), and a message naming the key and the keys that platform accepts, for example threads.mediaType (unknown; threads accepts no publish options). A non-empty options object for a platform none of the selected channels publishes to is rejected the same way; an empty object is accepted. Required-but-missing and bad-value problems keep publish_options_incomplete. The publish-time check does not reject unknown keys, so a Post stored before this rule still publishes.

Scheduled response (MCP summarizes this fan-out into a compact result):

{
  "post": { "id": "post_4c19…", "body": "Shipping the first version today.", "status": "scheduled" },
  "deliveries": [{ "id": "dlv_1…", "channelId": "chan_8f21…", "channelStatus": "connected", "effectiveAt": "2026-10-01T16:00:00.000Z", "status": "pending", "scheduled": true }]
}

A draft-only Credential gets { "id": "…", "state": "draft", "channels": ["@handle"], "channelStates": [{"id":"…","handle":"handle","status":"connected"}], "summary": "Drafted to @handle. A human has to publish it." }. Either branch may include warning. A partial workflow start also includes failedToStart in the scheduled response and marks those Deliveries unscheduled, with scheduled: false; use /schedule to re-arm them.

Idempotent create #

POST /v1/posts accepts an optional Idempotency-Key header (1 to 255 printable ASCII characters, no spaces; a random UUID is right). Use it whenever a lost reply would leave you unsure whether the Post exists.

  • Replay. The same key with the same body from the same caller within 24 hours returns the original result: 200 (the first answer was 201), the same JSON body, header Idempotent-Replay: true. No second Post, Delivery or workflow is created. The stored body is the original answer, including warning and failedToStart, even if the Post was edited or deleted since; read the Post for current state.
  • Same body means the same validated request: object key order and unknown fields do not matter, array order does.
  • Different body, same key: 409 idempotency_key_reused, nothing created.
  • Concurrent duplicates: one request wins; the other gets 409 idempotency_key_in_progress with retryable: true. Retry with the same key. If the first request died after creating the Post, the error names the Post id (retryable: false); read it with GET /v1/posts/{id}.
  • Only a created Post is remembered. A 4xx (invalid body, out-of-scope Channel, plan refusal) and a 5xx (including 503 scheduling_unavailable, where the Post is undone) store nothing, so the same key can be sent again once the cause is fixed.
  • Scope: a key belongs to the Workspace and the caller (one Credential, or the signed-in human). Another Credential using the same string is a different request, because a replay returns a stored response and Credentials differ in Channel scope.
  • A malformed key is 400 invalid_idempotency_key. Without the header nothing changes: every call creates a Post. Keys are purged daily after expiry.

Validate without creating #

POST /v1/posts/validate

The same body as POST /v1/posts, the same checks, nothing written. Use it to learn whether a Post would be accepted before spending a Post on it.

  • Success is 200:
    {
      "valid": true,
      "outcome": "scheduled",
      "effectiveAt": "2026-10-20T09:00:00.000Z",
      "channels": [{ "id": "ch_…", "platform": "threads", "handle": "cometpost", "status": "connected" }],
      "warning": "@cometpost needs reconnecting and will not publish until it is…"
    }

    outcome is draft for a Credential without may_schedule (the Post would land as a draft) and scheduled otherwise. warning is present when create would warn (a Channel that needs reconnecting).

  • Refusal is the exact response POST /v1/posts would give for that body: the same status and the same JSON, including problems on 400 content_rejected (each with its own stable code), the publish_options_* codes, 403 channel_out_of_scope, 402 plan limits and 409 Channel or media state. There is one error path for both routes. Both run the same function (checkCreate in apps/api/src/routes/posts.ts), so they cannot disagree.
  • Writes nothing: no Post, Delivery, Variant or workflow, and no Idempotency-Key row (the header is ignored). Media and Channels are only read.
  • Authorization is create's: any Credential or the signed-in human; a Channel outside the Credential's scope is the same 403, and a Channel or media id from another Workspace the same 400. The request counts against the daily allowance like any other.
  • What it cannot tell you: whether the platform will accept the content when it is published. That is decided at publish time, and only GET /v1/channels/{id}/prepare and the Delivery's Attempts speak to the account.
GET  /v1/posts              list, newest first; filter and page (below)
GET  /v1/posts/{id}         the full picture — every Delivery, every Attempt
PATCH /v1/posts/{id}        edit draft or pending content and destinations
DELETE /v1/posts/{id}       human-only permanent Post/history deletion
POST /v1/posts/{id}/cancel
POST /v1/posts/{id}/schedule  arm Deliveries whose workflow never started
POST /v1/posts/{id}/publish   a draft, by a signed-in human only

Both reads are bounded by the Credential's Channel scope: the list omits any Post whose Deliveries leave the scope, and reading one directly answers 404.

Listing and searching Posts #

GET /v1/posts returns { "posts": [...], "next_cursor": string | null }, newest first. Each row is compact: id, body, createdAt, state, deliveries (a count) and nextEffectiveAt; read one Post for the rest. With no parameters it is the newest 50, exactly as before plus next_cursor, so existing callers need no change. All parameters are optional and combine:

ParameterMeaning
statusOnly Posts in this derived state: draft, scheduled, unscheduled, publishing, published, sent_to_drafts, partially_published, failed, cancelled. Anything else is 400
channel_idOnly Posts with a Delivery on this Channel. A Channel outside the Credential's scope matches nothing, the same answer as a Channel that does not exist
from, toISO 8601 with Z or an offset. Half-open: from inclusive, to exclusive. from must be before to
range_onWhat from/to apply to: created (default; the Post's creation time, which is also the sort key) or scheduled (a Post matches when any of its Deliveries has effectiveAt in the range; drafts with Deliveries count)
qCase-insensitive substring of the Post's base caption, up to 200 characters. % and _ are literal. Platform variants are not searched
limitRows per page, 1 to 100, default 50
cursorThe previous page's next_cursor

Paging: follow next_cursor until it is null. The cursor is opaque; do not parse or build one. Repeat the same filters with it (it encodes only the position). Order is creation time then id, so a Post created while you page neither repeats nor shifts the rest, and Posts created in the same millisecond are not skipped. Because status is computed from Deliveries, a status filter examines at most 1000 Posts per request; when it has not filled the page by then it returns the short page, possibly empty, with a next_cursor to continue. Stop only when next_cursor is null. A bad cursor is 400 invalid_cursor; other bad parameters are 400 invalid_request.

Every one of these narrows the Credential's Channel scope and none replaces it: a scoped key lists only Posts wholly inside its scope, and no filter, search or cursor reaches a Post outside it. /schedule needs may_schedule; /publish needs a session and no Credential can reach it, whatever its permissions.

/cancel stops every Delivery still pending or unscheduled and answers { "id", "status", "state", "cancelled", "pastRecall" }. A Delivery already publishing, published or delivered_to_drafts cannot be recalled; it is listed in pastRecall as { "id", "status" }. The Post becomes cancelled only when at least one Delivery was stopped, so "cancelled": 0 with a nonempty pastRecall means the Post is live and state says so. state never reads cancelled while any Delivery is publishing, published or delivered_to_drafts.

GET /v1/posts/{id} is where the detail lives: per-Delivery status, effective time, the resolved content snapshot, platform post id, permalink, and the Attempt history with each platform's own status and error code. A Delivery that did not publish carries error in the shape below, read from the Attempt it points at rather than copied onto the Delivery, so this and the dashboard cannot describe one failure two ways (ADR-0013). failure_reason survives beside it as a debug string and is not for reading. state on a Post is always derived — partially_published is computed from the Deliveries and is never a stored value.

A Delivery status is one of pending, unscheduled, publishing, published, delivered_to_drafts, failed, cancelled. delivered_to_drafts (ADR-0023) means a draft-only platform (TikTok's inbox flow) accepted the content as a draft in the creator's app, which the creator must finish posting there. It is terminal, not a failure, never retried, past recall, and counts against Allowances and Quotas like a publish; platformPostId is the platform's handle for the draft and publishedAt is when it was delivered. A Post's state is sent_to_drafts when every Delivery is one; published and drafts together with nothing pending or failed read published, and a draft plus a failure reads partially_published. Do not report such a Delivery to a human as published.

PATCH accepts any nonempty subset of body, variants, publishOptions, mediaIds and channelIds. A variant value of null reverts to the base. Removing a pending destination cancels it; adding one creates a new Delivery. Publishing, published and failed destinations retain their frozen caption and options and cannot be removed. Shared media cannot change after any destination freezes. Draft-only Credentials can edit drafts, but cannot edit scheduled Posts or expand their Channel scope. Success returns { "id": "…", "updated": true }, optionally with failedToStart for newly added scheduled destinations.

For review, the detail includes effective platform captions/options and ordered media with short-lived previewUrl values. A human can pass { "expectedUpdatedAt": "<updatedAt from GET>" } to /publish; a changed draft returns 409 review_changed, requiring a fresh review. Release revalidates current content, media, connection and entitlement before starting workflows.

DELETE removes a draft or terminal Post and its variants, attachments, Deliveries and Attempts. Live scheduled/publishing destinations return 409 post_still_live: cancel or wait first. Success is { "id": "…", "deleted": true }. Media stays in the reusable library, and published social posts stay on their platforms. Delete library assets separately with /v1/media/{id}.

Scheduling against a Channel that is needs_reconnect warns rather than refuses: scheduling is a statement of intent about the future and the Channel may be repaired before the effective time. The 201 carries the warning and each Channel's state, which is how an agent finds out.

Deliveries #

A Delivery is never created directly. It is created by fan-out and controlled individually:

POST /v1/deliveries/{id}/reschedule   { "effectiveAt": "2026-10-01T16:00:00Z" }
POST /v1/deliveries/{id}/cancel

Both refuse once the Delivery has left pending — a Delivery freezes when it goes publishing, and published is terminal. Rescheduling also needs may_schedule: it chooses when something publishes, and time is content (ADR-0013).

A signed-in human can deliberately retry a failed destination:

POST /v1/deliveries/{id}/retry   { "effectiveAt": "2026-10-01T16:00:00Z" }

The retry is a new Delivery linked by retryOfDeliveryId, preserving the failed caption and options. Repeated submissions return the same replacement; success is 201 (new) or 200 (existing), with id, postId, retryOfDeliveryId and scheduled. An unarmed replacement includes a warning and can be re-armed with /schedule. Credentials cannot retry terminal failures. Published, sent-to-drafts and in-flight destinations, unavailable media, and uncertain platform outcomes are refused. The detail read includes retryProblem so clients can explain the refusal.

Media #

Three ways in, because agents differ in what they can physically do. All three produce a Media Asset id that a Post references.

Intake limits are cometpost's own and are smaller than most platforms' (a platform may accept far more than a file can be sent to us). They are stated by GET /v1/platforms as mediaIntake, read from the constants the intake code enforces:

PathLimitmediaIntake field
Presigned direct upload512 MiBdirectUpload.maxBytes (and uploadUrlTtlSeconds, 900)
Pull-from-URL64 MiBurlPull.maxBytes
Inline base645 MiBinline.maxBytes

Each entry carries a one-sentence howTo. A file over 64 MiB, or one with no public URL, can only come in by presigned upload.

Free intake and upload completion require both the recorded free entitlement and a connected Channel: refusals are 402 free_signups_closed or 402 connected_channel_required. Repair a needs_reconnect Channel first. Paid plans may ingest without a connected Channel; expired unpaid Grace returns 402 subscription_unpaid.

Presigned upload — the first-class path:

POST /v1/media    { "filename": "clip.mp4", "contentType": "video/mp4", "sizeBytes": 8123456 }
→ { "id": "media_71bc…", "upload_url": "https://…", "expires_at": "…" }

The client PUTs exactly sizeBytes bytes to upload_url using the declared Content-Type (the length is part of the signed URL, so storage refuses any other size), then calls POST /v1/media/{id}/complete with no body. Only its ready result may be attached to a Post. Completion returns id, status, size_bytes, content_type, probe_status, video (see Video facts below) and expires_at; a missing upload returns 409 media_not_uploaded, which can be retried after bytes arrive. Completion is final: calling it again on a ready asset returns the same asset, and on a refused or expired one answers 409 media_not_pending. The URL stays usable for 15 minutes, but once the asset is ready its bytes have been moved to a key the URL does not name, so a later PUT changes nothing. If the object changes while it is being checked, completion answers 409 media_changed and the asset is discarded.

Pull-from-URL — for clients that have a URL but cannot PUT bytes:

POST /v1/media    { "source_url": "https://example.com/clip.mp4" }

Inline — small images only, ≤5MB base64. Video is always too large for this to be useful, so it is a compatibility shim, not a design target.

POST /v1/media    { "data": "iVBORw0KGgo…", "content_type": "image/png", "filename": "chart.png" }

Pull-from-URL and inline both answer with the asset itself — id, status, content_type, size_bytes, probe_status, video, expires_at — already ready, because we counted the bytes ourselves and there is no /complete step to take.

Every pull-from-URL fetch is guarded, without exception:

  • The hostname is resolved and every address it points at is checked against private, loopback, link-local, unique-local and carrier-grade-NAT ranges, for IPv4 and IPv6 alike, before connecting. Without this, POST /v1/media {"source_url": "http://169.254.169.254/…"} reads our cloud metadata service, and a name with an A record pointing there does it just as well.
  • The connection is pinned to the address that passed, rather than resolving the name a second time — the gap between the two answers is DNS rebinding.
  • Every redirect hop is re-checked the same way. Cross-host redirects are allowed (a CDN is one), but a redirect into a blocked range is refused, and so is one into a non-HTTP scheme.
  • The body is counted while streaming and abandoned mid-transfer at the cap. Content-Length can shorten a fetch and can never permit one: it is written by whoever chose the URL.
  • One deadline bounds the whole fetch, connection through last byte.
  • Pulled bodies are capped at 64MB, below the 512MB upload cap, because a pulled body is buffered in the API process before it is judged. Video comes in by presigned upload, where the bytes never pass through us.

Platforms fetch media from public URLs themselves (Meta downloads from a publicly reachable server rather than accepting bytes), so cometpost hosts Rendition URLs regardless. Pull-from-URL is adjacent to something we already do.

GET /v1/media lists workspace assets, including width, height, probe_status, video and a short-lived preview_url for available ready media. Reuse ready IDs directly; the composer and saved-post media replacement also expose this library. Library items precede new uploads in attachment order. DELETE /v1/media/{id} refuses assets needed by live Deliveries. For terminal history it deletes the object and keeps an expired placeholder rather than erasing the attachment.

Video facts. A video is probed with ffprobe as it arrives, and what the file says about itself is stored and returned on the asset. Probing is not transcoding: nothing is converted, and the facts exist so a Platform that rejects videos by codec, frame rate, size or length can be answered at schedule time, with its reason, rather than by the Platform after publishing (ADR-0011).

{
  "id": "media_71bc…",
  "content_type": "video/mp4",
  "probe_status": "probed",
  "video": {
    "container": "mov,mp4,m4a,3gp,3g2,mj2",
    "codec": "h264",
    "fps": 29.97,
    "width": 1080,
    "height": 1920,
    "duration_seconds": 42.5,
    "has_audio": true
  }
}
  • probe_status is "probed", "unprobed", or null for an image (nothing to probe). video is an object only when probed; it is null otherwise, so a null codec never means "did not look".
  • unprobed is not a refusal at upload. A video that is truncated, is not really a video (an image sent as video/mp4), is corrupt, or could not be read inside the time limit is still accepted and stored. It is refused later, at schedule time, only by a Platform whose rules depend on what is in the video, with media_unprobed: "We could not read this video…". Platforms without such rules are unaffected. Every video uploaded before probing existed reads as unprobed; upload it again to have it probed.
  • width and height are the displayed size: a phone video stored sideways with a rotation flag is reported upright. For a probed video they replace any size the client declared.
  • fps is the average frame rate. A variable-frame-rate video reports its mean, which can differ from the nominal rate in the file header.
  • Schedule-time refusals for video carry stable codes, each message naming the limit: media_unprobed, media_video_codec, media_video_fps, media_video_dimensions, media_video_duration, alongside the existing media_aspect_ratio.
  • Probing runs inside the request and is bounded: at most 8 seconds in all (waiting for a probe slot, copying, ffprobe), ffprobe itself killed at 5 seconds, its output capped at 256 KiB, and at most two probes at a time per API process. A stored upload is probed from its first and last 16 MiB, not read whole. ffprobe runs without network access, a shell or any caller-supplied argument, and a hostile file can at worst cost those bounds and come back unprobed.

What arrives is checked, never what was claimed. The declared size and content type are a client's claim at presign; the object is the fact. After the bytes land, the real size is re-checked against the cap and against the declaration, and the real content type against both the declaration and "is this media at all" — an upload declaring 1MB and carrying 40GB is refused rather than marked ready (media_size_mismatch, media_too_large, media_type_mismatch, media_type_unsupported, media_empty). Which image types a platform accepts is a Capability, checked at schedule time (ADR-0011); this is only the global cap upload owes.

Volume is capped per Workspace per day, across all three intake paths together (ADR-0004's last SSRF guard). Each individual fetch was already bounded — address checked per hop, body capped mid-stream, deadline on the whole thing — and none of that limited how many of them a Credential could ask for. Over the allowance the answer is 429 ingest_cap_exhausted, naming the UTC instant it resets, and it is the one media refusal marked retryable: the others are facts about the object and do not change overnight.

The check runs before any bytes move — at presign, and before the request goes out on a pull, because refusing after the transfer has already paid the egress the cap exists to prevent. A presign reserves its declared size for the rest of the day (or until the upload is completed, refused or deleted), so several large presigns cannot be opened at once to outrun the cap. /complete checks again against the object that really arrived and, if the day has no room for it, deletes it and answers 429 ingest_cap_exhausted. What gets counted is what arrived, never what was declared.

Pull-from-URL also has a concurrency limit: a few transfers per API process, and fewer per Workspace. Over it the answer is 429 media_busy, retryable; wait a few seconds.

A refused asset goes to a terminal rejected and its object is deleted in the same call. The row survives to say why the id will never work; it can never be attached to a Post, and the retention sweep collects it like any other.

Webhooks #

Register an HTTPS endpoint and cometpost POSTs a signed event to it when a publish result is known, so an integration does not have to poll (ADR-0024). Every event is written in the same database transaction as the state change it reports, and sent at least once.

EventWhen
delivery.publishedA Delivery is live on its platform (platform_post_id, permalink)
delivery.delivered_to_draftsA Delivery was handed to the creator as a draft (TikTok). Not a post, so not delivery.published (ADR-0023)
delivery.failedA Delivery failed permanently, with the Attempt's error (code, message, retryable)
channel.needs_reconnectA Channel's token stopped working; a human must reconnect it
post.cancelledA Post was cancelled (and at least one Delivery with it)
webhook.testOnly from POST /v1/webhooks/{id}/test, only to that endpoint

Payloads carry ids and states, nothing a GET /v1/posts/{id} would not show and never a token, a caption or a media URL:

{
  "id": "evt_9f2c41d8b7a04e7f8c3e0a1b2c3d4e5f",
  "type": "delivery.published",
  "created_at": "2026-10-18T12:00:00.000Z",
  "data": {
    "delivery": {
      "id": "…", "post_id": "…", "channel_id": "…", "platform": "threads",
      "status": "published", "platform_post_id": "1789…",
      "permalink": "https://www.threads.net/@handle/post/…",
      "published_at": "2026-10-18T12:00:00.000Z"
    }
  }
}

Who may do what. Reading (list, get, the delivery log) takes a session or any Credential whose Channel scope is the whole Workspace; a narrowed one gets 403 workspace_scope_required, because the log names every Channel's Deliveries. Changing anything takes a session or a cp_live_ key minted with manageWebhooks (see What a Credential constrains); every other key and every assistant gets 401. A suspended Workspace is refused like everywhere else.

RouteDoes
GET /v1/webhooksEndpoints (never the secret) and the plan's endpoint_limit
POST /v1/webhooks{ "url", "events?", "description?" }. 201 with secret, shown once
GET /v1/webhooks/{id}One endpoint
PATCH /v1/webhooks/{id}Any of url (re-checked), events, description, enabled
DELETE /v1/webhooks/{id}Stops sending at once; its delivery log goes with it
POST /v1/webhooks/{id}/rotate-secret{ "grace_period_seconds?" }. New secret, shown once; the old one keeps signing for the grace period, default and maximum 24 hours, 0 to end it now
POST /v1/webhooks/{id}/test202: queues one signed webhook.test event
GET /v1/webhook-deliveriesThe delivery log, newest first: endpoint_id?, status? (pending, succeeded, failed), event_type?, limit? (1 to 100, default 20), cursor?
POST /v1/webhook-deliveries/{id}/redeliver202: a new delivery of the same event. Not for one still pending (409 webhook_delivery_pending)

Destinations. https only, no credentials in the URL, not a cometpost host, and every address the name resolves to must be public: private, loopback, link-local (the cloud metadata service), carrier-grade NAT, unique-local and their IPv6-embedded forms are refused (400 webhook_url_blocked; a bad URL is webhook_url_invalid, a name that does not resolve webhook_url_unresolved). This is checked when the URL is registered and again on every send, the connection is pinned to the address that passed, and redirects are not followed: a 3xx is a failed attempt. Register the final URL.

Signature. The Standard Webhooks scheme, so any of its libraries verifies us:

POST /your/endpoint
content-type: application/json
webhook-id: evt_9f2c41d8b7a04e7f8c3e0a1b2c3d4e5f
webhook-timestamp: 1760788800
webhook-signature: v1,<base64 HMAC>   (two, space-separated, during a rotation)

The signature is base64(HMAC-SHA256(key, "{webhook-id}.{webhook-timestamp}.{raw body}")), where key is the secret after whsec_, base64-decoded. During a rotation's grace period the header carries two space-separated v1, signatures; accept either. In Node:

import { createHmac, timingSafeEqual } from "node:crypto";

function verify(secret: string, headers: Headers, rawBody: string): boolean {
  const id = headers.get("webhook-id"), ts = headers.get("webhook-timestamp"), sig = headers.get("webhook-signature");
  if (!id || !ts || !sig) return false;
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // the replay window
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${ts}.${rawBody}`).digest();
  return sig.split(" ").some((part) => {
    const given = Buffer.from(part.replace(/^v1,/, ""), "base64");
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}

Replay window and dedupe. Reject a webhook-timestamp more than five minutes from your clock; within it, a repeated webhook-id is a duplicate. The id is the event's, identical on every retry and on a manual redelivery, so "process each webhook-id once" makes at-least-once delivery exactly-once for you. Verify against the raw bytes of the body, before parsing it.

Retries. Answer any 2xx within 10 seconds; the body is ignored. Anything else (an error status, a redirect, a timeout, a refused destination) is retried with exponential backoff from 30 seconds, doubling, capped at six hours, with a little jitter: up to 15 attempts over about a day and a half, and never more than 72 hours after the event. Then the delivery is failed and can be redelivered. The log keeps the status code and at most 256 characters of your reply, never more of the body, for 30 days. Events are not guaranteed to arrive in order; use created_at and the resource's own state.

Plan limits. Endpoints per Workspace: Free 1, Starter 5, Growth 10, Scale 25 (402 webhook_endpoint_limit past it). Sends per Workspace per minute: 60, 300, 600, 1200; past that they wait, they are not dropped. Test events plus redeliveries per Workspace per hour: 20, 100, 100, 200 (429 webhook_manual_send_limit, retryable).

Suspension and deletion. Nothing is sent for a suspended Workspace; its deliveries wait without spending attempts and resume if the suspension is lifted within the 72-hour limit. Starting account deletion deletes every endpoint and pending delivery first, so nothing more leaves for that account.


Errors #

{
  "error": {
    "code": "channel_needs_reconnect",
    "message": "@cometpost needs reconnecting before it can publish. Ask the human to visit /channels and reconnect it.",
    "retryable": false
  }
}

Three fields, each for a different reader:

  • code — stable, machine-readable, for our SDK and dashboard. It never changes meaning.
  • message — for the agent, and it states the next action, not just the fault. An agent that receives 409 CHANNEL_DISCONNECTED and nothing else will retry until it gives up.
  • retryable — the one thing an agent cannot reliably infer. We already compute exactly this distinction in the Provider port's error classification, so this surfaces a judgement the domain already makes rather than inventing one.

Delivery-level failures carry the platform's own status and error code through to the Attempt record, unchanged.

HTTPWhen
400Malformed request
401Missing, unknown, expired or revoked Credential or OAuth token — or an action that needs a signed-in human (releasing a draft, retrying a failed delivery, billing, managing Credentials)
403may_schedule refuses this action, or a new Post names a Channel outside the scope. Decided before anything is looked up, so it confirms nothing about what exists. Also reauthentication_required (a destructive action needs a sign-in from the last 15 minutes), origin_mismatch, account_deleting (the account's deletion has begun), and workspace_suspended (an operator suspended the workspace for abuse: every call is refused except exporting data and deleting the account; email hello@cometpost.app to appeal). A delivery that wakes while suspended fails with the same code
402The plan is what refuses this — no Slot left, or an unpaid subscription past its grace period
404No such resource in this Workspace, or it is outside this Credential's Channel scope. The two are deliberately indistinguishable: 403 would let a leaked key enumerate the Workspace by watching which ids it confirmed
409Valid request, wrong state — a Delivery past pending, a disconnected Channel. On POST /v1/posts also idempotency_key_reused and idempotency_key_in_progress
429Rate limited per Credential, or per connected assistant (1000 requests per day each). See Rate-limit responses below
502A platform failed us; retryable says whether to try again

Rate-limit responses #

A daily-allowance 429 says when the allowance returns, so a client can schedule its retry instead of guessing:

HTTP/1.1 429 Too Many Requests
Retry-After: 5400
RateLimit-Limit: 1000
RateLimit-Remaining: 0
RateLimit-Reset: 5400
{
  "error": {
    "code": "rate_limited",
    "message": "This credential has used its request allowance for today. It may call again in 5400 seconds.",
    "retryable": true,
    "retry_after_seconds": 5400
  },
  "rate_limit": { "limit": 1000, "remaining": 0, "reset_at": "2026-10-18T00:00:00.000Z", "window": "day" }
}

Retry-After and RateLimit-Reset are delays in whole seconds (never zero). The body repeats them for clients that cannot see headers. For an assistant the day is a UTC calendar day, so the wait is the time to the next UTC midnight. For a cp_live_ key the count resets only once a full day has passed since the key's most recent counted request (that is how the key limiter in @better-auth/api-key 1.6 behaves, read from its source on 2026-10-18; a key in steady use is therefore never reset by the calendar), and the wait is what the key limiter reports; if it ever reports none, the 429 is unchanged but carries no Retry-After, retry_after_seconds or reset_at. Every authenticated request spends one request, so an MCP tool call spends at least two (the transport check plus the tool's own call): 1000 requests is not 1000 tool calls. The media-intake 429s (ingest_cap_exhausted, media_busy) are Workspace allowances, not this one, and carry their own wording.

Every response to a Credential or assistant, not only the 429, carries what is left of its daily allowance after that request:

RateLimit-Limit: 1000
RateLimit-Remaining: 958
RateLimit-Reset: 41520

RateLimit-Reset is whole seconds until the allowance returns: for an assistant, the next UTC midnight; for a key, a full day after its most recent counted request, which is when its limiter resets the count (the same figure its 429 reports). A signed-in person has no daily allowance and gets none of these headers. They cost no extra database read: the numbers come from the rows authentication already touches.


Versioning #

/v1 is additive-only. No field ever changes meaning, and none is removed inside a major version. A breaking change is a new prefix.

This is stricter than it sounds, and deliberately so: our clients are language models reading a schema, not developers reading a changelog. Nobody is going to read our migration notes. The tool schema is the artifact that genuinely versions — MCP clients re-read it every session and honour notifications/tools/list_changed, so the surface can evolve live in a way the REST body never can.


The MCP surface #

Hosted at https://mcp.cometpost.app/mcp (Streamable HTTP); client setup is in apps/mcp/README.md. Seventeen tools. Shaped by the domain, not by the routes above — a wrapper with one tool per endpoint is the documented anti-pattern, and the tool list is a design artifact in its own right.

ToolArgumentsReturns
cometpost_list_channels—Handles, platforms and statuses — of the Channels this Credential may use, which are the only ones it is shown
cometpost_list_platforms—Caption limits, media constraints and supported Publish Options
cometpost_prepare_channelchannel_idGET /v1/channels/{id}/prepare: what one Channel can do right now: legal options, problems standing between it and publishing, quota where the platform volunteers it
cometpost_validate_postthe content arguments of cometpost_create_postPOST /v1/posts/validate: valid: true with outcome (scheduled or draft), or valid: false with the reason and each coded problems entry. Creates nothing
cometpost_list_media—Workspace assets with readiness and expiry for reuse
cometpost_create_postbody, channels, scheduled_for?, media_ids?, variants?, publish_options?, idempotency_key?Post id, whether it was scheduled or left as a draft, and one sentence to relay. Sends an Idempotency-Key; if the reply is lost the error carries the key and says retrying the same call is safe
cometpost_edit_postpost_id, optional body, variants, publish_options, media_ids, channelsSaved changes, preserving frozen destinations; null variant reverts
cometpost_list_postsstatus?, channel_id?, from?, to?, range_on?, q?, limit? (default 20), cursor?Compact Posts (caption preview, state, Delivery count, next publish time) and next_cursor; the filters of GET /v1/posts
cometpost_get_postpost_id, detail? (summary default, or full)Per-Channel outcome, permalinks, failure reasons; full adds caption, variants, media, publish options, provenance and Attempt history
cometpost_cancel_postpost_idWhat was cancelled, what was already past recall
cometpost_reschedule_deliverydelivery_id, effective_atThe new time, or why it can no longer move
cometpost_schedule_postpost_idHow many Deliveries it armed, and how many still could not be. Safe to repeat — it only ever touches Deliveries with no workflow behind them
cometpost_upload_mediasource_url, or data+content_typeMedia id, already ready (URL up to 64 MiB, inline up to 5 MiB)
cometpost_start_media_uploadfilename, content_type, size_bytes, width?, height?The presigned branch of POST /v1/media: id, upload_url and what to PUT (method, headers, exact body length). For an agent that can make a PUT and holds a file over 64 MiB (up to 512 MiB) or with no public URL. The tool never receives the file
cometpost_complete_media_uploadmedia_idPOST /v1/media/{id}/complete: the asset (ready, size, type, probe facts). media_not_uploaded is retryable once the PUT lands. Safe to repeat
cometpost_list_webhooks—GET /v1/webhooks: each webhook endpoint's id, URL, subscribed events and whether it is enabled. Never a signing secret. Needs a credential that covers every Channel
cometpost_list_webhook_deliveriesendpoint_id?, status?, event_type?, limit? (default 20), cursor?GET /v1/webhook-deliveries: newest first, each with event id and type, status, attempts, last HTTP status and a short reply snippet, plus next_cursor. Read only: registering, rotating and redelivering are REST-only

Not tools, on purpose:

  • Human draft approval and terminal-failure retry — use the dashboard.
  • Connecting a Channel — needs a human completing a consent screen. A tool that cannot succeed is worse than no tool.
  • Anything touching billing — no agent should be able to change what a Workspace pays.
  • Deleting a Media Asset — destructive, irreversible, and never urgent.
  • Minting a Credential — a key that can mint keys has no blast radius.
  • Registering, changing or redelivering a webhook — choosing where a Workspace's events are sent is not a decision a prompt should make. The two webhook tools only read.
  • A tool that absorbs the whole upload — the MCP server never holds file bytes (#21, ADR-0006). The presigned pair above returns the URL and the facts and the agent's own PUT goes straight to storage; an agent that cannot make a PUT uses cometpost_upload_media with a URL.

Two conventions the tool definitions follow:

Descriptions say when to call, not just what the tool does. "Use this when the human asks to publish or schedule something" earns its tokens; "creates a post" does not.

Returns are compact. cometpost_create_post returns an id, a deadline, and a sentence — not the Delivery array, which is an order of magnitude more tokens for information the model will not use. Detail is one cometpost_get_post away.

Publishing tools are annotated destructiveHint: true and idempotentHint: false. The annotations are honest signalling, not a control — a client is free to ignore them. The enforced gate is may_schedule on the Credential, checked server-side on every request.


Notifications #

cometpost emails a Workspace about two things, both of the form something needs you, never you did a thing:

EventCadenceWhy
A Channel went needs_reconnectImmediateThe only one where acting early prevents future failures rather than reporting past ones — every pending Delivery to that Channel will fail until a human reconnects it
A Delivery failed permanentlyDebounced ~60sNobody is watching, and published-is-terminal means a silent failure is a post that simply never happened

There is deliberately no "your agent created drafts" email. The human triggered the agent; telling them it did what they asked is noise.

Failure emails lead with a translation and carry the platform's own error beneath it — "Threads rejected this because the token expired; reconnect @handle", then the raw Failed to decode. We already store the platform status, code, and message on every Attempt, so both halves are free.

Debouncing matters more than it looks: an agent that fans out twelve Deliveries and fails all twelve must produce one email, not twelve. Over-notifying does not merely annoy — it is how someone turns the emails off, and these are the only channel that reports a broken Channel.

Every Member receives them, with per-event-type toggles in settings rather than a literal unsubscribe. Operational mail is exempt from one-click-unsubscribe obligations, so this is a product choice: letting the last recipient silence "your channel is broken" makes the product fail quietly and get blamed loudly.

GET /v1/settings/notifications #

Human session only. Returns the current Member's three controls:

{
  "channelNeedsReconnect": true,
  "deliveryFailed": true,
  "graceStarted": true
}

graceStarted gates the one email sent when a grace period begins (a failed payment, or a downgrade that left more channels than the new plan includes).

PATCH /v1/settings/notifications #

Human session only. Accepts any of the booleans above and returns all three, updated. An omitted key is unchanged; there is deliberately no global unsubscribe field.

This page is generated from the product documentation. Spotted a mistake? Email hello@cometpost.app.