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
person —
people: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.
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.