BillKit/Docs Console
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 13, 2026 · API version 2026-09-13

How it works

Every POST and DELETE under /v1 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"
}'

Keys are scoped to your account. They are at most 255 characters; longer is 400 parameter_invalid with reason: "idempotency_key_too_long".

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.

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.
409 idempotency_key_in_useThis key was used before with a different request body, or against a different route. 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.

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 that is the caller’s fault gets stored like a success. Send the same key again and you get the same 400 or 409 back, because the answer will not change.

Server errors are treated differently. A 5xx clears the key entirely, so the next attempt with that key runs the work again. That is the correct behaviour for a failure that might be transient, and it is why retrying a 5xx with the same key is safe.

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 expose an idempotencyKey option on those methods.

What does not need a key

GET requests are already idempotent and the header is ignored on them.

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.