Idempotency

Network calls fail in ambiguous ways: a request times out, a connection drops, a retry fires — and you can’t tell whether the original one succeeded. For a publishing API that is dangerous, because a naive retry of POST /posts could publish the same content twice. Idempotency makes POST /posts safe to retry: a replayed request returns the original post instead of creating a duplicate.

You opt in by attaching a stable key to the request. Send it either as the Idempotency-Key request header or as the external_ref field in the body — they are equivalent, and the body field takes precedence if you supply both.

The Idempotency-Key header

Generate a unique key per logical operation and send it with the request. If you ever resend that same request — because of a timeout, a crash-recovery, an at-least-once job runner, or a manual retry — reuse the exact same key.

POST /posts
curl https://api.postfuze.com/api/v1/posts \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 6f1d9c2e-1b7a-4f3e-9a2c-0d5e8b7c6a40" \
  -d '{
    "content": "Launch day is here 🚀",
    "accounts": ["a1b2c3d4-…", "b7c8d9e0-…"]
  }'

The first time the key is seen, the post is created and returned with 201 Created. Every subsequent request carrying the same key returns the same post — same id, same targets — with 200 OK instead of 201. The status code is how you tell “newly created” from “replayed.”

RequestResponseMeaning
First with key K201 CreatedThe post was created.
Repeat with key K200 OKReplay — the original post is returned, no duplicate created.

Using external_ref instead

If it’s more convenient to carry the key in your data model, set it in the body as external_ref (max 255 characters). This is handy when the natural key is something you already store — a row id, a content hash, a campaign slug.

POST /posts
curl https://api.postfuze.com/api/v1/posts \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "Launch day is here 🚀",
    "accounts": ["a1b2c3d4-…"],
    "external_ref": "campaign-launch-2026-06-09"
  }'

The resolved key is echoed back on the post as external_ref, so you can correlate the response with your own records. If both the header and the body field are present, external_ref wins.

Which endpoints honor it

Idempotency keys apply to the non-idempotent writes where a blind retry could create a duplicate. Reads (GET) are naturally idempotent and need no key; DELETE is idempotent in effect. The endpoints that honor an Idempotency-Key today:

EndpointKey sourceReplay behavior
POST /postsIdempotency-Key header or external_ref body fieldReturns the original post201 on first create, 200 on replay (see above). Enforced by a per-org uniqueness constraint on the post row.
POST /posts/{id}/repliesIdempotency-Key headerReplays the captured first response (status + body) verbatim, so a retried reply is posted at most once per target.
POST /webhooksIdempotency-Key headerReplays the original endpoint’s response — including the one-time signing_secret — instead of creating a duplicate endpoint and minting a new secret.
POST /social-accounts/{id}/importsIdempotency-Key headerReplays the original import job’s response, so a retried backfill doesn’t kick off a second job.

Two mechanisms, one header

POST /posts dedupes on a persisted key (the post’s external_ref), so its protection lasts the lifetime of the post and is reflected in the 200-vs-201 status. The replies, webhooks, and imports endpoints use a response cache keyed by (org, route, key) with a 24-hour TTL: the first response is stored and served back byte-for-byte on a retry within that window. Either way, sending the same key with a different body still returns the original result — the new body is ignored, not merged.

How replay protection works

The key is stored on the post and enforced by a database uniqueness constraint scoped to your organization: unique (org_id, external_ref). When a create request arrives with a key, we first look for an existing post with that key in your org. If one exists, we short-circuit and return it; otherwise we create the new post under the same transaction that writes the key, so the dedupe and the insert can never disagree under concurrency.

Scope is per organization

Idempotency keys are unique within your organization, not globally. Two different orgs can independently use the key launch-2026 without colliding. A null key (no header, no external_ref) opts out of dedupe entirely — every such request creates a brand-new post.

Why a retry can never double-publish

Idempotent replay protects the create call. A second, deeper guarantee protects the publish itself. When a post is created, PostFuze mints a stable publish_run_id and derives a per-target idempotency key from it — sha256(target_id + ":" + publish_run_id) — which is unique per target. Publishing is at-least-once (the same target may be attempted more than once), but before it creates anything on a platform it checks whether that target already has a platform_post_id; if it does, it finalizes without re-creating. The effect is exactly-once delivery per target.

The practical upshot: even if your Idempotency-Key somehow differed between two attempts and you did create two posts, each post still has its own run id and would publish once. And when you reuse the same key correctly, the second attempt never publishes again — it returns the original post. Either way, the same content is never posted twice to the same account.

Generating keys

  • One key per logical operation. A key identifies “this specific post,” not “this HTTP attempt.” Mint it once, before the first attempt, and reuse it across every retry of that operation.
  • Prefer a UUID (v4). A random UUID is collision-free and leaks nothing. Generate it when you decide to publish, persist it alongside your record, and send it on every attempt.
  • Or use a deterministic natural key via external_ref — e.g. order-4821-thankyou or a content hash — when your system already has a stable identifier for the operation. Make sure it is genuinely unique per intended post.
  • Do not reuse a key for different content. A key maps to exactly one post. If you send the same key with a different body, you get the original post back — your new body is ignored, not merged.
idempotent-create.ts
import { randomUUID } from 'node:crypto';

type CreatePost = {
  content?: string;
  accounts: string[];
  scheduled_at?: string;
};

async function createPost(body: CreatePost, idempotencyKey: string) {
  const res = await fetch('https://api.postfuze.com/api/v1/posts', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.POSTFUZE_API_KEY}`,
      'Content-Type': 'application/json',
      // Reuse this exact key on every retry of this one logical post.
      'Idempotency-Key': idempotencyKey,
    },
    body: JSON.stringify(body),
  });

  const post = await res.json();
  // 201 = freshly created, 200 = idempotent replay of an earlier attempt.
  return { post, replayed: res.status === 200 };
}

// Mint the key ONCE, persist it, then retry with the same key on failure.
const key = randomUUID();
const body: CreatePost = { content: 'Launch day is here 🚀', accounts: ['a1b2c3d4-…'] };

let result;
for (let attempt = 0; attempt < 3; attempt++) {
  try {
    result = await createPost(body, key); // same key every time
    break;
  } catch (err) {
    if (attempt === 2) throw err;
    await new Promise((r) => setTimeout(r, 2 ** attempt * 1000));
  }
}

Scope, retention, and lifetime

The key is persisted for the lifetime of the post (it lives on the post row), so replay protection does not silently expire after a few hours the way a short-lived cache would. There is no separate TTL to track. A few consequences to keep in mind:

  • A key is consumed by the first successful create. From then on it always returns that post.
  • Deleting a post is a soft-delete; the key remains associated with the (now canceled) post, so reusing it still returns that post rather than creating a new one. Use a fresh key to publish new content.
  • Idempotency applies to the write endpoints listed under Which endpoints honor it above. Reads (GET) are naturally idempotent and need no key; DELETE is idempotent in effect (a second delete returns 404 once the post is gone).

Make every publish path retry-safe

Wire an idempotency key into your create flow from day one — generate a UUID per post, store it next to your own record, and send it on the first attempt and every retry. It costs nothing on the happy path and turns ambiguous network failures into safe, repeatable operations. See Errors for which statuses are safe to retry.