Usage counts that do not match the invoice
Usage does not match the invoice? The invoice bills overage blocks for a period that already closed. How to reconcile the two, fastest check first.
In short
A usage counter and an invoice are not meant to agree. The counter holds every unit since the last monthly reset. The invoice bills only units past the included amount, rounded up to whole blocks, for a Stripe period that has already closed. Check that arithmetic first, then which period each number covers.
On any plan with an included allowance, a usage screen that matched the invoice would be the bug.
They count different things. The usage screen reads one field on the workspace,
quotas.<slug>, which holds every unit recorded since the counter last reset.
The invoice bills a Stripe metered line that only ever received the units past
the plan's included amount, rounded up to whole blocks, for a billing period
that has already closed. Different quantity, different unit, different window.
A dashboard reading 12,500 beside an invoice line reading 3 can be exactly
right.
So "why don't they match" is the wrong question. The useful one is whether the gap is the gap the code produces. The checks below run in the order that settles that fastest, so stop at the first one that explains your number.
Before you start
The Stripe account you connected under Billing → Credentials, because that is where the invoices live. The workspace id and the quota slug. And two numbers from the quota on its plan version: the included amount and the unit size. The SDK's invoice object carries totals and links but no line items, so the line you are reconciling is read in Stripe, on the dashboard or the hosted invoice page.
Read the symptom first
| What you see | Where to look first |
|---|---|
| Invoice quantity far below the usage screen | Blocks, not units |
| No overage line at all | Trial, hard limit, annual plan, or in flight |
| Last period's overage on this invoice | A late report, or a plan change |
| Usage screen low, invoice high | A reset after a missed payment |
| Your own logs higher than the counter | Records that were refused |
| The counter higher than your own logs | Retries that were not deduplicated |
Compare blocks to blocks
The invoice line for a quota is a Stripe product named after the quota item with "Overage" on the end, and its quantity is not units. It is billable blocks. Each record reports the difference between the block count after it and the block count before it, so over a period the line adds up to:
overage = max(0, consumed - included)
billable blocks = ceil(overage / unitSize)Put demo numbers through it. A plan includes 10,000 api-calls and prices
overage per 1,000. The workspace has consumed 12,500. Overage is 2,500, which is
3 blocks. The usage screen says 12,500, the invoice says 3, and nothing is
wrong.
The pre-built usage screen, openWorkspaceSettings('usage'), already does this
arithmetic. When the unit size is above 1 it prints a "Billable blocks" row, and
its estimated charge multiplies that same ceiling by the overage rate. Compare
the invoice to that row, not to the big number at the top. From your own code
it looks like this. I have not run the snippet as written, so treat it as a
shape rather than a tested script.
import BuildBase from '@buildbase/sdk';
const bb = BuildBase({ serverUrl, orgId, getSessionId });
// unitSize comes from the quota on the plan version: 1 when overage is
// priced per unit, 1000 when the rate reads "per 1,000".
export async function expectedBlocks(
workspaceId: string,
quotaSlug: string,
unitSize: number
) {
const q = await bb.usage.getQuota(workspaceId, quotaSlug);
return {
consumed: q.consumed, // what the usage screen shows
included: q.included,
blocks: Math.ceil(q.overage / unitSize), // what Stripe is told
};
}Three situations never produce an invoice line, however high the counter goes. A trialing subscription is capped at its included amount and its overage is never reported. A quota with overage turned off is a hard limit, and the Stripe subscription carries no metered item for it at all. And a free plan has no Stripe subscription to bill. Our own plans lean on the second kind heavily: storage is the only quota that bills over, at $0.10 per GB beyond the plan allowance, while emails, workflow runs and webhook deliveries are hard allowances that never reach an invoice.
Credits never reach one either. A credit pack is a one-time payment, and spending credits debits a wallet rather than reporting usage to Stripe. If the number you are reconciling is a credit balance, you are on the other system, and quotas are not credits is where the two part ways.
Price can drift too, separately from quantity. The invoice charges the Stripe price's unit amount. If the plan version's overage rate and that Stripe price have come apart, the server logs an "Overage amount mismatch" warning and keeps billing at Stripe's figure, while the usage screen's estimate reads the plan. Same blocks, different total.
Key takeaway
Reconcile the invoice quantity against ceil((consumed - included) / unitSize) for the same window. If that holds, nothing is broken. If it does not, the next three checks are about timing.
Check which period the invoice is billing
Stripe bills metered usage in arrears. Its own documentation puts it plainly: at the end of the billing period, the total usage is multiplied by the unit amount. So the overage on an invoice belongs to the period that just closed, and by the time that invoice reaches anyone, the usage screen has reset or is about to. You are holding last period's bill against this period's counter.
To see what the next invoice will bill, preview the upcoming invoice in Stripe. Stripe's docs warn that the quantity there "might not be the most up-to-date", because its own processing lags a little.
On an annual plan the gap is a year. Overage prices are created with the same interval as the plan's base price, because the subscriptions BuildBase creates, through Checkout on Stripe's classic billing mode, must keep every price on one interval. So a yearly subscription carries a yearly metered item. The counter, meanwhile, resets monthly whatever the interval. Twelve monthly counters' worth of overage goes onto one invoice at renewal. A customer who went over in the second month sees it on an invoice about ten months later, unless a plan change bills it sooner.
That is the other way usage jumps invoices. Removing a metered item from a
Stripe subscription discards its usage, so before a plan change swaps the items,
the server reads what each metered item has accumulated and moves it onto the
next invoice as an invoice item described Usage before plan change: N unit(s).
N there is blocks. So after a plan change, one quota's overage can arrive on
two lines: the carried one, on the next invoice the subscription produces, and
the new metered item's own line, counting up from zero. Why the carry exists at
all is in the
proration guide.
Check whether it has reached Stripe yet
Recording happens in two phases, and only the first one is in your request. The
call increments the counter and returns. Reporting the overage to Stripe is a
queued job. The response tells you which happened: billedAsync: true means a
job was queued for this record, and false means either nothing was billable or
the enqueue failed.
The job gets 5 attempts with exponential backoff starting at 3 seconds. A report that never lands leaves its usage log marked unbilled, and a sweep at :03, :13, :23 and every 10 minutes after re-queues unbilled logs older than 2 minutes, up to 100 per org per pass. In the normal case Stripe is one queued job behind the counter. After a failure, a sweep or two behind.
The Stripe idempotency key on that report is the usage log's id, not the job's. It used to be the job's, and the comment in the code records what that cost: the sweep re-queued a log whose "billed" flag had failed to save after Stripe had already accepted the usage, the re-queue was a new job with a new id, and the same overage was billed twice. Key on the thing being billed, not on the attempt to bill it. Everything else in this post keeps landing on that rule.
Usage near a boundary lands one invoice later
Stripe refuses a usage record stamped before the subscription's current period. Its docs describe a grace window of a few minutes for clock drift, and say not to rely on it. The worker doesn't. When a report is stamped before the open period's start, which is exactly what a sweep produces when it re-queues a log created just before a renewal, the worker stamps it at the start of the open period instead. The usage is billed. One invoice late.
So the closing invoice comes up short by those blocks and the next one runs long by the same amount. Two consecutive invoices reconcile even when neither does on its own, which is worth checking before anyone opens a ticket.
The counter also keeps its own clock. Its monthly period starts when the checkout webhook is processed, not at Stripe's billing anchor, and a reset only takes effect once that period has ended. The reset sweep runs every 5 minutes. Net effect: the counter resets a little after Stripe renews. Units recorded in that gap count against the old counter, and if it was already past its included amount, they bill as overage into the new Stripe period.
With a trial, the gap is not a little. Stripe anchors the billing cycle at the trial's end by default, while the counter anchored at checkout. Say the trial is 14 days: every counter month starts two weeks before the invoice month, and it stays that way, so each invoice's overage is drawn from two counter windows. Units used during the trial also stay on the counter after it converts, until the counter's own month runs out.
Your own logs are a third clock. Sum them by calendar month and you are comparing against a window neither system uses. Use the invoice line's own period, which Stripe gives in Unix seconds:
// periodStart and periodEnd: the invoice line's period, from Stripe
const page = await bb.usage.getLogs(workspaceId, {
quotaSlug: 'api-calls',
from: new Date(periodStart * 1000).toISOString(),
to: new Date(periodEnd * 1000).toISOString(),
limit: 100,
});
// page.docs, page.hasNextPage: page through and sum quantityHow far back that reaches is bounded by a log-retention limit on your BuildBase plan, so reconcile while the period is recent rather than at year end.
A missed payment changes what counts as overage
A past_due subscription can still record usage. Entitlement treats it as the
grace window while Stripe retries the card, and overage keeps being reported.
What it does not get is a reset. Reset eligibility is active and trialing
only, on purpose, so a failing card does not buy a fresh allowance.
Follow that through a renewal. The counter carries last period's total into the new one, so every new unit is already past the included amount, and every new unit bills. Then the payment recovers, a reset is queued straight away, and the usage screen drops to a small number while the upcoming invoice holds a pile of overage. Neither number is wrong. It is the same rule, read at two moments. On a quota with overage off, the identical gap shows up as a counter stuck at the cap instead, which the 429 guide walks through.
One step further, a subscription suspended by dunning is not entitled to record
at all. The call fails with No active subscription found. Your app did the
work, nothing was counted, and nothing will be billed.
Then compare your own logs with the counter
Everything so far is the counter against the invoice. If the counter disagrees with what your app believes it did, the cause is on the recording side, and the direction tells you which.
Your logs are higher. Records were refused. A quota rejection, a missing
subscription, or a batch item reported as success: false inside a response
that was otherwise a 200. Each is work your app did that was never counted.
The counter is higher. A retry was counted, which the
per-call billing guide names as the
invoice dispute you will actually get. An idempotency key is matched on
workspace, quota slug and key together, and a replay of a finished record is
absorbed: the response comes back with used: 0, which is the cheapest way to
watch deduplication working. It fails in two shapes.
The first is a fresh key per attempt. Our own docs sample keys on
req.headers.get('x-request-id'), which is only stable if your caller sets that
header and resends it. If a proxy in front of you mints one per inbound request,
every retry is a new key and a new unit.
The second is an overlapping retry. The key is checked with a read before the increment and stored with the usage log after it, so a retry that arrives while the first attempt is between those two points passes the check. Both count. Both bill. Retry after a backoff, never in parallel, and never from a client timeout shorter than the call takes to answer.
The fix for the first shape is to mint the key where the operation is defined, not where the attempt happens:
// The key is created with the export and stored on it, so every retry of
// this job sends the same one.
const usage = await bb.usage.record(workspaceId, {
quotaSlug: 'exports',
quantity: 1,
idempotencyKey: `export:${exportId}`,
});
if (usage.used === 0) {
// A replay. This export was already counted, and nothing new was billed.
}Same caveat as before: I have not run it.
Our own metering is built this way. When an org on BuildBase sends email, the send is counted into a Redis buffer, flushed every 30 seconds, and reported onto a workspace counter in our own BuildBase, the same kind your app writes through the SDK. The key is minted once, when the buffer is taken, and travels with the queued job. The comment above that code says why: a retry that re-read the buffer "would send new counts under an old key, or old counts twice".
When the counter and the invoice move differently
Three paths move the counter and the invoice by different amounts.
A correction in the console. The admin path,
PATCH /api/workspaces/:id/quota/usage, sets values absolutely. An increase
goes through the billing path like any record. A decrease is written to the
counter only, so lowering a counter after a bug leaves whatever was already
reported to Stripe on the invoice; that needs a credit in Stripe. An increase the
billing path refuses (no entitled subscription, or a trial or hard-capped quota
at its limit) is written to the counter anyway, without billing. A correction
path that reaches the invoice is one of the pieces
building metering yourself
has to price in.
A write-off. When the sweep finds an unbilled log whose workspace no longer has a Stripe subscription, a cancellation for instance, it marks the log billed without reporting it. Overage that failed to report before a cancellation is dropped rather than chased.
Records that land at the same instant. Overage is computed from the counter as it was read before the increment, so simultaneous records each see the same starting value. The code comment says this can slightly under-bill, and across the included line with a unit size of 1, it does. With a unit size above 1, two simultaneous records that each open a new block each bill it, so the invoice can also run a block over. Sequential records add up exactly. This is the last place to look, and only when traffic for one workspace overlaps.
Install
npm i @buildbase/sdk