Idempotency
Send an Idempotency-Key on writes and a retry after a timeout costs nothing. BillKit replays the first response instead of doing the work twice.
How it works
Every POST and DELETE under /v1 that changes something accepts an Idempotency-Key
request header. The first request holding a given key runs normally, and its status, headers and
body are stored against that key for 24 hours. Any later request with the same key gets that
stored response back verbatim, with Idempotent-Replayed: true added.
curl https://api.billkit.eu/v1/checkout/sessions \
-H "Authorization: Bearer $BILLKIT_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: signup-8f21c4" \
-d '{
"customer_email": "ada@example.com",
"price_id": "price_7Hd3Wm9pQx2vRt5Kna",
"success_url": "https://example.com/welcome",
"cancel_url": "https://example.com/pricing"
}'A key is scoped to the credential that sent it and the mode it ran in. A replay only reaches a
retry sent with the same API key (or the same console, portal or checkout session) in the same
mode, so two of your services, or test and live, can never collide on a key or see each other’s
responses. The flip side is that a retry has to use the credential the first attempt used: after
you rotate an API key, a retry signed with the new key is a new request and runs the work again.
Finish or abandon in-flight retries before revoking the old key. Keys are at most 255 characters;
longer is 400 parameter_invalid with reason: "idempotency_key_too_long". A header sent with an
empty or whitespace-only value is 400 parameter_invalid with reason: "idempotency_key_blank"
rather than being read as absent, because running the write unprotected would let your retry
repeat it. To send a write without a key, omit the header.
The record and the work commit in one transaction. There is no window in which the key is marked
complete but the customer was never created, or the reverse. A checkout session started with
customer_email that fails at the payment provider does keep the customer it created, because the
provider needs it before the charge, but it does not announce it: a retry with the same key
continues with that customer, and customer.created is sent once, with the checkout that succeeds.
Choosing a key
Derive it from the unit of work, not from the attempt. An order id, a job id, a hash of the request body. A fresh UUID generated inside the retry loop defeats the whole mechanism, because every attempt then looks like a new request and you get duplicate charges rather than one.
The two conflicts
| Response | What it means |
|---|---|
| 409 idempotency_in_progress | The first request with this key is still running. Wait and retry the same key. Do not switch keys, which is exactly how you would end up with two of whatever you were creating. If that first request died without answering (its connection was cut, or the server handling it went away), the key is freed for a retry with the same body after 5 minutes. |
| 409 idempotency_key_in_use | This key was used before with a different request body, or against a different path (including a different object id). Both are bugs on the caller side: either the key is not as unique as you thought, or two code paths are generating the same one. |
The body-hash check is what makes the key a promise rather than a suggestion. Reusing a key with
a changed amount is refused rather than silently replaying the old amount. A JSON body is
compared as JSON, not as bytes: key order and whitespace do not matter, so re-serialising the
same object is still the same request. Every value does matter, including its type, so 1 and
1.0 are different requests, as are "1" and 1.
One field a replay does not repeat
An embedded checkout session’s client_secret lives 30 minutes; an idempotency record is
replayable for 24 hours. Replaying the stored body verbatim would therefore hand you a secret
that can no longer mount an element for most of that window, and retrying would return the same
dead one.
So on a replayed POST /v1/checkout/sessions, if the session is still open and its secret has
expired, BillKit mints a fresh one and returns that. Everything else, the session id included, is
the stored response. A session that has reached a terminal state keeps its dead secret, because
there is nothing left to mount an element for.
Errors are replayed too
A failure the route itself decides, such as a 400, 404 or 409 about the request’s content
or an object’s state, is stored like a success. Send the same key again and you get the same
status and error back, because the answer will not change. The replayed error carries type,
code, message, param and reason, but not request_id: that belonged to the first request.
Some failures happen before the key is looked at, so nothing is stored and the same key can be
used again once you fix the request: authentication (401), scope (403) and schema validation
(a 400 naming the field, including an unknown one).
A failure that says “not now” rather than “not this” clears the key, so the next attempt with
that key runs the work again: every 5xx (including 503 service_unavailable), 429 and
409 operation_pending. These are exactly the responses Errors lists as
worth retrying, and clearing the key is what makes that advice work: wait for the payment
operation in flight to finish, retry with the same key, and you get a real answer rather than the
first 409 again. It is also why retrying a 5xx with the same key is safe.
A request that fails without answering at all, because the connection dropped or the server
handling it went away, cannot clear its key itself. Its key is held for at most 5 minutes, during
which a retry gets 409 idempotency_in_progress; after that, a retry with the same key and the same
body runs the request. If the first attempt did complete after all, its stored response is what the
retry gets back. A retry after those 5 minutes runs the request from the start; it does not undo
anything the first attempt had already done, which is why the payment paths below carry their own
protection rather than relying on the key.
That holds even when the failed attempt had already started a payment. A checkout session, plan
change or payment method update records its payment operation before it calls the payment
provider, so an attempt that dies part way can leave that operation behind. The retry with the
same key is the same request: it picks up that operation instead of being refused with
409 operation_pending by it, and the provider returns the payment the first attempt created
rather than making a second one. A request with a different key for the same customer is still
refused with 409 operation_pending until the operation resolves.
Money-moving calls should always carry a key. Checkout sessions, refunds and one-off payments are the ones where a duplicate is expensive and visible to the customer. The official SDKs generate a key for every write and reuse it across their own retries. That covers a network blip inside one call, not your own job retrying the whole operation, so pass your own key derived from the unit of work.
What does not need a key
GET requests are already idempotent and the header is ignored on them. It is also ignored on
two POST routes that only read: POST /v1/coupons/validate and
POST /v1/subscriptions/{id}/preview_update.
Several operations are idempotent by construction regardless: cancelling an already-cancelled subscription returns it unchanged, resuming an already-running one does nothing, and redelivering a webhook that was already delivered is a no-op. A key still helps there, because it also protects you from the double-submit that produced the retry in the first place.
BillKit