GuidesIdempotency

Idempotency

Three routes create things that cost money. All three require an Idempotency-Key header:

  • POST /v1/rentals/reservations
  • POST /v1/rentals/reservations/{reference}/book
  • POST /v1/rentals/bookings

Omit it and you get 400 idempotency_key_required before anything happens. This is not a best-effort nicety you can skip while prototyping — the header is part of the request.

No other route needs one. Cancels do not, because cancelling an already-cancelled booking is not a second side effect and already answers 200.

Sending one

Any string your side can guarantee is unique per logical operation. A UUID v4 is the obvious choice:

curl -sS -X POST "https://{your-base-url}/v1/rentals/bookings" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 91b7d3e5-6a04-4f28-b9c1-5e3a7d260f84" \
  -d '{ "...": "..." }'

Generate it once, when your customer commits — not inside your retry loop. A key regenerated on retry is a different key, which defeats the whole mechanism and books twice.

What a replay returns

A key that has already been used returns the original outcome, whatever it was. Never a second booking, and never success for a request that failed.

Replayed againstYou get
A completed requestThe original response, byte for byte.
A request still running409 idempotency_key_in_progress
The same key, a different body409 idempotency_key_reused
A request that threw500 carrying the original error message

idempotency_key_reused compares a hash of the body that is stable across key ordering, so {a, b} and {b, a} are the same request. Only a genuine difference in values trips it.

failed is terminal, not a release

This is the part that differs from some other APIs, and the reason matters.

When the work behind a key throws, the key is not released for a fresh attempt. It is marked failed, and replaying it returns the original error rather than trying again.

The naive alternative — release the key on any error, so a retry is a real attempt — is only safe if the work was all-or-nothing. It is not. Creating a booking writes the booking row and then, in a separate statement, links the payment. If that second statement throws, the booking row already exists. Releasing the key at that point would let your retry create a second booking for the same customer — exactly the failure this mechanism exists to prevent. From the shape of a thrown error there is no way to tell whether a side effect already committed.

So a failed key stays failed. Getting a genuinely wedged one unstuck means someone confirming by hand whether the underlying booking landed, which is an operational action rather than something the API does for you.

In practice: when a creating POST replays as a 500, do not spin. Look the booking up by your own reference with GET /v1/bookings/ext/{external_ref} — if it is there, the write landed and you have your answer without opening a ticket. If it is not, use a new key.

Concurrency

Two requests carrying the same key race on a unique index. Exactly one wins and does the work; the loser reads back what the winner stored, or gets 409 idempotency_key_in_progress if the winner has not finished yet.

There is no window where both callers see “no such key” and both proceed. Treat idempotency_key_in_progress as “wait briefly and read the outcome”, not as a failure.

price_mismatch needs a new key

The one case where reusing a key is wrong.

A 409 price_mismatch is a completed outcome. That key is now permanently associated with the refusal, and replaying it returns the same 409 forever — even with a corrected expected_total in the body, because a changed body against a used key is idempotency_key_reused anyway.

Send a new Idempotency-Key with the corrected total.

A working pattern

key = uuid4()                       # once, when the customer commits
attempt = 0

loop:
  response = POST(..., Idempotency-Key: key)

  2xx                     -> done
  409 in_progress         -> back off, retry with the SAME key
  409 price_mismatch      -> re-quote, then retry with a NEW key
  409 key_reused          -> bug on your side: the body changed under a used key
  4xx (anything else)     -> terminal; do not retry
  5xx / network timeout   -> retry with the SAME key, up to a few attempts,
                             then look the booking up by external_ref

The rule underneath all of it: the same key for the same intent, a new key for a new intent. A network timeout is the same intent. A corrected price is a new one.