Skip to content

Idempotency ​

POST /api/v1/campaigns prints and mails paper. A retried request that creates a second campaign costs money twice and confuses recipients. Idempotency-Key is how you make retries safe.

Send the header on every campaign create

Without it, a timed-out request leaves you unable to tell whether the mail was queued: retrying might duplicate the batch, not retrying might send nothing. No endpoint answers "did my create succeed".

Usage ​

Generate one value per send intent — not per HTTP attempt — and reuse it across retries.

bash
KEY_VALUE=$(uuidgen)

curl -X POST "$DRSE_BASE_URL/api/v1/campaigns" \
  -H "x-api-key: $DRSE_API_KEY" \
  -H "Idempotency-Key: $KEY_VALUE" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Summer promo", "templateId": "...", "listId": "..." }'
ts
async function createCampaign(payload: unknown, idempotencyKey: string) {
  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      const res = await fetch(`${BASE}/api/v1/campaigns`, {
        method: "POST",
        headers: {
          "x-api-key": KEY,
          // Same key on every attempt — that is what makes the retry safe.
          "Idempotency-Key": idempotencyKey,
          "Content-Type": "application/json",
        },
        body: JSON.stringify(payload),
      });

      const body = await res.json();
      if (res.status === 201) return { campaign: body.data, created: true };
      if (res.status === 200) return { campaign: body.data, created: false };
      // 4xx other than 429 is a real error — do not retry.
      if (res.status !== 429 && res.status < 500) {
        throw new Error(`${body.error.code}: ${body.error.message}`);
      }
    } catch (err) {
      if (attempt === 2) throw err;
    }
    await new Promise((r) => setTimeout(r, 2 ** attempt * 1000));
  }
  throw new Error("unreachable");
}

// One key per intent, persisted alongside the job so a process restart reuses it.
await createCampaign(payload, crypto.randomUUID());

What replay returns ​

StatusMeaning
201The campaign was created by this request
200This key was used before — here is the original campaign

The 200 body has the same fields as the 201, with two differences to handle:

  • The campaign is in its current state, so status and progress may have advanced past pending.
  • import is absent. It describes work the original request performed, so a replay omits it.
json
{
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "name": "Summer promo",
    "status": "processing",
    "progress": 40,
    "listId": "550e8400-e29b-41d4-a716-446655440001",
    "listName": "Summer list",
    "templateId": "018f2c7a-0000-7000-8000-000000000000",
    "publishedAt": "2026-07-25T10:04:12.000Z",
    "createdAt": "2026-07-25T10:00:00.000Z",
    "updatedAt": "2026-07-25T10:06:00.000Z",
    "qrCode": null
  }
}

Branch on the status code, not on the presence of import:

ts
const created = res.status === 201;

Rules ​

Format: 1–255 characters; a UUID is a good default. Outside that range is a 400 Idempotency-Key must be between 1 and 255 characters.

Scope: per account — two accounts may use the same value without colliding.

Lifetime: stored on the campaign, never expiring. A value reused months later still replays the original campaign.

The payload is not compared

Reusing a key with a different body returns the first campaign and silently ignores the new payload — the key identifies the intent, it is not a checksum.

So never reuse a key across logically different sends, and never use a value like "daily-send" — the second day would replay day one and mail nothing.

Concurrency: if two requests with the same key race, one wins and the other replays the winner. Neither produces a duplicate or a partially created campaign.

Choosing a key ​

ApproachVerdict
crypto.randomUUID() generated once per job and persistedBest — survives restarts
A deterministic hash of your own send record's idGood — naturally stable
crypto.randomUUID() generated inside the retry loopBroken — a new key per attempt defeats the purpose
A constant, or a date stringBroken — replays forever, later sends never happen

Persistence matters most: if the worker crashes after sending the request but before recording the response, the retry must present the same key — so the key must be written down before the request is made.

ts
// 1. Reserve the intent, with its key, in your own store.
const job = await db.sends.create({ payload, idempotencyKey: crypto.randomUUID() });

// 2. Only then call the API. A crash anywhere after step 1 retries safely.
const result = await createCampaign(job.payload, job.idempotencyKey);

Other endpoints ​

Only campaign create supports the header. Everywhere else:

EndpointRetry behaviour
POST /api/v1/listsCreates another list each time. Harmless but untidy
POST /api/v1/recipientsCreates a duplicate recipient — which would mail two letters if used in a campaign
ImportsAdditive; re-importing the same file doubles the rows
POST /api/v1/qr-codesSecond call is 409, so effectively idempotent
All GETsNaturally safe

Duplicate recipients are the one to watch: nothing dedupes addresses, and two identical rows in a list mean two letters. When retrying an import you cannot confirm, re-read the list count first.

Next ​