Appearance
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
| Status | Meaning |
|---|---|
| 201 | The campaign was created by this request |
| 200 | This 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
statusandprogressmay have advanced pastpending. importis 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
| Approach | Verdict |
|---|---|
crypto.randomUUID() generated once per job and persisted | Best — survives restarts |
| A deterministic hash of your own send record's id | Good — naturally stable |
crypto.randomUUID() generated inside the retry loop | Broken — a new key per attempt defeats the purpose |
| A constant, or a date string | Broken — 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:
| Endpoint | Retry behaviour |
|---|---|
POST /api/v1/lists | Creates another list each time. Harmless but untidy |
POST /api/v1/recipients | Creates a duplicate recipient — which would mail two letters if used in a campaign |
| Imports | Additive; re-importing the same file doubles the rows |
POST /api/v1/qr-codes | Second call is 409, so effectively idempotent |
All GETs | Naturally 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
- Polling & failures — what to do about
failed - Production checklist