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.

FieldTypeDescription
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.

FieldTypeDescription
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.

FieldTypeDescription
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.

FieldTypeDescription
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).

FieldTypeDescription
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.

FieldTypeDescription
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. Omitted or past: now

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.

FieldTypeDescription
limit integer (query)
cursor integer (query)

The next_cursor of the previous page

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.

FieldTypeDescription
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.

FieldTypeDescription
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. Omitted or past: now

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.

FieldTypeDescription
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.

FieldTypeDescription
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}

FieldTypeDescription
media_id * integer (path)

Responses: 200

Delete an image

DELETE /v1/media/{media_id}

FieldTypeDescription
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.

FieldTypeDescription
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}

FieldTypeDescription
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).

FieldTypeDescription
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).

FieldTypeDescription
endpoint_id * integer (path)

Responses: 202