Publishing is synchronous and can run for minutes on video. A network failure or client
timeout leaves you unable to tell whether the post went out. An idempotency key removes the
question.Or in the body — the body field wins if both are present:Keys are 1–255 characters and scoped per project, so two tenants can use the same string
without colliding.
What a retry does
Attempt
Status
Body
First
`201` — or `202` if you sent `publishAt`
`replayed: false`
Retry with the same key
`200`
`replayed: true`, the original post
Retrying never publishes twice. The post row carrying the key is inserted before any
platform call, and a unique index on (project, key) means a concurrent duplicate loses the
insert race and is answered with the original post instead.
A replayed response is a snapshot, not a promise
Replayed targets may not be final
A replay returns the stored per-target state at that moment. If the original request is still running — video processing, a slow carousel — some targets will still be pending. Poll GET /api/v1/posts/:id until they settle.
A scheduled post replays the same way, and its targets stay pending until its time comes
— so poll on a schedule of your own rather than in a tight loop.
Choosing a key
Derive it from the logical post, not from the attempt:
Good: release-2-4-0, campaign-42-slot-3, a UUID stored with your own draft record.
Bad: a fresh crypto.randomUUID() per attempt — that is no key at all.
Use a new key when retrying failed targets
Reusing the key returns the original post untouched. To republish targets that failed, send a fresh request with only those accountIds and a NEW idempotency key — usage is recorded per target that actually published, so retrying the third of three targets bills one write.
Rescheduling works the same way. A scheduled post is immutable, so moving one is
followed by a fresh create — with a key, since the old one
would just hand back the post you cancelled.
let { post, replayed } = await publish();
while (replayed && post.targets.some((t) => t.status !== "published" && t.status !== "failed")) {
await new Promise((r) => setTimeout(r, 5_000));
({ post } = await fetch(`${CHIRIO}/api/v1/posts/${post.id}`, { headers }).then((r) => r.json()));
}
POST /posts/:id/cancel
new
What is not idempotent — and what does not need a key
Only POST /api/v1/posts takes an idempotency key. POST /api/v1/accounts/connect mints a
new single-use OAuth state each call by design — an authorize URL that could be replayed would
be a security problem, not a convenience.
The repost endpoints need no key because they carry one implicitly. A repost of a published
target is billed once per target; a repost of any other post URL is billed once per account
and post. Calling either twice reaches the platform twice — where a repeat retweet is a no-op
— and bills once. See Metering.