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 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"
}' 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
| 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. |
| 409 idempotency_key_in_use | This 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.
BillKit