BillKit/DocsConsole
Build & operate

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.

Updated Sep 25, 2026·OpenAPI v1.1.0

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.

shell
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

ResponseWhat it means
409 idempotency_in_progressThe 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_useThis 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.