Blog
Explainer

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.

Dharmendra Jagodana11 min read

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.

WriteWho retries itThe key should name
A charge or invoice itemyour server, your job queuethe thing being charged for
A usage eventthe client, your queuethe unit of work that used something
A credit debitthe client, a background jobthe job that spent the credits
A webhook you receivethe senderthe 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.

billing
metering
credits
webhooks

Frequently Asked Questions

What is an idempotency key?

A unique value the caller sends with a write so the receiver can tell a retry from a new operation. The receiver stores the key with the result, and a second request carrying the same key gets the first result back instead of running again. Make it once per operation, before the first attempt, and never inside the retry loop.

Should I use a new idempotency key for each retry?

No. Every retry of one operation carries the same key, otherwise the key protects nothing. A new key means a new operation. The mistake usually hides in plumbing: a key built from a queue job id, or from a request header your proxy stamps fresh on every attempt, changes on each retry even though the work did not.

How long do idempotency keys last?

As long as the receiver remembers them, which differs by system. Stripe can prune an API idempotency key once it is at least 24 hours old, and a key reused after that starts a new request. Anything that retries for longer than the window needs its own record of what it already did.

Is exactly-once delivery possible for webhooks?

Not over a network. A sender that hears nothing back cannot tell a lost request from a lost response, so it retries and the receiver can see the event twice. What you can build is exactly-once processing: at-least-once delivery from the sender plus a handler that refuses to apply the same event twice.

Put this into practice

The modules in this post are one console away. 7-day free trial, no credit card.

7-day free trialNo credit card requiredCancel anytime