Skip to content
← BlogDeveloper

Idempotency-Key: stopping a flaky retry from double-firing

What the Idempotency-Key header does on Norbelys's API, why it matters for state-changing POSTs, and what happens to a retried request without one.

By Gabriel Lara, Developer Relations, Norbelys

Founder-reviewed ·How we research and correct articles

A network request can fail in two very different ways: the server never got it, or the server got it, did the work, and the response never made it back to you. From the caller’s side those look identical — a timeout, a dropped connection, a 5xx from a proxy in between. The only safe response to “I don’t know if that worked” is to retry. And retrying a POST that already succeeded is exactly how a single request turns into two people getting erased, two imports running, or two campaigns getting enrolled from one bulk job. Idempotency-Key exists to make that retry safe.

The pattern, briefly

An idempotency key is a client-generated token, sent as a header, that identifies one logical attempt at an operation rather than one HTTP request. The server remembers which keys it’s already processed and, when it sees a repeat, returns the original result instead of doing the work again. It’s a standard pattern — Stripe popularized it for payments, where a double-charge is the textbook failure mode — and it applies to the same failure mode anywhere a POST has a side effect that shouldn’t happen twice: sending, enqueuing, or erasing something.

On Norbelys’s API, the rule is uniform across all five endpoint patterns: any POST that sends or enqueues work accepts an Idempotency-Key header, and passing one makes a retried request safe. It’s also explicitly allowed through CORS alongside Authorization and Content-Type, so browser-based clients built against the REST API can set it without a preflight failure.

Before: a retry without a key

Say your integration calls the GDPR hard-erase action on a personpeople:erase in the API’s action vocabulary, the one true delete that actually removes PII rather than soft-archiving the row. Your request goes out, the connection drops before the response arrives, and your retry logic — reasonably — fires again.

curl https://api.norbelys.com/v1/people/con_8h3k:erase \
  -X POST \
  -H "Authorization: Bearer ak_live_..."
# connection reset — no response, unclear whether it landed

curl https://api.norbelys.com/v1/people/con_8h3k:erase \
  -X POST \
  -H "Authorization: Bearer ak_live_..."
# fired again, because the first outcome was unknown

Without a key, both calls are indistinguishable requests to erase the same record. The second one either fails with a confusing “already erased” error your code now has to special-case, or — worse, in an operation with any side effects beyond the row itself, like an audit log entry or a downstream webhook — it produces a duplicate consequence for a single logical action. Either way, your retry logic just turned a transient network blip into a data-integrity question.

After: the same retry, made safe

Attach an Idempotency-Key to the original attempt, and reuse the exact same key when you retry. The server recognizes the second call as a repeat of the first and returns the original result without erasing anything twice.

retry with an idempotency key
$ curl https://api.norbelys.com/v1/people/con_8h3k:erase \$ -X POST \$ -H "Authorization: Bearer ak_live_..." \$ -H "Idempotency-Key: erase-con_8h3k-2026-07-15"→ connection reset — no response received$ curl https://api.norbelys.com/v1/people/con_8h3k:erase \$ -X POST \$ -H "Authorization: Bearer ak_live_..." \$ -H "Idempotency-Key: erase-con_8h3k-2026-07-15"{ "id": "con_8h3k", "status": "erased" }

Same operation, same outcome, but now the retry is provably safe instead of merely probably safe. The key is what turns “I hope that didn’t fire twice” into “I know it didn’t,” which is the entire value of the header — it moves the safety guarantee from your retry logic’s assumptions into the server’s actual behavior.

Why “just check if it already exists first” doesn’t fix this

A natural instinct is to sidestep the whole problem client-side: before retrying, query the API to see whether the erase or the import already went through, and only retry if it didn’t. That works sometimes, but it doesn’t actually solve the problem — it just moves the race condition one step over. The check-then-act sequence is itself two requests, and the same network that dropped your original call can drop the connection between your check and your retry, or two concurrent workers can both check, both see “not done yet,” and both fire the retry at the same moment. A read-then-write pattern can narrow the window a duplicate slips through; it can’t close it. An idempotency key closes it, because the deduplication happens on the server, atomically, against the one thing that’s actually unique per attempt — the key itself — rather than against a state check that’s already stale by the time your retry lands.

This is also why the header belongs on the request that has the side effect, not on a follow-up confirmation call. Norbelys’s rule — any POST that sends or enqueues accepts the header — puts the safety mechanism exactly where the risk is, instead of asking your integration to build its own out-of-band reconciliation logic to catch what the server could have prevented directly.

Where this matters most: bulk work

The same problem gets bigger, not smaller, on the job pattern — a CSV import or a mass campaign enrollment kicked off through POST /v1/jobs. A dropped connection on a job-creation call is exactly the kind of thing a retry loop fires on reflexively, and without a key, a retried contact_import doesn’t fail loudly — it silently starts a second import job against the same file, and now you have duplicate people rows to clean up instead of an obvious error.

curl https://api.norbelys.com/v1/jobs \
  -X POST \
  -H "Authorization: Bearer ak_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: import-oct-list-01" \
  -d '{"type":"contact_import","sourceR2Key":"imports/org_4k2/list.csv"}'

If that call times out and your code retries it with the same Idempotency-Key: import-oct-list-01, you get back the original job’s current status — processing, 4000 total, however far it’s gotten — instead of a second job racing the first one against the same 4,000 rows.

What to actually pick as a key

The key just has to be unique per logical attempt and stable across retries of that same attempt — a UUID generated once before the first try and reused for every retry works, and so does a deterministic value built from the operation itself, like import-{orgId}-{fileHash} or erase-{personId}-{date} for operations where “the same input twice” should genuinely collapse to one attempt. What matters is that your retry logic reuses the key rather than generating a fresh one per attempt — a new key on every retry defeats the entire mechanism, because the server has no way to recognize a repeat if every “repeat” arrives looking like a brand-new request.

The one-line rule

If a POST sends something, enqueues something, or deletes something for real — as opposed to reading data back — generate an Idempotency-Key once per logical attempt and send it on every retry of that attempt. It costs one header. What it buys is the difference between a network hiccup and a duplicate side effect, which is exactly the gap that turns a robust integration into a fragile one under real-world conditions — the same conditions any client generated from the OpenAPI spec will eventually hit in production, key or no key.

Frequently asked questions

Do I need an Idempotency-Key on every API call?

No, only on state-changing POSTs that send, enqueue, or delete something for real — resource creation, actions, and jobs. Plain GET reads don't have a side effect to protect against, so there's nothing for the key to deduplicate.

What if I send two different requests with the same Idempotency-Key by mistake?

Treat the key as tied to one specific request body and operation, not just reused freely. Generate a fresh key per genuinely new logical attempt — a UUID per operation, or a value deterministic on the operation's real inputs — and only reuse it across retries of that exact same attempt.

Does the Idempotency-Key header work through the SDKs and CLI too?

Yes. It's a standard HTTP header on the public REST surface, so any client built against the API — the official SDKs, a generated client, the CLI, or a raw curl call — can set it the same way.