Tutorial

Safe text cross-posting: idempotency keys, status polling and ambiguous failures

Engineering Describes the 0.2.0-rc.1 candidate, which is prepared but unpublished. Threads and Bluesky have only been exercised against local mock servers, so treat live behaviour as unverified.

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
scheduledWaiting for scheduledAt.Wait; Cron scans every 15 minutes.
queuedAccepted, waiting for a publication job.Poll again shortly.
publishingAt least one platform request is running.Poll again shortly.
publishedEvery selected platform succeeded.Stop. Record the external URLs.
partialSome platforms succeeded, some failed.Inspect each publication before you retry anything.
failedEvery 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
Threads500 Unicode charactersMock-tested locally; live acceptance pending
Bluesky300 characters and 3,000 UTF-8 bytes; HTTP(S) URLs get link facetsMock-tested locally; live acceptance pending
X280 weighted characters, validated with twitter-textExperimental
TumblrOne NPF text block, up to 4,096 Unicode code pointsExperimental
LinkedIn3,000 UTF-16 units after little-text escapingExperimental

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.