Idempotency
Three routes create things that cost money. All three require an
Idempotency-Key header:
POST /v1/rentals/reservationsPOST /v1/rentals/reservations/{reference}/bookPOST /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 against | You get |
|---|---|
| A completed request | The original response, byte for byte. |
| A request still running | 409 idempotency_key_in_progress |
| The same key, a different body | 409 idempotency_key_reused |
| A request that threw | 500 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_refThe 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.