API reference
Publish and schedule posts to Bluesky and Mastodon from your own code: one request, every account, each in its language.
OpenAPI schema: /v1/openapi.json
Getting started
Create an API key in your dashboard, under API keys, and send it as a bearer token. All requests and responses are JSON, except image uploads.
curl https://postcove.net/v1/accounts \
-H "Authorization: Bearer pc_..."
Publish to two accounts now, with a different text for the second:
curl https://postcove.net/v1/posts \
-H "Authorization: Bearer pc_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1c2a9e" \
-d '{"text": "Olá!", "account_ids": [1, 2], "overrides": {"2": "Hello!"}}'
Schedule it instead with "scheduled_at": "2026-10-09T09:30", "timezone": "Europe/Lisbon". With images, upload them first and pass their ids:
curl https://postcove.net/v1/media \
-H "Authorization: Bearer pc_..." \
-F [email protected] -F alt_text="The harbour at dawn"
# → {"id": 7, ...}, then "media_ids": [7] in the post
Errors and limits
Errors have a stable code and a readable message:
{"error": {"code": "invalid_post", "message": "Not valid for some accounts.",
"accounts": {"2": "Bluesky allows 300 characters; this has 342."}}}
Each key may make 120 requests a minute; past that, requests get 429 rate_limited. Text is checked against every account's limits when you create the post, so a post that is accepted fits everywhere.
Exactly once
Send an Idempotency-Key header with POST /v1/posts: retrying a request with the same key returns the first post instead of creating a second one.
Publishing is exactly-once too. If a platform times out or our server restarts mid-publish, the retry finds the post already there instead of publishing it twice. Temporary errors are retried for about an hour; a post the platform rejects fails at once, with the platform's reason in error.
Languages
Every account posts in one language, so its posts are tagged correctly on the platform. A workspace has a list of languages (GET /v1/workspace); the first is the default for new accounts. To post in several languages, connect one account per language and send each one its text in overrides.
Connecting accounts
Let your users connect their own accounts without you ever handling a password: create a connect link and send them to it.
curl https://postcove.net/v1/connect-links \
-H "Authorization: Bearer pc_..." -H "Content-Type: application/json" \
-d '{"platform": "mastodon", "redirect_url": "https://example.com/connected"}'
# → {"url": "https://postcove.net/connect/…", "expires_at": "…"}
They sign in on the platform, and come back to redirect_url?status=connected&account_id=12 (or status=cancelled). A link works once, for an hour.
Webhooks
Add an https URL with POST /v1/webhooks (or in the dashboard) to hear about post.published and post.failed, one per account, and account.needs_reconnect. Each event is a POST:
{"id": "evt_3b1f…", "type": "post.published", "created_at": "2026-10-09T08:30:02+00:00",
"data": {"post_id": 41,
"variant": {"id": 88, "account_id": 2, "status": "published",
"url": "https://bsky.app/profile/…/post/…", "error": null},
"account": {"id": 2, "platform": "bluesky", "handle": "ana.bsky.social"}}}
Answer with any 2xx within 10 seconds. Otherwise we retry after 1 and 5 minutes, then 30 minutes, 2 and 6 hours. The same event can arrive twice: use its id to ignore repeats.
Verifying the signature
Every request carries Postcove-Signature: t=<unix time>,v1=<hex>: the HMAC-SHA256 of <t>.<raw body> with your webhook's secret (whsec_…, shown once when you add it). Check it, and reject old timestamps:
import hashlib, hmac, time
def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
parts = dict(item.split("=", 1) for item in header.split(","))
signed = f"{parts['t']}.".encode() + body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
fresh = abs(time.time() - int(parts["t"])) <= tolerance
return fresh and hmac.compare_digest(expected, parts["v1"])
Reference
Get the workspace
GET /v1/workspace
Its languages, in order: the first is the default for new accounts.
Responses: 200
Set the workspace's languages
PATCH /v1/workspace
Replace the list of languages; the first becomes the default.
| Field | Type | Description |
|---|---|---|
| languages * | array of string | Language tags; the first is the default |
Responses: 200
List platforms and their limits
GET /v1/platforms
What each platform accepts: characters (Mastodon servers can allow more: see each account's max_chars), images, and how an account is connected.
Responses: 200
Connect a Bluesky account
POST /v1/accounts/bluesky
Connect with the account's handle and an app password (created in Bluesky under Settings → Privacy and security → App passwords). To let your users connect their own accounts without handling passwords, use a connect link instead.
| Field | Type | Description |
|---|---|---|
| handle * | string | e.g. ana.bsky.social |
| app_password * | string | An app password, not the account's |
| language | string | What the account posts in Default:en-US |
Responses: 201
List connected accounts
GET /v1/accounts
Every connected account, with its language and status (ok or needs_reconnect).
Responses: 200
Change an account's language
PATCH /v1/accounts/{account_id}
The language this account's posts are published in.
| Field | Type | Description |
|---|---|---|
| account_id * | integer (path) | |
| language * | string | A language tag, e.g. pt-PT |
Responses: 200
Disconnect an account
DELETE /v1/accounts/{account_id}
Deletes the account's credentials now and cancels what is still scheduled for it. The row stays (without secrets) for the post history.
| Field | Type | Description |
|---|---|---|
| account_id * | integer (path) |
Responses: 204
Create a connect link
POST /v1/connect-links
A one-time page on postcove.net where a person connects an account (Bluesky or Mastodon) to this workspace. Send them to url; it works once, for an hour. Afterwards they go to redirect_url with ?status=connected&account_id=… (or status=cancelled).
| Field | Type | Description |
|---|---|---|
| platform | string | bluesky or mastodon; omit to let the person choose |
| language | string | What the account will post in; default: the workspace's default |
| redirect_url | string | Where to send the person afterwards: https (or http on localhost) |
Responses: 201
Create a post
POST /v1/posts
One post for one or more accounts, now or at scheduled_at. Each account gets its own copy (a variant), with its text from overrides if given. Send an Idempotency-Key header to make retries safe: the same key returns the first post (200) instead of creating another.
| Field | Type | Description |
|---|---|---|
| idempotency-key | string (header) | |
| text | string | May be empty when there are images |
| account_ids | array of integer | The accounts to publish to (a draft may have none yet) |
| scheduled_at | datetime | ISO 8601. With an offset (2026-10-07T09:00:00+01:00) it's exact; without one it's read in |
| timezone | string | An IANA name, e.g. Europe/Lisbon Default:UTC |
| overrides | object | Text per account: {"<account id>": "text"}, e.g. a translation |
| media_ids | array of integer | Images from POST /v1/media, in order; every account gets them |
| draft | boolean | Keep it as a draft: saved, never published until updated with draft false Default:False |
Responses: 201
List posts
GET /v1/posts
Newest first. Pass next_cursor as cursor for the next page.
| Field | Type | Description |
|---|---|---|
| limit | integer (query) | |
| cursor | integer (query) | The |
Responses: 200
Get a post
GET /v1/posts/{post_id}
A post with each account's variant: status, attempts, error and the link to the published post.
| Field | Type | Description |
|---|---|---|
| post_id * | integer (path) |
Responses: 200
Edit a draft or scheduled post
PATCH /v1/posts/{post_id}
Replaces its text, accounts, images and time, as a whole. Only before publishing starts (409 not_editable after). Set draft to false to publish a draft.
| Field | Type | Description |
|---|---|---|
| post_id * | integer (path) | |
| text | string | May be empty when there are images |
| account_ids | array of integer | The accounts to publish to (a draft may have none yet) |
| scheduled_at | datetime | ISO 8601. With an offset (2026-10-07T09:00:00+01:00) it's exact; without one it's read in |
| timezone | string | An IANA name, e.g. Europe/Lisbon Default:UTC |
| overrides | object | Text per account: {"<account id>": "text"}, e.g. a translation |
| media_ids | array of integer | Images from POST /v1/media, in order; every account gets them |
| draft | boolean | Keep it as a draft: saved, never published until updated with draft false Default:False |
Responses: 200
Cancel a post
DELETE /v1/posts/{post_id}
Cancels every variant still scheduled. One being published right now finishes; published ones stay published.
| Field | Type | Description |
|---|---|---|
| post_id * | integer (path) |
Responses: 200
Upload an image
POST /v1/media
Upload an image to attach to posts with media_ids. It is re-encoded: orientation applied, metadata (such as location) removed, at most 4096 pixels a side.
| Field | Type | Description |
|---|---|---|
| file * | string | A JPEG, PNG, WebP or GIF image, up to 15 MB |
| alt_text | string |
Sent as multipart/form-data.
Responses: 201
Get an image
GET /v1/media/{media_id}
| Field | Type | Description |
|---|---|---|
| media_id * | integer (path) |
Responses: 200
Delete an image
DELETE /v1/media/{media_id}
| Field | Type | Description |
|---|---|---|
| media_id * | integer (path) |
Responses: 204
Add a webhook
POST /v1/webhooks
Events: post.published, post.failed (per account) and account.needs_reconnect. The response's secret signs every request (Postcove-Signature); it is shown only here.
| Field | Type | Description |
|---|---|---|
| url * | string | An https URL |
| description | string | A name for it |
Responses: 201
List webhooks
GET /v1/webhooks
Responses: 200
Remove a webhook
DELETE /v1/webhooks/{endpoint_id}
| Field | Type | Description |
|---|---|---|
| endpoint_id * | integer (path) |
Responses: 204
List a webhook's deliveries
GET /v1/webhooks/{endpoint_id}/deliveries
The 50 most recent deliveries (kept for 30 days).
| Field | Type | Description |
|---|---|---|
| endpoint_id * | integer (path) |
Responses: 200
Send a test event
POST /v1/webhooks/{endpoint_id}/test
Queues a ping event for this endpoint (sent within a minute).
| Field | Type | Description |
|---|---|---|
| endpoint_id * | integer (path) |
Responses: 202