Skip to content

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

Send one on every write. Generate a UUID per logical request and reuse it across every retry of that request.

Terminal window
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.json

Same key plus same body, within 48 hours, replays the stored response - same order ID, same status code. Nothing is created twice.

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.

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.

  1. 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.

  2. 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.

  3. 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.

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.

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.

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-118442
claim.withdrawn:CASE-2026-00871

Scope 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.

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 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.

Terminal window
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.