Postering REST API (v1)
A complete, agent-drivable HTTP API. Everything the dashboard can do, you can do with a bearer token: upload media, compose posts, schedule them, cancel, retry, inspect per-channel results.
There are no cookies, no CSRF tokens and no browser steps. A handful of curl calls is enough to go from nothing to a scheduled post.
Scope. This product schedules and publishes posts. There are no AI features and no analytics endpoints. You supply the text and the media.
Start here: zero to published, API only
docs/zero-to-published.md — served at /docs/zero-to-published — is a real transcript of eight curl calls taking an agent from nothing but an email and password to a published post: bootstrap, mint a key, discover providers, connect an account, upload a 12 MB video, and schedule it. Read that first if you are automating this product.
Connecting a channel: per-provider runbooks
Before you can publish, an account must be connected. Each provider has a step-by-step runbook, written to be followed literally by a person or by an automated agent driving a browser — exact URLs, exact button names, exact values, [HUMAN STEP] markers where a human must log in or approve something, and a closing verification call.
| Provider | Runbook |
|---|---|
| Bluesky | /docs/connect/bluesky |
/docs/connect/instagram |
|
| Stub (local testing) | /docs/connect/stub |
These pages are public — no API key, no sign-in — so an agent can read the instructions before its user has an account here.
You do not need to hard-code those paths: GET /api/v1/providers (also public) returns a setupGuideUrl for every provider it lists. Start there.
1. Base URL and versioning
https://postering-mauve.vercel.app/api/v1
Every path below is relative to that. The version is in the path; a breaking change ships as /api/v2.
Two endpoints need no authentication, so a client can orient itself before it has a key: GET /api/v1/info (brand, rate limit, providers) and GET /api/v1/providers (full capabilities plus each provider's setup runbook). Everything else requires a key.
2. Authentication
Send an API key as a bearer token:
Authorization: Bearer pk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Getting a key: sign in to the dashboard → Settings → API keys → name it → Create key. The full key is displayed exactly once. It is stored only as a SHA-256 hash plus a display prefix, so it cannot be recovered later — if you lose it, revoke it and make a new one.
A key carries the full permissions of the account that created it. Keys can be revoked at any time from the same page, and last_used_at is tracked.
| Situation | Status | error.code |
|---|---|---|
No Authorization header |
401 | unauthorized |
| Unknown or revoked key | 401 | unauthorized |
| API disabled for this brand | 403 | forbidden |
| Over the rate limit | 429 | rate_limited |
3. Response envelope
Success — always an object with data, plus meta on list endpoints:
{
"data": { "id": "post_...", "status": "scheduled" },
"meta": { "total": 42, "limit": 25, "offset": 0 }
}
Failure — always an object with error:
{
"error": {
"code": "validation_error",
"message": "This post does not fit every selected channel.",
"details": [
{ "field": "content", "message": "Bluesky (@you.bsky.social) allows 300 characters; this post has 412." }
]
}
}
error.code is stable and machine-readable; error.message is for humans; error.details is a (possibly empty) list of field-level problems.
Error codes
| Code | HTTP | Meaning |
|---|---|---|
validation_error |
422 | The request body or query failed validation. See details. |
unauthorized |
401 | Missing, unknown or revoked API key. |
forbidden |
403 | The API is disabled on this instance. |
not_found |
404 | No such post/asset/account for this account. |
conflict |
409 | The object is in a state that forbids this operation (e.g. editing a published post). |
payload_too_large |
413 | Upload exceeds MAX_UPLOAD_BYTES. |
unsupported_media_type |
415 | File type not accepted. |
rate_limited |
429 | Too many requests. See Retry-After. |
provider_error |
502 | The social provider rejected the request. |
internal_error |
500 | Unexpected server error. |
Rate limiting
Every authenticated response carries:
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 118
X-RateLimit-Reset: 1756641600
The default is 120 requests/minute per key (API_RATE_LIMIT_PER_MINUTE). A 429 also includes Retry-After, in seconds.
4. Scheduling and timezone semantics
This is the part worth reading twice.
POST /posts and PATCH /posts/:id accept two fields:
scheduledAt— when to publish, ornull/omitted for "as soon as possible".timezone— an IANA zone name (Europe/Lisbon,America/New_York,UTC).
How scheduledAt is interpreted:
| You send | Interpreted as |
|---|---|
"2026-09-07T09:00:00" (no offset) |
09:00 wall-clock in timezone, converted to UTC, daylight saving included. |
"2026-09-07T08:00:00Z" |
That exact instant. timezone is then only used for display. |
"2026-09-07T09:00:00+01:00" |
That exact instant. |
null or omitted |
Publish immediately — queued at once, picked up on the worker's next tick (15s by default). |
If timezone is omitted it defaults to the account's timezone (see GET /me → user.timezone).
Everything is stored in UTC. Every timestamp in a response is UTC ISO 8601 with a Z suffix. The post also echoes back the timezone you composed in, so you can render the user's original intent.
The practical rule for an agent: to schedule "Monday at 9am for this user", send the naive local string and the user's zone. Do not do the offset maths yourself.
5. Post lifecycle
A post has one status, rolled up from one target per selected channel.
draft ──publish──► scheduled ──worker claims──► publishing ──► published
▲ │
│ ├──► failed (attempts exhausted,
retry │ or a permanent error)
│ │
cancelled ◄────cancel────────────┘
| Status | Meaning |
|---|---|
draft |
Saved, not queued. Nothing will go out. |
scheduled |
Queued. The worker will claim it at scheduledAt. |
publishing |
A worker holds it right now. |
published |
Every live target succeeded. |
failed |
At least one target failed permanently. targets[].error says why. |
cancelled |
Cancelled before publishing. |
The post's status is derived from its targets, so a post is never reported published while one channel is still failing. Per-target detail — status, attempts, error, errorCode, remoteId, remoteUrl — is in targets[] on every post response.
Retries. A retryable provider failure (5xx, timeout, rate limit) is retried with exponential backoff: 30s · 2^(attempt-1) with ±20% jitter, up to WORKER_MAX_ATTEMPTS (4) attempts. A permanent failure (bad credentials, text too long, unsupported media) is not retried at all — retrying could not help.
Editing. Only draft, scheduled and failed posts can be edited or cancelled. Editing re-queues every target that has not already published; targets that already went out are left alone, so an edit can never double-post.
6. Endpoints
GET /info
The only unauthenticated endpoint. Brand identity, API metadata, providers.
curl -s https://postering-mauve.vercel.app/api/v1/info
{
"data": {
"brand": { "id": "postering", "name": "Postering", "tagline": "...", "features": { "...": true } },
"api": {
"version": "v1",
"baseUrl": "https://postering-mauve.vercel.app/api/v1",
"docsUrl": "https://postering-mauve.vercel.app/docs/api",
"authentication": "Authorization: Bearer pk_live_...",
"rateLimitPerMinute": 120
},
"providers": [{ "id": "bluesky", "displayName": "Bluesky", "capabilities": { "...": true } }]
}
}
GET /me
The authenticated account, the key being used, brand info, and post counts by status. Use it to verify a key works.
curl -s $BASE/api/v1/me -H "Authorization: Bearer $KEY"
{
"data": {
"user": { "id": "usr_...", "email": "demo@postering.test", "name": "Demo User", "timezone": "Europe/Lisbon", "createdAt": "2026-08-31T10:00:00.000Z" },
"apiKey": { "id": "key_...", "name": "Scheduling bot", "prefix": "pk_live_a1b2c3d4" },
"brand": { "id": "postering", "name": "Postering", "...": "..." },
"postCounts": { "draft": 1, "scheduled": 2, "publishing": 0, "published": 5, "failed": 0, "cancelled": 0 }
}
}
GET /providers
Every provider this instance supports, with its capabilities and a link to its connection runbook. Call this before composing so you know the limits rather than guessing — and before connecting, to find the setup guide.
No authentication required. The response is entirely product facts — channel names, limits, the fields the connect form asks for, and documentation links. Nothing account-specific appears in it. It is public precisely so an automated client can discover how to connect a channel before its user has an account here.
curl -s $BASE/api/v1/providers
{
"data": [
{
"id": "bluesky",
"displayName": "Bluesky",
"description": "Post to Bluesky via the AT Protocol...",
"capabilities": {
"text": true,
"images": 4,
"video": false,
"maxTextLength": 300,
"imageMimeTypes": ["image/jpeg", "image/png", "image/webp"],
"videoMimeTypes": [],
"maxAssetBytes": 1000000
},
"credentialFields": [{ "name": "identifier", "label": "Handle or email", "type": "text", "required": true, "help": "..." }],
"setupInstructions": ["Sign in to Bluesky in a browser...", "..."],
"setupGuideUrl": "https://example.com/docs/connect/bluesky"
}
],
"meta": { "total": 3 }
}
GET /accounts
The connected social accounts you can target.
curl -s $BASE/api/v1/accounts -H "Authorization: Bearer $KEY"
{
"data": [
{
"id": "acct_...",
"provider": "stub",
"providerName": "Stub (testing)",
"remoteId": "stub:demo-channel",
"handle": "demo-channel",
"displayName": "demo-channel (stub)",
"avatarUrl": null,
"status": "active",
"lastError": null,
"createdAt": "2026-08-31T10:00:00.000Z"
}
],
"meta": { "total": 1 }
}
status is active or needs_reauth. A needs_reauth account will fail to publish until it is reconnected.
POST /accounts
Connect a social account without going through the dashboard.
You do not need to be told a provider's field names: GET /providers returns credentialFields for each, and those name values are exactly the keys of credentials here.
# 1. discover the fields (no key needed for this one)
curl -s $BASE/api/v1/providers | \
jq '.data[] | select(.id=="tiktok") | .credentialFields'
# 2. construct the call from them
curl -s -X POST $BASE/api/v1/accounts \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{
"provider": "tiktok",
"credentials": {
"authorizationCode": "https://…/dashboard/accounts?code=…",
"postMode": "inbox"
}
}'
| Field | Type | Notes |
|---|---|---|
provider |
string | A provider id from GET /providers. |
credentials |
object | Keyed by the provider's own credentialFields[].name. All values are strings. |
Returns 201 with the same account shape as GET /accounts. Reconnecting an account that already exists updates its credentials rather than duplicating it.
Errors are 422 validation_error: an unknown provider, a missing required field (with a details[].field of credentials.<name>), or the provider's own rejection of the credentials — the message is the provider's, verbatim.
Some providers need instance-level configuration first. TikTok needsTIKTOK_CLIENT_KEY/TIKTOK_CLIENT_SECRETon the server; without them this call returns a message saying exactly that. Each provider'ssetupGuidePathpoints at its runbook.
Per-post channel options
Some channels need a decision before a post can go out — TikTok will not accept one without an audience. Those are declared by the provider, so a client discovers them rather than being told:
# The static schema: every option, every value, and the rules between them.
curl -s $BASE/api/v1/providers | \
jq '.data[] | select(.id=="tiktok") | .capabilities.postOptions'
{
"options": [
{
"name": "privacyLevel",
"label": "Who can see this post",
"type": "select",
"required": true,
"choices": [
{ "value": "PUBLIC_TO_EVERYONE", "label": "Everyone" },
{ "value": "SELF_ONLY", "label": "Only me (private)" }
]
},
{ "name": "allowComment", "label": "Allow comments", "type": "boolean", "required": false,
"appliesTo": ["video", "photo"] },
{ "name": "allowDuet", "label": "Allow Duet", "type": "boolean", "required": false,
"appliesTo": ["video"] }
],
"rules": [
{
"code": "tiktok_branded_content_cannot_be_private",
"when": { "option": "brandedContent", "equals": true },
"forbid": { "option": "privacyLevel", "values": ["SELF_ONLY"] },
"message": "Branded content cannot be private on TikTok. …"
}
]
}
A select marked required has no default and must not be given one — send an explicit value. appliesTo narrows an option to a post shape (video, photo, text); an option that does not apply is neither required nor accepted. showWhen nests an option under another one, and a nested option is ignored unless its parent holds the given value.
GET /accounts/:id/post-options
The static schema is the union of everything a channel could ask. What one account may actually use is narrower, changes without notice, and only the platform knows it:
curl -s $BASE/api/v1/accounts/$ACCOUNT/post-options -H "Authorization: Bearer $KEY"
{
"data": {
"accountId": "acct_…",
"provider": "tiktok",
"author": { "name": "Example Creator", "handle": "examplecreator", "avatarUrl": "…" },
"limits": { "maxVideoDurationSec": 600 },
"notices": [
{ "code": "tiktok_unaudited_app", "severity": "warning", "message": "This app has not passed…" }
],
"options": [
{
"name": "privacyLevel",
"required": true,
"choices": [
{ "value": "PUBLIC_TO_EVERYONE", "label": "Everyone",
"unavailableReason": "This app has not passed TikTok's Content Posting API audit…" },
{ "value": "SELF_ONLY", "label": "Only me (private)" }
]
},
{ "name": "allowDuet", "type": "boolean",
"unavailableReason": "The account holder has turned Duet off for their whole TikTok account." }
]
}
}
An unavailableReason — on a choice or on a whole option — means it exists but cannot be used by this account. Sending it anyway is a 422. It is reported rather than filtered out so a client can say why a value is off the table. unavailable at the top level means the platform could not be reached; do not post on a guess.
This call costs a request to the platform, so make it once per account, not per edit. A channel with no per-post options returns an empty options list.
Sending the values
options on POST /posts and PATCH /posts/:id is keyed by account id, then by option name:
curl -s -X POST $BASE/api/v1/posts \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{
"content": "Late afternoon light across the harbour.",
"assetIds": ["ast_…"],
"accountIds": ["acct_tiktok"],
"options": {
"acct_tiktok": {
"privacyLevel": "SELF_ONLY",
"allowComment": true,
"commercialContent": true,
"brandOrganic": true
}
}
}'
Validated against the declaration before anything is queued. A missing required option is a 422 whose details[].field is options.<accountId>.<optionName>. The chosen values are stored on the target and returned in targets[].metadata.options.
Two checks happen at different times, deliberately. Creating a post validates against the static schema — cheap, no network call per channel. Delivery re-checks the choice against the platform's current answer, because a privacy level withdrawn between scheduling and delivery would otherwise post to the wrong audience; that fails the target with a message naming the change.
Publish outcomes that are not the whole story
A target's status says the publish call succeeded. It does not always say the post is visible. Providers that had to restrict a post report it in targets[].metadata, and that is the field to read:
{
"id": "tgt_…",
"provider": "tiktok",
"status": "published",
"metadata": {
"visibility": "self_only",
"publiclyVisible": false,
"privacyLevel": "SELF_ONLY",
"note": "Published as SELF_ONLY — visible to the account holder and nobody else. TikTok restricts unaudited apps to private posting."
}
}
visibility is public, limited (followers or friends only), self_only or draft_only. metadata is {} for providers that publish normally and report nothing; metadata.options holds what was chosen when the post was composed.
The restriction is discoverable before you post, too: GET /providers returns capabilities.publishRestriction for any channel that has one — with a code, a visibility, a summary, a remedy and a docsPath. That endpoint needs no key, so an agent can find out what a channel will actually do before its user has an account here.
Uploading media
There are two ways to get a file in. Which one you need depends on size.
| File size | Use | Why |
|---|---|---|
| Up to ~4 MB | POST /assets (multipart, one call) |
Simplest — one request |
| Anything larger | POST /uploads → PUT → POST /uploads/complete |
Serverless request bodies are capped at a few megabytes; a video cannot be sent through this API at all |
Prefer the direct upload for anything you did not personally measure. The multipart endpoint fails on large files with a platform-level error that is not in this app's control.
POST /uploads — direct-to-storage upload (any size)
Bytes go straight from your client to object storage, never through this API. Three steps.
Step 1 — ask for a target.
curl -s -X POST $BASE/api/v1/uploads \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{ "filename": "clip.mp4", "contentType": "video/mp4", "sizeBytes": 12582912 }'
sizeBytes is optional but recommended: an over-size file is then rejected before a single byte moves.
{
"data": {
"assetId": "ast_0mthgo0pj023fba09972b9147",
"upload": {
"uploadUrl": "https://blob.vercel-storage.com/?pathname=usr_…%2Fast_….mp4",
"method": "PUT",
"headers": {
"authorization": "Bearer vercel_blob_client_…",
"x-api-version": "12",
"x-vercel-blob-access": "public",
"x-content-type": "video/mp4",
"x-add-random-suffix": "0",
"x-allow-overwrite": "0",
"content-type": "video/mp4"
},
"expiresAt": "2026-08-31T18:00:00.000Z"
},
"completeToken": "eyJ1Ijoi…",
"maxBytes": 26214400
}
}
Step 2 — send the bytes. Use uploadUrl, method and headers exactly as given. Do not add, drop or reorder headers; the token is scoped to that one destination, content type and size.
curl -s -X PUT "$UPLOAD_URL" \
-H "authorization: Bearer $BLOB_CLIENT_TOKEN" \
-H "x-api-version: 12" \
-H "x-vercel-blob-access: public" \
-H "x-content-type: video/mp4" \
-H "x-add-random-suffix: 0" \
-H "x-allow-overwrite: 0" \
-H "content-type: video/mp4" \
--data-binary @clip.mp4
No SDK is required — this is plain HTTP.
Step 3 — register the asset.
curl -s -X POST $BASE/api/v1/uploads/complete \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{ "completeToken": "eyJ1Ijoi…", "altText": "A short clip" }'
Returns the asset, exactly as POST /assets would:
{
"data": {
"id": "ast_0mthgo0pj023fba09972b9147",
"kind": "video",
"mimeType": "video/mp4",
"sizeBytes": 12582912,
"url": "https://….public.blob.vercel-storage.com/usr_…/ast_….mp4"
}
}
Completion is idempotent — calling it twice returns the same asset.
What is enforced, and where. Everything is decided when the target is minted and sealed into a signed token: the owner, the destination, the accepted content type and the size ceiling. None of it is read from your later requests.
| Situation | Response |
|---|---|
Unsupported contentType |
415 unsupported_media_type at step 1 |
sizeBytes over the limit |
413 payload_too_large at step 1 |
| Uploaded bytes exceed the ceiling | 413 at step 3; the object is deleted |
| No bytes were uploaded | 422 validation_error at step 3 |
| Token belongs to another account | 403 forbidden |
| Token older than 30 minutes | 422 validation_error — mint a new one |
GET /assets
Paginated (limit, default 50, max 200; offset).
GET /assets/:id · DELETE /assets/:id
Deleting an asset that is attached to a post returns 409 conflict — detach it from the post first.
POST /posts
Create a post. This is the main endpoint.
| Field | Type | Notes | |
|---|---|---|---|
content |
string | The post text. Defaults to "". |
|
assetIds |
string[] | Asset ids, in the order they should appear. | |
accountIds |
string[] | Which connected accounts to publish to. Required unless draft is true. |
|
scheduledAt |
string \ | null | See §4. Omit or null to publish now. |
timezone |
string | IANA zone. Defaults to the account's timezone. | |
draft |
boolean | true saves without queueing. |
A post needs text, media, or both.
curl -s -X POST $BASE/api/v1/posts \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: launch-post-2026-09-07" \
-d '{
"content": "Doors open at 9 on Monday.",
"assetIds": ["ast_..."],
"accountIds": ["acct_..."],
"scheduledAt": "2026-09-07T09:00:00",
"timezone": "Europe/Lisbon"
}'
{
"data": {
"id": "post_...",
"content": "Doors open at 9 on Monday.",
"status": "scheduled",
"scheduledAt": "2026-09-07T08:00:00.000Z",
"timezone": "Europe/Lisbon",
"publishedAt": null,
"createdAt": "2026-08-31T10:00:00.000Z",
"updatedAt": "2026-08-31T10:00:00.000Z",
"assets": [{ "id": "ast_...", "kind": "image", "url": "/api/media/...", "...": "..." }],
"targets": [
{
"id": "tgt_...",
"accountId": "acct_...",
"provider": "stub",
"providerName": "Stub (testing)",
"handle": "demo-channel",
"status": "pending",
"attempts": 0,
"runAt": "2026-09-07T08:00:00.000Z",
"error": null,
"errorCode": null,
"remoteId": null,
"remoteUrl": null,
"publishedAt": null
}
]
}
}
Returns 201. Note scheduledAt came back as 08:00Z — 09:00 Lisbon time in September is UTC+1.
Idempotency. Send an Idempotency-Key header. If a post with that key already exists for the account, the existing post is returned instead of a second one being created. Safe to retry a call whose response you never saw.
Validation happens up front. If the text is too long for one of the selected channels, or the media does not fit, you get a 422 listing each problem — before anything is queued:
{
"error": {
"code": "validation_error",
"message": "This post does not fit every selected channel.",
"details": [
{ "field": "content", "message": "Bluesky (@you.bsky.social) allows 300 characters; this post has 412." },
{ "field": "assetIds", "message": "Bluesky (@you.bsky.social) does not support video." }
]
}
}
GET /posts
| Query | Notes |
|---|---|
status |
Comma-separated: draft,scheduled,publishing,published,failed,cancelled. |
from / to |
ISO 8601 bounds on scheduledAt. |
limit |
Default 25, max 100. |
offset |
Default 0. |
order |
asc or desc (default) by scheduled time. |
curl -s "$BASE/api/v1/posts?status=scheduled,failed&limit=10&order=asc" \
-H "Authorization: Bearer $KEY"
Returns data (array of posts, same shape as above) and meta: { total, limit, offset }.
GET /posts/:id
One post, including per-target status, remoteId, remoteUrl and error. This is how you check whether something actually published.
PATCH /posts/:id
Edit a draft, scheduled or failed post. Every field is optional; omitted fields are left unchanged. Editing a published, publishing or cancelled post returns 409 conflict.
# Move it two hours later and change the text
curl -s -X PATCH $BASE/api/v1/posts/post_... \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{ "content": "Doors open at 11 on Monday.", "scheduledAt": "2026-09-07T11:00:00", "timezone": "Europe/Lisbon" }'
Passing assetIds or accountIds replaces the whole list. Pass "publish": true to move a draft into the queue.
POST /posts/:id/cancel
Cancels every target that has not published yet. A target already in flight is left to finish, and the post's status reflects the real outcome. Cancelling an already-published post returns 409 conflict.
curl -s -X POST $BASE/api/v1/posts/post_.../cancel -H "Authorization: Bearer $KEY"
POST /posts/:id/retry
Requeues every failed or cancelled target immediately, resetting its attempt counter and clearing the stored error. Returns 409 conflict if there is nothing to retry.
curl -s -X POST $BASE/api/v1/posts/post_.../retry -H "Authorization: Bearer $KEY"
Managing posts on the platform
Once a post is published it lives on the platform, and what you may do to it there is the platform's decision, not ours. Ask first:
curl -s $BASE/api/v1/providers | \
python3 -c "import json,sys; [print(p['id'], p['capabilities']['remote']) for p in json.load(sys.stdin)['data']]"
bluesky {'list': True, 'delete': True, 'edit': False, 'notes': {'edit': 'Bluesky does not support editing…'}}
instagram {'list': True, 'delete': True, 'edit': False, 'notes': {...}}
stub {'list': True, 'delete': True, 'edit': True}
Check the declaration before acting. Calling an unsupported operation returns 409 conflict with the platform's reason — it is not something to discover by trying.
GET /accounts/:id/posts
The account's posts as they exist on the platform, including ones not published through here (managed: false). Those are read-only from our side; we do not adopt them as if we had sent them.
curl -s "$BASE/api/v1/accounts/$ACCOUNT/posts?limit=25" -H "Authorization: Bearer $KEY"
{
"data": [
{
"remoteId": "at://did:plc:…/app.bsky.feed.post/3mu…",
"remoteUrl": "https://bsky.app/profile/…/post/3mu…",
"text": "Blue skies today.",
"publishedAt": "2026-08-31T12:30:23.527Z",
"mediaUrls": [],
"accountId": "acct_…",
"provider": "bluesky",
"handle": "example.bsky.social",
"managed": true,
"postId": "post_…"
}
],
"meta": { "total": 1, "cursor": "…", "capabilities": { "list": true, "delete": true, "edit": false } }
}
DELETE /accounts/:id/posts/:remoteId
Deletes the post from the platform. This destroys the user's own content on a service we do not control, and cannot be undone — the platform will not give it back. It is always an explicit call and never a side effect of anything else.
URL-encode remoteId (an at:// URI contains slashes).
curl -s -X DELETE "$BASE/api/v1/accounts/$ACCOUNT/posts/$(printf %s "$REMOTE_ID" | jq -sRr @uri)" \
-H "Authorization: Bearer $KEY"
{ "data": { "remoteId": "at://…", "deleted": true, "localTargetUpdated": true } }
Our record is updated to say the post was published and later removed — remote_deleted_at is set and remote_url kept — rather than pretending it never went out.
PATCH /accounts/:id/posts/:remoteId
Edit a published post's text, where the platform allows it at all. Neither Bluesky nor Instagram does; see PROVIDERS.md for why, including why Bluesky's putRecord looks like an edit and is not.
curl -s -X PATCH "$BASE/api/v1/accounts/$ACCOUNT/posts/$ENCODED_ID" \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"content":"Updated text."}'
DELETE /posts/:id
Permanently deletes the post and its targets. Assets are not deleted.
{ "data": { "id": "post_...", "deleted": true } }
7. Worked example: "post this on Monday at 9am"
The user is in Europe/Lisbon. It is Monday 31 August 2026; they mean Monday 7 September.
BASE=https://postering-mauve.vercel.app
KEY=pk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# 1. Confirm the key works and learn the user's timezone.
curl -s $BASE/api/v1/me -H "Authorization: Bearer $KEY" | jq '.data.user.timezone'
# "Europe/Lisbon"
# 2. Find out what the target channel accepts, so the text fits.
curl -s $BASE/api/v1/providers -H "Authorization: Bearer $KEY" \
| jq '.data[] | {id, max: .capabilities.maxTextLength, images: .capabilities.images}'
# { "id": "bluesky", "max": 300, "images": 4 }
# 3. Pick the account to publish to.
ACCOUNT=$(curl -s $BASE/api/v1/accounts -H "Authorization: Bearer $KEY" | jq -r '.data[0].id')
# 4. Upload the image.
ASSET=$(curl -s -X POST $BASE/api/v1/assets \
-H "Authorization: Bearer $KEY" \
-F "file=@./monday.jpg" \
-F "altText=Our new opening hours" | jq -r '.data.id')
# 5. Schedule it. Naive local time + the user's zone: no offset maths needed.
POST=$(curl -s -X POST $BASE/api/v1/posts \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: monday-hours-2026-09-07" \
-d "{
\"content\": \"New opening hours from Monday: 9am to 7pm.\",
\"assetIds\": [\"$ASSET\"],
\"accountIds\": [\"$ACCOUNT\"],
\"scheduledAt\": \"2026-09-07T09:00:00\",
\"timezone\": \"Europe/Lisbon\"
}" | jq -r '.data.id')
# 6. Verify what was actually stored (UTC) and that it is queued.
curl -s $BASE/api/v1/posts/$POST -H "Authorization: Bearer $KEY" \
| jq '{status: .data.status, scheduledAt: .data.scheduledAt, tz: .data.timezone}'
# { "status": "scheduled", "scheduledAt": "2026-09-07T08:00:00.000Z", "tz": "Europe/Lisbon" }
# 7. Later: did it go out?
curl -s $BASE/api/v1/posts/$POST -H "Authorization: Bearer $KEY" \
| jq '.data.targets[] | {handle, status, remoteUrl, error}'
8. Notes for automated clients
- Discover, don't assume.
GET /providersgives you exact limits per
channel. Validate against them before composing.
- Send local time plus a zone. Do not convert to UTC yourself; §4 exists so
you don't have to reason about DST.
- Use
Idempotency-Keyon everyPOST /posts. A retried call then cannot
create a duplicate.
- Read
error.details, not justerror.message.details[].fieldtells
you which input to fix.
202-style semantics: a201fromPOST /postsmeans queued, not
published. Poll GET /posts/:id and read targets[].status to confirm.
- The worker must be running (
pnpm worker) for anything to publish. If
posts sit in scheduled past their time, that process is not up.
- Testing failure paths: with the
stubprovider, any post whose text
contains FAIL_TEST fails on purpose. Use it to exercise retry and error handling without touching a real account.