Idempotency
Two mechanisms with similar names and different jobs. Getting them confused has a concrete consequence: a real person receives a second telephone call about the same debt.
Idempotency-Key |
dedupe_key |
|
|---|---|---|
| Where | HTTP header, on writes | Field in the signal body |
| Protects against | A retried HTTP request | A re-emitted business fact |
| Scope | (tenant, key, method, resolved path) |
(tenant, signal_type) |
| Window | 48 hours | 90 days |
| Repeat behaviour | Replays the stored response | Returns the original with duplicate: true |
| Chosen by | Your HTTP layer, per attempt | Your business logic, per fact |
Idempotency-Key
Section titled “Idempotency-Key”Send one on every write. Generate a UUID per logical request and reuse it across every retry of that request.
IDEM=$(uuidgen)
curl -sS -X POST https://api.pacepayments.ai/v1/call-orders \ -H "Authorization: Bearer $TOKEN" \ -H "Idempotency-Key: $IDEM" \ -H "Content-Type: application/json" \ -d @order.jsonSame key plus same body, within 48 hours, replays the stored response - same order ID, same status code. Nothing is created twice.
Why a timeout without one loses an order
Section titled “Why a timeout without one loses an order”Consider a POST that times out at your end.
Without a key, you have no way to know what happened. The request may never have arrived, or it may have been processed and the response lost on the way back. Both look identical from where you are standing. You then choose between two bad options: retry, and risk a second call to the same person; or do not retry, and risk an order that was never placed sitting in your system marked as sent.
With a key, there is no dilemma. Retry. If the first attempt got through, you receive that same order back. If it did not, the order is created now. Either way, exactly one call happens.
This is the whole reason for the mechanism. It is not about duplicate button clicks.
Scope and conflicts
Section titled “Scope and conflicts”The response is stored against (tenant, key, method, resolved path). The resolved
path matters: the same key against a different path returns 409
idempotency_key_conflict rather than replaying an unrelated response.
Same key with a different body also returns 409. That is a bug on your side - two distinct requests sharing a key - and a clear error beats silently returning the wrong one.
A concurrent second attempt with the same key waits, briefly, for the first to finish, so two racing retries do not both create.
Practical rules
Section titled “Practical rules”-
Generate the key where the work is decided
In the code that decides “this case needs a call”, not in the HTTP wrapper. A key generated per attempt protects against nothing at all.
-
Persist it alongside the work item
If your process restarts mid-retry, the key has to survive with it. A key held only in memory is gone exactly when you need it.
-
Do not reuse a key across logical requests
One key, one intent. Reuse gets you a 409 or, worse, a replayed response for something you did not ask for.
dedupe_key
Section titled “dedupe_key”On signals, not orders. It is a business key, not a technical one:
{ "signal_type": "payment.received", "subject_reference": "CUST-88213", "dedupe_key": "payment.received:INV-2026-118442"}A repeat returns the original signal with duplicate: true and triggers nothing
again. Not an error - a correct response to a fact you have already reported.
Why 90 days rather than 48 hours
Section titled “Why 90 days rather than 48 hours”Because the threat is different.
Idempotency-Key guards a single HTTP exchange, which either succeeds or fails
within seconds. Forty-eight hours is generous for that.
dedupe_key guards against an at-least-once producer. A message queue replayed after
an incident, a nightly batch that ran twice, a migration that re-imported last
quarter’s payments - these re-emit facts days or weeks later, with entirely new HTTP
requests carrying entirely new idempotency keys. Nothing at the HTTP layer can catch
them.
Ninety days is long enough to cover a batch job that ran twice a quarter apart.
Constructing one
Section titled “Constructing one”Derive it from the fact, deterministically, so the same fact yields the same key however it reaches you:
{signal_type}:{your business identifier}payment.received:INV-2026-118442claim.withdrawn:CASE-2026-00871Scope is (tenant, signal_type), so the same business identifier can appear under
payment.received and claim.disputed without colliding.
Reusing a dedupe_key with materially different content returns 409
dedupe_key_conflict. Comparison is against the validated body, not the raw
bytes - a different key order or an extra line break is not a different business
event, and a 409 for that would be pedantry aimed at a producer sending the same
thing twice.
Using both together
Section titled “Using both together”They compose, and a robust producer uses both:
def report_payment(invoice_id, subject_ref, amount, occurred_at): signal = { "signal_type": "payment.received", "subject_reference": subject_ref, "dedupe_key": f"payment.received:{invoice_id}", # per FACT "occurred_at": occurred_at.isoformat(), }
return call( "POST", f"{BASE}/v1/signals", json=signal, idempotency_key=str(uuid4()), # per ATTEMPT, reused on retry )The idempotency key protects the HTTP call. The dedupe key protects the meaning. If
this function is invoked twice a week apart with the same invoice, the second returns
duplicate: true and changes nothing.
client_reference is neither
Section titled “client_reference is neither”client_reference on an order is your own business key - an invoice number, a case
file. It is mirrored onto the resource and into every event, and it is filterable.
It does not deduplicate anything and it does not have to be unique. Its job is reconciliation: when you need to answer “did we ever call about invoice X?”, you filter on it.
curl -sS "https://api.pacepayments.ai/v1/call-orders?client_reference=INV-2026-118442" \ -H "Authorization: Bearer $TOKEN"This is also the right recovery when the 48-hour idempotency window has passed and you no longer know whether an order was placed. Ask.