Idempotency in billing, and why it matters
Idempotency in billing means a retried charge, usage event, credit debit or webhook takes effect once. Having a key is the easy part. What the key names is where double billing starts.
In short
Idempotency in billing means applying an operation twice has the same effect as applying it once: a retried charge, usage event, credit debit or webhook moves money one time. Networks deliver at least once, so exactly-once billing is at-least-once delivery plus a receiver that refuses to apply the same key twice.
Idempotency in billing means applying the same operation twice has the same effect as applying it once. A charge retried after a timeout charges once, a usage event sent twice counts once, a credit debit replayed by a queue debits once, and a webhook delivered three times grants one thing.
Most explainers on this stop at "send an idempotency key with the request". That is the easy half. Three idempotency bugs written up in the comments of BuildBase's own billing code had a key the whole time: one named the retry instead of the event, one forgot which organization the event belonged to, and one was written down before the work was safe. A key fixed none of them. Deciding what it meant did.
Why it exists: a timeout is not a no
A billing write over a network has three outcomes, not two. It worked and you heard. It failed and you heard. Or you heard nothing, and the work may or may not have happened on the other side.
That third case is the whole subject. Retry, and you might charge twice. Give up, and you might never charge at all. Every layer between your code and the money - the HTTP client, the job queue, the SDK, the provider's webhook sender - settles the question the same way. It retries.
So the double charge is rarely a bug in the usual sense. It is your reliability engineering doing its job. The BuildBase queue that reports overage to Stripe makes up to 5 attempts with exponential backoff. Our outgoing webhooks retry a failed delivery 4 more times, at roughly 10s, 20s, 40s and 80s. Stripe retries a live-mode webhook for up to three days. Every one of those retries exists so that nothing gets lost. Without a key, every one is also a chance to apply something twice.
Exactly once is two guarantees, not one
"Exactly-once delivery" is the phrase people reach for, and over a network it is not on offer. A sender that hears nothing cannot tell a request that never arrived from a response that never came back, so it has to pick: retry and risk a duplicate, or stop and risk a loss. Billing cannot afford the loss, so the sender picks the duplicate.
What you can build is exactly-once processing, and it splits into two jobs owned by two sides:
- The sender delivers at least once. It keeps trying until the receiver acknowledges.
- The receiver applies at most once. It recognises a repeat and answers with the first result instead of doing the work again.
Put them together and each operation takes effect once. Remove either and you are back to choosing between a lost charge and a double one. The key is only the mechanism the receiver uses to recognise a repeat.
Key takeaway
Exactly-once billing is at-least-once delivery plus idempotent processing. The sender's retries make sure nothing is lost; the receiver's key makes sure nothing is applied twice. Neither half works on its own.
How it works for the four billing writes
An idempotency key is a value the caller sends with a write so the receiver can tell a retry from a new operation. The receiver stores it with the effect, and a second request carrying it gets the first answer back. That mechanism is the same everywhere. What the key should name is not.
| Write | Who retries it | The key should name |
|---|---|---|
| A charge or invoice item | your server, your job queue | the thing being charged for |
| A usage event | the client, your queue | the unit of work that used something |
| A credit debit | the client, a background job | the job that spent the credits |
| A webhook you receive | the sender | the sender's event id, plus who received it |
A charge. Stripe documents its version precisely, and the details are the
point. On its v1 API, Stripe saves the status code and body of the
first request made with a key once the endpoint starts executing, and returns
that saved response to every retry with the same key, including a 500. It
compares a retry's parameters with the original and errors if they differ.
Keys run up to 255 characters, can be pruned once they are at least 24 hours
old, and only POST requests take one, since GET and DELETE are idempotent
already.
A good key is built from the facts of the charge. In BuildBase, when a plan change carries metered usage onto the next invoice, the invoice item is keyed on the subscription, the subscription item and the number of units carried. Retry the plan change and it is still one charge.
A usage event. bb.usage.record() takes an idempotencyKey, and a repeat
of a key already recorded for that quota returns the current standing without
counting again. The key is unique per workspace and per quota, so one request
that uses two quotas can pass the same key to both.
The trap is what you pass in. Our docs' samples use the incoming x-request-id
header. That works when your caller sets the header and sends the same value on
a retry. If a proxy in front of you stamps a fresh one on every request, each
retry arrives with a new key and the key protects nothing.
We meter our own customers on BuildBase, and the job that reports their usage sidesteps the question. Counts are taken from a buffer in batches, and the key is minted when the batch is taken, then travels inside the queued job. A retry re-sends the same counts under the same key. A report that re-read the buffer on retry would send new counts under an old key, or old counts twice.
A credit debit. bb.credits.consume() takes a key the same way. The
interesting part is where the protection actually lives. The server does look
the key up before debiting, but that read is not atomic with the debit, so two
retries racing each other can both pass it. What decides is a unique index on
the credit ledger over the workspace and the key. The second debit's ledger row
is refused, its deduction is put back, and it answers with the first debit's
result.
That is the general rule, and the reason "check whether we have seen this key" is not an implementation. A read followed by a write is a race. The constraint in the database is the referee; the read is a fast path for the common case.
// One key per unit of work, made before the first attempt
// and reused by every retry of it.
const key = `export:${exportJob.id}`;
await bb.credits.consume(workspaceId, {
amount: 50,
description: 'Batch export',
idempotencyKey: key,
});A webhook you receive. Here the sender hands you the key. Stripe's own
advice is to log the event ids you have processed and skip any you have seen,
and it adds a wrinkle worth knowing: in some cases it generates two separate
event objects for the same change, which it says to catch with the id of the
object in data.object plus the event type. Even a provider's event id names a
message, not a business fact.
BuildBase's Stripe endpoint verifies the signature first, then stores the event under a unique index on its id, queues it, and only then answers 200. A duplicate delivery hits the index instead of creating a second row. Those rows are kept for 365 days, against a sender that retries for three.
Where it breaks: what the key names
The mechanism is solved. The meaning is where billing double-charges, and the three bugs in our own code show it better than any definition, because each one had a key from the start.
The key named the attempt, not the event. Overage reports to Stripe go out from a queue, and the Stripe key used to be built from the queue job. A sweep every 10 minutes re-enqueues usage logs still marked unbilled. When Stripe had accepted a report but our write marking the log as billed failed, the sweep created a new job for the same log, with a new id and so a new key, and the same overage could be billed twice. The key now comes from the usage log, which is the thing being billed, rather than the job carrying it.
The same trap catches any key built from a field that can move. Plan credit grants are keyed on the subscription, the plan version and the period end. A trial converting to paid moves the period end, so a grant at that moment would carry a fresh key and walk past the duplicate check. The code does not grant there, for exactly that reason.
The key forgot whose it was. Processing jobs for Stripe events were deduplicated on the event id. But one Stripe account can be connected to more than one BuildBase organization, which every test-mode setup reusing a key does, and Stripe then sends the same event id to each. Whichever organization queued it first was the only one to process it. The other's job was dropped as a duplicate after its webhook had already answered 200, so Stripe never retried. Our payment lifecycle test suite caught it, with a subscription that existed in Stripe and never locally. The job id now includes the organization. A key is unique only within a scope, and the scope has to be part of the key.
The key was written down before the work was safe. The Stripe endpoint stores the event row, then queues the job. If queueing failed, it answered 500 and Stripe retried. The retry hit the unique index on the stored row, read that as "already handled", and answered 200, with the event never processed. Seen is not done. A duplicate now falls through to the queueing step instead of returning early, and because the job id comes from the event, queueing it again does nothing while the first job exists.
Key takeaway
A key fails in three ways that having one does not prevent: it names the retry instead of the event, it leaves out the scope it is unique within, or it is recorded before the effect is safe. Build it from the business event, include whose it is, and record it with the effect rather than ahead of it.
When idempotency is the wrong tool
A key fixes one problem: the same operation arriving twice. It gets reached for when the problem is something else.
When the write already sets state. Setting a subscription's status to
active from a webhook is safe to apply twice. Adding credits from one is not.
The cheapest idempotency is a write that sets a value instead of adding to it,
and where a handler can be shaped that way, a key table on top is one more thing
that can fail.
When two different events arrive in the wrong order. They are not duplicates, and no amount of deduplication fixes them. Stripe does not guarantee event order and neither do our webhooks. Our Stripe event worker processes one event at a time for that reason, which is an ordering fix, not an idempotency one.
When the retry outlives the window. A key only works while the receiver remembers it. A job that retries a Stripe call more than 24 hours later may be a new charge as far as Stripe is concerned, so anything with a longer retry horizon needs its own record of what it already did. For overage, that record is the billed flag on each usage log.
When the key is too coarse. Two genuinely different operations that share a key get merged, and the second silently does not happen. That is an under-bill, and unlike a double charge, nobody writes in to report it. If you are unsure whether two writes are the same operation, they are not. Give them different keys.
And one gap that is ours. BuildBase's outgoing webhooks carry no unique event
id; our webhook docs say so. Retries repeat the same event and timestamp,
but no single field in the delivery is the event's identity. So a handler for
our webhooks has to be safe to run twice by construction: set state from the
payload, do not add to it. A credit.consumed handler that subtracts the amount
from a local copy of the balance double-counts on a retry. One that stores the
balance the payload carries does not, as long as it keeps the newer timestamp
when two deliveries arrive out of order.
Where this sits next to usage-based billing
Idempotency is the property that turns a usage invoice from accurate into defensible. What usage-based billing does to your invoices gives it one line. This post is the long version of that line. Idempotent ingestion is also part of the scope that the cost of building usage metering yourself prices, and its line about a meter that double-counts on retry is this whole post in one sentence.
In code, the per-request version is charging per API call, where a client's automatic retries are the invoice dispute you will actually get. The debit version is the credits wallet, including why the database and not the handler is the referee. And on the receiving side, the reason our five-minute signature window is tight is the same gap as above: with no event id, the age check is the replay defence.
Both bb.usage.record() and bb.credits.consume() take an idempotencyKey,
and the credits and usage reference
has the full surface. The decision about what the key names is yours either way.