cometpost API v1
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:
| Constraint | Values | Effect |
|---|---|---|
may_schedule | true / false, default false | false 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_ids | list, or null for all | The 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 false | May 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_in | seconds, or null — no default | Offered 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 | |
|---|---|
| Issuer | https://app.cometpost.app/api/auth — the dashboard origin, because the Google session cookie lives there (ADR-0012) |
| Authorization-server metadata | https://app.cometpost.app/.well-known/oauth-authorization-server/api/auth |
| Protected resource | https://mcp.cometpost.app/mcp |
| Protected-resource metadata | https://mcp.cometpost.app/.well-known/oauth-protected-resource |
/v1 | unchanged, 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_idsabove: 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 saynone, and a cookie-less request asking for another method is registered as a public client anyway - redirect URIs must be
httpsor loopback - grant types are
authorization_codeandrefresh_token—client_credentialsis 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.
| Situation | Response |
|---|---|
No token at /mcp | 401 with WWW-Authenticate: Bearer resource_metadata="https://mcp.cometpost.app/.well-known/oauth-protected-resource" |
| Token expired, revoked or unknown | 401 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 grant | 403, the same body a scoped key gets |
| Human-only action | 401, the same body a key gets |
| Over 1000 requests in a UTC day | 429 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
problemsmeans "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:
| Refusal | Code | Status |
|---|---|---|
| Not a signed-in human | unauthorized | 401 |
Origin is not the dashboard's | origin_mismatch | 403 |
| The session was created more than 15 minutes ago (sliding renewal does not count) | reauthentication_required | 403 |
confirmEmail is not the account's email (case and padding ignored) | confirm_email_mismatch | 400 |
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 was201), the same JSON body, headerIdempotent-Replay: true. No second Post, Delivery or workflow is created. The stored body is the original answer, includingwarningandfailedToStart, 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_progresswithretryable: 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 withGET /v1/posts/{id}. - Only a created Post is remembered. A
4xx(invalid body, out-of-scope Channel, plan refusal) and a5xx(including503 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…" }outcomeisdraftfor a Credential withoutmay_schedule(the Post would land as a draft) andscheduledotherwise.warningis present when create would warn (a Channel that needs reconnecting). - Refusal is the exact response
POST /v1/postswould give for that body: the same status and the same JSON, includingproblemson400 content_rejected(each with its own stablecode), thepublish_options_*codes,403 channel_out_of_scope,402plan limits and409Channel or media state. There is one error path for both routes. Both run the same function (checkCreateinapps/api/src/routes/posts.ts), so they cannot disagree. - Writes nothing: no Post, Delivery, Variant or workflow, and no
Idempotency-Keyrow (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 same400. 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}/prepareand 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:
| Parameter | Meaning |
|---|---|
status | Only Posts in this derived state: draft, scheduled, unscheduled, publishing, published, sent_to_drafts, partially_published, failed, cancelled. Anything else is 400 |
channel_id | Only 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, to | ISO 8601 with Z or an offset. Half-open: from inclusive, to exclusive. from must be before to |
range_on | What 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) |
q | Case-insensitive substring of the Post's base caption, up to 200 characters. % and _ are literal. Platform variants are not searched |
limit | Rows per page, 1 to 100, default 50 |
cursor | The 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:
| Path | Limit | mediaIntake field |
|---|---|---|
| Presigned direct upload | 512 MiB | directUpload.maxBytes (and uploadUrlTtlSeconds, 900) |
| Pull-from-URL | 64 MiB | urlPull.maxBytes |
| Inline base64 | 5 MiB | inline.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-Lengthcan 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_statusis"probed","unprobed", ornullfor an image (nothing to probe).videois an object only when probed; it isnullotherwise, so anullcodec never means "did not look".unprobedis not a refusal at upload. A video that is truncated, is not really a video (an image sent asvideo/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, withmedia_unprobed: "We could not read this video…". Platforms without such rules are unaffected. Every video uploaded before probing existed reads asunprobed; upload it again to have it probed.widthandheightare 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.fpsis 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 existingmedia_aspect_ratio. - Probing runs inside the request and is bounded: at most 8 seconds in all
(waiting for a probe slot, copying,
ffprobe),ffprobeitself 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.ffproberuns without network access, a shell or any caller-supplied argument, and a hostile file can at worst cost those bounds and come backunprobed.
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.
| Event | When |
|---|---|
delivery.published | A Delivery is live on its platform (platform_post_id, permalink) |
delivery.delivered_to_drafts | A Delivery was handed to the creator as a draft (TikTok). Not a post, so not delivery.published (ADR-0023) |
delivery.failed | A Delivery failed permanently, with the Attempt's error (code, message, retryable) |
channel.needs_reconnect | A Channel's token stopped working; a human must reconnect it |
post.cancelled | A Post was cancelled (and at least one Delivery with it) |
webhook.test | Only 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.
| Route | Does |
|---|---|
GET /v1/webhooks | Endpoints (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}/test | 202: queues one signed webhook.test event |
GET /v1/webhook-deliveries | The delivery log, newest first: endpoint_id?, status? (pending, succeeded, failed), event_type?, limit? (1 to 100, default 20), cursor? |
POST /v1/webhook-deliveries/{id}/redeliver | 202: 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 receives409 CHANNEL_DISCONNECTEDand 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.
| HTTP | When |
|---|---|
| 400 | Malformed request |
| 401 | Missing, 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) |
| 403 | may_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 |
| 402 | The plan is what refuses this — no Slot left, or an unpaid subscription past its grace period |
| 404 | No 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 |
| 409 | Valid request, wrong state — a Delivery past pending, a disconnected Channel. On POST /v1/posts also idempotency_key_reused and idempotency_key_in_progress |
| 429 | Rate limited per Credential, or per connected assistant (1000 requests per day each). See Rate-limit responses below |
| 502 | A 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.
| Tool | Arguments | Returns |
|---|---|---|
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_channel | channel_id | GET /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_post | the content arguments of cometpost_create_post | POST /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_post | body, 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_post | post_id, optional body, variants, publish_options, media_ids, channels | Saved changes, preserving frozen destinations; null variant reverts |
cometpost_list_posts | status?, 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_post | post_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_post | post_id | What was cancelled, what was already past recall |
cometpost_reschedule_delivery | delivery_id, effective_at | The new time, or why it can no longer move |
cometpost_schedule_post | post_id | How 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_media | source_url, or data+content_type | Media id, already ready (URL up to 64 MiB, inline up to 5 MiB) |
cometpost_start_media_upload | filename, 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_upload | media_id | POST /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_deliveries | endpoint_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
PUTgoes straight to storage; an agent that cannot make aPUTusescometpost_upload_mediawith 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:
| Event | Cadence | Why |
|---|---|---|
A Channel went needs_reconnect | Immediate | The 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 permanently | Debounced ~60s | Nobody 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.