A cross-posting call is a distributed write with an unreliable network in the middle. The moment your client times out, you no longer know whether the post exists. Retrying blindly duplicates it; not retrying loses it. This walkthrough builds a small client that never has to guess: it sends one logical post under a stable key, then reads the recorded state back until the post reaches a terminal status.
Accepted is not published
POST /v1/posts returns 202 as soon as the request is validated and stored. The
response body gives you an identifier and the first recorded status:
HTTP/1.1 202 Accepted
{
"id": "post_01J9ZK4W2Q",
"status": "queued"
}
queued means accepted for processing, not yet confirmed by any platform. The Worker writes one
publication row per selected platform, then dispatches publication jobs through Cloudflare Queues. If you
send scheduledAt, the status is scheduled instead and the response echoes the
time. That request is not queued until a Cron scan finds it due.
Choose an idempotency key you can reproduce
Idempotency-Key is optional, but it is the whole reason a retry is safe. The key must identify
one logical post across every attempt on every machine. Two rules follow from that:
- Never generate a new key per attempt. A fresh key on retry asks the API to create a second post.
- Generate the key once, persist it with the payload, and reuse it for every retry of that post. A key derived from a timestamp or random value is fine if it is stored and reused.
- Derive the key from something stable that already identifies the content: a release tag, a job run id, or a hash of the final text.
A release note keyed by its version stays reproducible months later:
Idempotency-Key: release-0.2.0-rc.1
Replaying the same key with the same body returns the original post with replayed: true. The
same key with a different body is rejected with HTTP 409, because accepting it would silently
change what a caller already believes was published. A 409 is a bug in your key derivation,
not a transient error: surface it rather than retrying.
Send the request
The example below publishes to Threads and Bluesky, with a shorter first line for Bluesky. Every adapter in this candidate publishes text only.
export SYNDROO_URL="https://your-worker.your-subdomain.workers.dev"
export SYNDROO_API_KEY="the-same-secret-entered-during-deployment"
curl -X POST "$SYNDROO_URL/v1/posts" \
-H "Authorization: Bearer $SYNDROO_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: release-0.2.0-rc.1" \
--data '{
"content": "Syndroo 0.2.0-rc.1 is a release candidate: official Bluesky SDK, strictly timed retries, and a local Mock SNS gate.",
"platforms": ["threads", "bluesky"],
"overrides": {
"bluesky": { "content": "0.2.0-rc.1 is up for review: official @atproto/api SDK, timed retries, Mock SNS end-to-end gate." }
}
}'
The request body is limited to 64 KiB; larger bodies return 413. A body that is not
application/json returns 415. Platform-specific text lives under
overrides and is used verbatim for that platform.
Poll the recorded state
Capture the identifier from the accepted response and read the post back with
GET /v1/posts/{id}. Space out the attempts and stop when the status is terminal.
export POST_ID="post_01J9ZK4W2Q"
curl \
-H "Authorization: Bearer $SYNDROO_API_KEY" \
"$SYNDROO_URL/v1/posts/$POST_ID"
The response carries the post and one entry per platform publication. This excerpt shows one of the two entries the request above produces:
{
"id": "post_01J9ZK4W2Q",
"content": "Syndroo 0.2.0-rc.1 is a release candidate...",
"platforms": ["threads", "bluesky"],
"status": "published",
"createdAt": "2030-01-02T03:04:05.000Z",
"publications": [
{
"id": "pub_...",
"postId": "post_01J9ZK4W2Q",
"platform": "bluesky",
"provider": "bluesky-native",
"content": "0.2.0-rc.1 is up for review...",
"status": "published",
"attempts": 1,
"externalId": "bafyre...",
"externalUrl": "https://bsky.app/profile/.../post/...",
"errorAmbiguous": false,
"createdAt": "2030-01-02T03:04:05.000Z",
"publishedAt": "2030-01-02T03:04:06.000Z"
}
]
}
Post statuses tell you whether to keep waiting:
| Post status | Meaning | Action |
|---|---|---|
scheduled | Waiting for scheduledAt. | Wait; Cron scans every 15 minutes. |
queued | Accepted, waiting for a publication job. | Poll again shortly. |
publishing | At least one platform request is running. | Poll again shortly. |
published | Every selected platform succeeded. | Stop. Record the external URLs. |
partial | Some platforms succeeded, some failed. | Inspect each publication before you retry anything. |
failed | Every selected platform failed. | Read errorCode and decide. |
A polling loop should treat published, partial and failed as
terminal, and give up after a bounded number of attempts rather than looping forever. If you are scripting
this, GET /v1/posts?limit=50 lists recent posts, where limit is an optional
integer from 1 to 100.
Handle the ambiguous case explicitly
The genuinely hard failure is the one where the platform may have accepted the post and you cannot tell. A
transport timeout, an interrupted response, or a post-stage 5xx all leave the same doubt. Syndroo marks
those publications ambiguous through errorAmbiguous, and it does not resend them
automatically.
That is a deliberate trade: for retryable failures Syndroo will try again, but for an ambiguous write it
hands the decision back to you. Your client should do the same. Alerting on errorAmbiguous and
letting a human check the platform beats an automated duplicate.
Retryable failures return to pending with a stored deadline:
- at least 60 seconds after the first failure;
- at least 120 seconds after the second;
- three attempts in total, after which the publication stops.
You do not need to schedule these retries yourself. Sending the original request again with the same
Idempotency-Key replays the stored result instead of creating a second post.
Prefer scheduling over sleeping
If the post belongs at a specific future time, pass scheduledAt as an ISO date-time with an
explicit timezone, preferably UTC. A future value makes the post scheduled:
{
"content": "Nightly ingest finished: 41,208 rows reconciled, 0 quarantined records.",
"platforms": ["threads", "bluesky"],
"scheduledAt": "2030-01-02T03:04:05.000Z"
}
Cron scans every 15 minutes, so expect dispatch up to roughly 15 minutes after the requested time. A
scheduledAt in the past is handled as an immediate post, which makes backfills behave the way
you would expect.
Respect per-platform text limits before you send
Limit checking is split: some limits are enforced at request time and some surface later as a failed publication. Validating locally first is cheaper than a failed write.
| Platform | Text limit in this candidate | Status |
|---|---|---|
| Threads | 500 Unicode characters | Mock-tested locally; live acceptance pending |
| Bluesky | 300 characters and 3,000 UTF-8 bytes; HTTP(S) URLs get link facets | Mock-tested locally; live acceptance pending |
| X | 280 weighted characters, validated with twitter-text | Experimental |
| Tumblr | One NPF text block, up to 4,096 Unicode code points | Experimental |
| 3,000 UTF-16 units after little-text escaping | Experimental |
Over-limit content can still be accepted by the API and will finish as a failed publication with
INVALID_CONTENT before any network call. Nothing is truncated, so a too-long post never becomes
a misleadingly shortened one.
Ask for a platform you have not configured
Naming a platform whose adapter is not installed, or whose credentials are missing, returns HTTP
422 with the error code PLATFORM_NOT_CONFIGURED before anything is persisted. That
check happens before D1 and before the Queue, so a misconfigured deployment cannot half-publish.
{
"error": {
"code": "PLATFORM_NOT_CONFIGURED",
"message": "..."
}
}
Other responses worth handling: 400 for invalid input, 401 for a missing or wrong
Bearer token, 404 for an unknown route or post, and 503 when maintenance mode is
enabled. Maintenance rejects new posts before the body is read, so retrying with the same key and body after
the window is safe.
What this tutorial does not cover
Media, threads, replies and resharing are outside this candidate, and so are OAuth login and token issuance. You obtain credentials in each platform's own console and store them as Worker secrets. The candidate has no published SDK package and no MCP server, so the HTTP API here is the whole interface.
The honest caveat: Threads and Bluesky have been exercised against loopback mock servers in the end-to-end gate, and X, Tumblr and LinkedIn are covered by unit tests. None of the five has been validated against live accounts. Use this pattern, verify your own platforms, and do not treat the states above as proof that a given account will accept your post.