DEV Community

Cuong Duong
Cuong Duong

Posted on Originally published at duonglabs.com

Two ways usage-based AI billing quietly loses money

If you charge for tokens, two bugs will take real money off you before any customer notices: charging before you know the token count, and charging twice when Stripe retries a webhook. The fixes are small — meter after the stream closes using the provider's own usage numbers, and make the ledger reject duplicates at the database level instead of in your code.

Both bugs are invisible in testing. Streams complete, webhooks arrive once, and the numbers look right. They show up in production, on the requests that get cancelled and the webhooks that get redelivered.

Bug one: metering before the answer exists

The tempting order is to estimate cost up front, deduct, then stream. It's tempting because the balance check is already there and an estimate feels close enough. It isn't: output length varies by an order of magnitude on the same prompt, and the moment you add a model with different pricing, every estimate silently becomes wrong.

Do it the other way around. Check the balance before the call — a cheap gate, not a charge — then stream, then read the usage the provider reports on the final event and meter that.

// gate, don't charge
    if (await balance(customerId) <= 0) return payWall();

    const stream = await client.messages.stream({ ... });
    const final  = await stream.finalMessage();

    const { input_tokens, output_tokens } = final.usage;
    const cost = ceilToCredit(
      input_tokens  * MODEL.inPrice +
      output_tokens * MODEL.outPrice
    );
    await meter.record(customerId, cost);   // after, with real numbers
Enter fullscreen mode Exit fullscreen mode

Round up to your credit unit, and store credits as integers. Floating-point money is a slow leak that only shows up when you reconcile at month end, and a credit worth a tenth of a cent makes rounding up honest rather than greedy.

Two edge cases decide whether this holds: if the client disconnects mid-stream you still owe the provider for the tokens produced, so meter what was generated, not what was delivered; and if the metering call itself fails after a successful response, write the pending charge somewhere durable and retry it, rather than dropping it and calling that customer service.

Bug two: the webhook that arrives twice

Stripe retries a webhook whenever it doesn't get a fast 2xx — a timeout, a deploy, a cold start. If a retry runs your "add credits" handler again, the customer gets a second top-up for one payment. The version of this that hurts is the one nobody reports.

An if (alreadyProcessed) check in application code doesn't fix it, because two retries can run concurrently and both read "no" before either writes. Push the guarantee down to the database: make the payment's own identifier a unique key on the ledger.

-- one row per checkout session, enforced by the database
    CREATE UNIQUE INDEX credit_ledger_session_uniq
      ON credit_ledger (stripe_checkout_session_id);

    -- handler
    try {
      await db.insert(creditLedger).values({ ... });
    } catch (e) {
      if (isUniqueViolation(e)) return res.status(200).end(); // already credited
      throw e;
    }
Enter fullscreen mode Exit fullscreen mode

The duplicate now fails at insert time, under a lock the database is already taking, and you answer 200 so Stripe stops retrying. Same shape works for the reverse direction: one row per metered request, so a retried deduction can't double-charge either.

While you're there, check the concurrency case on spending too. Fire five requests at once against a balance of one and the naive read-then-deduct path lets all five through. A row lock on the balance (SELECT ... FOR UPDATE), or a single in-flight request per user, closes it — worth doing before you have the kind of customer who finds it on purpose.

Where it pays, and where it doesn't

Worth doing

Any product where the marginal cost of a request is real money and users control how much they consume. The two fixes together are an afternoon; one month of a heavy user on estimated billing costs more.

Not worth it

Flat-rate subscriptions with generous limits, or an internal tool. If nobody is billed per token, a token counter is telemetry, not accounting — keep it in your logs and skip the ledger.

The trade-off. Metering after the fact means a user can overshoot a near-zero balance by one request — accepted deliberately: refusing to answer mid-stream is worse product than eating a fraction of a cent. And a unique-key ledger makes replays harmless but not free; you still need the failed-metering retry path, or you've moved the leak instead of closing it.

Both patterns ship in my Next.js SaaS Starter Kit ($149 one-time, my own product) alongside Auth.js and Stripe Billing Meters — but they're a hundred lines each and worth writing yourself if you'd rather.

Top comments (0)