Guides
Build guide

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.

Dharmendra Jagodana12 min read

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 seeWhere to look first
Invoice quantity far below the usage screenBlocks, not units
No overage line at allTrial, hard limit, annual plan, or in flight
Last period's overage on this invoiceA late report, or a plan change
Usage screen low, invoice highA reset after a missed payment
Your own logs higher than the counterRecords that were refused
The counter higher than your own logsRetries 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 quantity

How 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
billing
metering
troubleshooting

Frequently Asked Questions

Why is my metered usage not on the invoice?

Usually because none of it is billable yet. Only units past the included amount are reported to Stripe, a trialing subscription or a quota with overage turned off never bills, and Stripe charges metered usage in arrears, so this period's overage appears on the invoice cut when the period closes.

Why does the invoice show far fewer units than my usage dashboard?

The dashboard shows consumed units. The invoice quantity is billable blocks of overage. With 10,000 included, overage priced per 1,000 and 12,500 consumed, the dashboard says 12,500 and the invoice says 3, and both are correct.

Can a retried usage request be billed twice?

A retry that reuses the same idempotency key after the first attempt has finished is absorbed: the response carries used: 0 and nothing is recorded or billed. A fresh key per attempt, or a retry sent while the first attempt is still running, can count twice.

Why did last month's usage show up on this month's invoice?

Either it was reported after the period closed, and Stripe refuses usage stamped before the current period, so it is stamped into the open period instead of being dropped. Or a plan change moved already-reported usage onto the next invoice as its own line.

Ship it

Create a project and run this guide against your own workspace.

7-day free trialNo credit card requiredCancel anytime