Idempotency
Send an Idempotency-Key on the five creates that honour it and a retry replays the stored response instead of doing the thing twice.
A request that times out tells you nothing. The socket closed; the message may already be on a customer's phone, the call may already be ringing in their pocket. You have two options and both are wrong: retry and risk doing it twice, or give up and risk never doing it at all.
Idempotency-Key removes the choice. You label the operation, we store the first response against that label, and every subsequent attempt with the same label gets the stored response back instead of executing again.
Where it works #
Idempotency-KeyheaderoptionalAny string you choose, up to 200 characters. Read case-insensitively, surrounding whitespace stripped, and truncated at 200 characters if you send more.
Five creates honour it. One create deliberately does not, and the action routes use a version check instead.
| Endpoint | Idempotency-Key |
Replayed status |
|---|---|---|
POST /api/v1/messages |
Honoured | 201 |
POST /api/v1/calls |
Honoured | 201 |
POST /api/v1/tickets |
Honoured, and deduplicates even without it | 201 |
POST /api/v1/campaigns |
Honoured | 201 |
POST /api/v1/apps/duesly/subscribers |
Honoured | 201 |
POST /api/v1/contacts |
Ignored | – |
POST /campaigns/{id}/launch, /pause, /resume, /cancel, PATCH /tickets/{id}, POST /apps/duesly/dues/{id}/paid |
Ignored. These take expected_version in the body, and a repeat with a stale version returns 409 conflict instead of acting twice |
– |
POST /calls/{call_id}/hangup, POST /campaigns/{id}/evaluate |
Ignored. Repeating them is harmless | – |
Every GET on the API is naturally idempotent — reading twice costs you a rate-limit slot and nothing else — so the header has no meaning there and is not read.
POST /contacts is the interesting omission. It is an upsert: it matches an existing contact on email, then on phone, and updates it in place, creating one only if nothing matched. Sending it twice with the same body resolves to the same contact both times. There is no duplicate to prevent, so there is no mechanism, and adding one would imply a hazard that does not exist. Contacts covers the matching rules and the one case where a repeat does fail — identity_conflict, which no idempotency key would have saved you from.
The mechanism #
- 1
You send a key
The header travels with your
POST. We hash your workspace, the specific API key you authenticated with, the endpoint, and your key string into one record id. - 2
We atomically claim the key
Once your body has passed schema validation, the request creates a pending record before any external action. A completed record with the same body replays its stored JSON immediately. A request that finds the key still pending receives
409 idempotency_in_progress; it never reaches WhatsApp, telephony, or ticket creation. - 3
The response is committed before it is returned
The handler runs and its response is written to the idempotency record before you receive the
201. The completed response remains replayable for 24 hours.Only a success is stored. If the handler fails after the claim, the claim is released, so the same key is free again: see After an error response, retry with the same key.
How the key is scoped #
The stored record is keyed on four things together:
your workspace + the API key you used + the endpoint + your key string
Three consequences worth knowing before you design your key format.
You do not have to namespace by endpoint. Idempotency-Key: order-4821-shipped on POST /messages and the identical string on POST /calls are two independent records. Each of the five creates has its own namespace (messages, calls, tickets, campaigns, and duesly for Duesly subscribers). They cannot collide, cannot replay each other's response, and cannot 409 each other. Use whatever name describes the operation and let the scoping do the separating.
Two API keys in the same workspace do not share records. If your sender service and your reconciliation job hold different keys, they each get their own idempotency namespace, even for byte-identical requests.
Rolling a key mid-retry loses the replay
Because the API key is part of the scope, an attempt made with the old half of a rolled key and a retry made with the new half are two separate operations. The retry executes for real. If you are rolling keys and running a retry queue at the same time, drain the queue first.
The key is yours, the record id is not. We never return the record id and there is no endpoint to inspect, list, or delete idempotency records. The only way to observe one is to send the request again.
What a replay looks like #
Exactly like the original. That is not a simplification — it is the whole contract.
- The status code is the endpoint's normal success status, so
201for all five, not200and not208. - The body is the stored JSON: what you got the first time. The same
message_id, the samecall_id, the sameticket_idorcampaign_id. - There is no marker header and no field saying "this was replayed". No
Idempotent-Replay: true, nothing in the envelope.
You cannot detect a replay from the response
If your code needs to know whether this attempt was the one that actually did the work — to decide whether to write an audit row, or increment a counter — the API will not tell you. Decide from your own state instead: check your "already sent" flag before you call, and set it after. The idempotency key protects the customer from a duplicate; only your own record protects your reporting from a double count.
Reusing a key with a different body #
A stored record remembers a hash of the request body it was created from. Send the same key with a different body and you get a 409 rather than either behaviour you might have wanted:
{"error": {"code": "conflict","message": "This Idempotency-Key was already used with a different request body.","details": { "reason": "idempotency_key_reuse" },"request_id": "req_01JQRS4T8XZ0AB1CD2EF3GH4","correlation_id": "cor_01JQRS4T8XZ0AB1CD2EF3GH4"}}
We refuse rather than guess. Replaying the old response would silently drop your new request; executing the new one would break the promise the key made. Neither is safe, so the ambiguity comes back to you.
This is almost never a race. It is almost always one of two ordinary mistakes:
- The key is too coarse.
order-4821used for the "shipped" message and again for the "delivered" message. Two different messages, one key. Put the event in the key. - The body was re-serialised. Read the next section, because this one catches people who did everything else right.
The body hash is over raw bytes #
The match is sha256 of the exact bytes of your request body. Not a normalised form, not a parsed structure — the bytes.
These two bodies are the same object to every JSON parser on earth, and two different bodies to us:
{"to":"+919876543210","text":"Your order AM-4821 has shipped."}
{"text":"Your order AM-4821 has shipped.","to":"+919876543210"}
Key order changed, so the hash changed, so the retry is 409 idempotency_key_reuse. Whitespace does it too: a pretty-printed retry of a compact original is a different body.
Serialise once, resend the same string
This is the failure that survives code review. A retry loop that rebuilds the payload from a dictionary each time is at the mercy of your serialiser's key ordering — which in several languages is stable within a process and not stable across versions or across a dict that was mutated in between.
Build the JSON string before the first attempt, hold it in a variable, and pass that same string to every attempt. With Python requests that means data=body with a pre-encoded body rather than json=payload; in Node it means one JSON.stringify outside the loop.
The same rule applies to metadata. A metadata object that includes an attempt counter, a timestamp taken at call time, or a hostname produces a different body on every attempt and defeats the key entirely while looking correct.
Records live for 24 hours #
An idempotency record expires 24 hours after it is written. After that it reads as absent, so the same key executes a fresh request.
That boundary is real and it has a real failure mode: a job that fails, sits in a dead-letter queue over a weekend, and is replayed on Monday will send the message again. The key it carries is three days old and no longer matches anything.
If your queue can replay work that old — and most can — the idempotency key is not your last line of defence. Check your own "already notified" flag before you call. The key protects a retry loop measured in seconds and minutes; your database protects everything longer.
Concurrent requests and uncertain outcomes #
There is no post-response race window: the key is claimed before the side effect and the response is stored before it is returned. If two copies arrive together, one runs and the other receives 409 with details.reason: idempotency_in_progress and retry_after_seconds: 2. Retry the second one with the same key and identical body after that delay, and you get the first one's stored response once it has finished.
If the worker dies outright after an external provider accepted the action but before the result can be stored, nothing is left to release the claim, so the key stays pending for its 24-hour lifetime. We do not steal it and risk repeating a real message or call whose outcome is unknown. Reconcile from your own record, or contact support with the request id, rather than sending the operation again under a new key.
After an error response, retry with the same key #
A request that fails schema validation (a 400 with details.errors) is refused before the key is claimed. Any other error response comes after the claim, and the claim is released when the request fails: a 400 such as empty_message, a 404 template_not_found, a 412 such as channel_not_connected or allowance_exhausted, and a 500 or 502. Either way nothing is stored for that key, so the next request with it runs fresh.
So when you get an error response back:
- Nothing was stored to replay, and the key is free.
- Fix the cause, then send the operation again with the same key. The corrected body is fine: there is no earlier body to conflict with.
The 4xx refusals above are returned before anything is sent. A 5xx is different: it can come after the provider accepted the action, for example when the result could not be stored. The claim is released then too, so a retry with the same key sends again. Before you retry a message or a call after a 5xx, confirm it did not go out: check the conversation in the Inbox, wait for its message.status webhook, or look the call up with GET /calls/{call_id}.
This is different from a timeout or a dropped connection, where you got no response. Then you don't know what happened, so retry with the same key: you get the stored response if the first attempt succeeded, 409 idempotency_in_progress if it is still running, or a fresh attempt if it failed.
Tickets deduplicate without a key #
POST /tickets has a second, independent guard. Even with no Idempotency-Key header at all, the ticket id is derived from a hash of your request body, so an identical body sent twice converges on the same ticket instead of opening a second one.
That is why a tkt_ id is a hash rather than a time-ordered id like con_ or call_.
It is a useful floor, not a substitute for the header, because it defines "the same request" as "the same bytes" — and a description containing a timestamp, or a subject with the retry count in it, is not the same bytes. The header lets you declare what "the same request" means: one key per incident, per order, per reconciliation run. Tickets has the detail.
Choosing a key #
This is the part that decides whether any of the above helps you. The mechanism is simple; picking the label is where integrations go wrong.
A key names a real-world operation, not an attempt. "The shipping notification for order 4821" is an operation. "The HTTP request I am about to make" is an attempt. There can be many attempts of one operation, and they must all carry the same key.
Derive it, do not generate it. The best key is a pure function of data you already have — an id from your own database, plus the event that triggered the send.
order-4821-shippedrenewal-8821-2026-08-01ticket-sync-INC-9931cart-88213-recovery
Every one of those can be recomputed from scratch after a process crash, a redeploy, or a queue redelivery, without having stored anything extra. That property is the whole point: a derived key survives situations where a generated key is already lost.
Never a fresh UUID per attempt #
// Wrong. This defeats the entire mechanism.headers: { "Idempotency-Key": crypto.randomUUID() }
A key generated inside the request function is a new key on every attempt. Every attempt therefore looks like a brand-new operation, executes for real, and sends a second message. The header is present, the code looks careful, and you have exactly the duplicate you were trying to prevent — with the added cost of believing you were protected.
This is the single most common way this feature is misused, and it is invisible until the day a network blip produces two WhatsApp messages to the same customer.
A UUID generated once and persisted alongside your record is correct, and is the right answer when nothing in your data identifies the operation. It is still weaker than derivation, because there is a window between generating it and durably storing it. Crash inside that window and the retry generates a new UUID. Derivation has no such window.
Put the event in the key, not just the entity #
order-4821 is too coarse. The order will be the subject of several messages over its life — shipped, out for delivery, delivered, feedback request — and the second one to reuse that key gets a 409 because its body differs.
order-4821-shipped names one message about one order. The next notification is a different operation and gets a different key.
Add a discriminator when a repeat is intentional #
Some operations are meant to happen more than once with identical content: a daily reminder, a monthly statement, an OTP the customer asked to be resent. For those, the thing that makes the second one legitimately different is the occurrence, so put the occurrence in the key.
statement-8821-2026-08reminder-INV-4410-2026-08-01otp-919876543210-1754049600
Now the second send is a different operation, gets a different record, and executes — while a retry within that occurrence still replays.
The table #
| Key | Verdict |
|---|---|
order-4821-shipped |
Good. Derived, names one operation, recomputable. |
renewal-8821-2026-08-01 |
Good. Repeats are legitimate and the date discriminates them. |
a3f0c9e1-… persisted with the record |
Acceptable. Correct if you write it before the first attempt. |
order-4821 |
Too coarse. The next message about this order will 409. |
crypto.randomUUID() at call time |
Broken. Every attempt is a new operation. |
order-4821-attempt-2 |
Broken. The attempt number is exactly what must not vary. |
order-4821-${Date.now()} |
Broken. Same reason, harder to spot. |
whatsapp-send |
Broken the other way. Every send in 24 hours after the first one 409s. |
Two smaller rules:
- Keep the distinguishing part early. The value is truncated at 200 characters. A key that only differs after character 200 is not a different key.
- Treat the value as loggable. It travels as a plain header and lands in log lines on both sides. If your logs are less protected than your database, do not build the key out of a raw email address.
A retry loop that is actually safe #
Both examples do the things that matter: derive the key once, serialise the body once, reuse both on every attempt after a timeout, wait out idempotency_in_progress, and stop on any other error response, because it needs a decision rather than a blind retry: a 4xx needs a fix, and a 5xx needs a check that nothing was sent. After either, the same key is free for the retry.
const BASE = "https://services.amomic.in/api/v1";const sleep = (ms) => new Promise((r) => setTimeout(r, ms));async function sendShippedNotice(order) {// Derived from data we already have. Recomputable after any crash.const idempotencyKey = `order-${order.id}-shipped`;// Serialised ONCE. Every attempt sends these exact bytes.const body = JSON.stringify({to: order.customerPhone,template: {name: "order_shipped",language: "en_US",variables: [order.customerName, order.reference],},});for (let attempt = 1; attempt <= 4; attempt++) {let response;try {response = await fetch(`${BASE}/messages`, {method: "POST",headers: {Authorization: `Bearer ${process.env.AMOMIC_API_KEY}`,"Content-Type": "application/json","Idempotency-Key": idempotencyKey,},body,signal: AbortSignal.timeout(15_000),});} catch (err) {// Timeout or connection reset. The send may already have happened —// the key is what makes retrying it the right move rather than a gamble.if (attempt === 4) throw err;await sleep(500 * 2 ** (attempt - 1));continue;}// 201 on the first success, and 201 again on every replay. Indistinguishable.if (response.ok) return response.json();const { error } = await response.json();if (error.details?.reason === "idempotency_key_reuse") {// Our own bug: the body changed between attempts. Retrying cannot fix it.throw new Error(`Idempotency-Key ${idempotencyKey} was reused with a different body ` +`(request_id ${error.request_id})`,);}if (error.details?.reason === "rate_limited" && attempt < 4) {// Refused before the key was claimed, so the same key is still free.await sleep((60 - new Date().getSeconds()) * 1000 + 250);continue;}if (error.details?.reason === "idempotency_in_progress" && attempt < 4) {// An earlier attempt with this key is still running. Wait, then ask again.await sleep((error.details.retry_after_seconds ?? 2) * 1000);continue;}// Any other error response released the key. Surface it: fix a 4xx, and// after a 5xx confirm nothing was sent before retrying with the same key.throw new Error(`${error.code}: ${error.message} (request_id ${error.request_id})`);}}
Failures #
conflictidempotency_key_reuseThis key already has a record created from a different request body. Either the key is too coarse, or the payload is being re-serialised. (A corrected body after an error response is not this: the failed request released the key.) Do not retry with this key.conflictidempotency_in_progressA request with this key is still pending. <code>details.retry_after_seconds</code> is <code>2</code>. Retry with the same key and body after that. A request that fails releases its key, so this lasts only while the first request is running.invalid_argumentSchema validation, with <code>details.errors</code>. Refused before the key is claimed, so the same key is still free.Any other error response is returned after the key was claimed, and releases it, so a retry with the same key runs fresh. See After an error response, retry with the same key.
Related #
Errors
The full catalogue — nine codes, every details.reason, and which of them are worth retrying.
Rate limits
120 requests per key per minute — the other half of any retry loop you write.
Messages
POST /messages: the endpoint where a missing key costs a customer a duplicate.
Order updates recipe
End to end — derived keys, backoff, and the webhooks that confirm delivery.