10 min read

How to Build an AI Billing Ledger

Your model provider reports usage. Your billing platform sends the invoice. This article shows how to connect the two so every change to a customer's allowance can be accurately priced and explained.

How to build an AI billing ledger

One of the hardest problems when building billing for an AI product is that the two systems you depend on don't speak the same language. Your model providers meter usage in tokens, per customer, per request. Your billing platform, e.g. Stripe, collects payments in dollars, per customer, per month.

The problem is what happens in the middle. This is where tokens become money: your price per model, included allowance, promotions, paid top-ups, overage, plus a breakdown you can show every customer at the end of the month. The record that does this is a ledger.

And because AI pricing keeps changing, it can't be a rigid one. You need a ledger flexible enough to model whatever pricing you ship next, not just what a billing tool supports out of the box. If the ledger can't bend, your pricing can't either.

In this article, you’ll learn how to build a ledger for your AI billing system. We’ll use Blnk Core to illustrate the code implementation, but the same design decisions apply to whatever stack you choose.

Rule 1: Record both token usage and dollar cost

Every model task has two sides: the tokens consumed and the dollar cost they represent. The model provider only gives you one half of the equation: the raw token count. It's on you to convert that into actual spend, track it across multiple models with different pricing tiers, and ensure every customer is billed precisely for what they used.

The biggest risk with this is overcharging customers for tokens they never consumed, or leaving money on the table by forgetting to bill for usage you already delivered.

To solve for this, every model interaction, your ledger must capture both model consumption (tokens) and its financial equivalent (cost) atomically once interaction is complete. One must not exist without the other. This way, logically, for every token spent, you can immediately trace its equivalent cost in your ledger.

Simply put:

  1. User completes a model task. Model provider returns the total tokens spent, i.e. inputTokens + outputTokens.
  2. You retrieve your rate per model. Rate is model cost per token + your margin.
  3. Cost is how much you charge the customer, not how much the model provider charges you, i.e. cost = rate x quantity.

In practice, Blnk lets you model an atomic transaction in your ledger using its bulk transaction object:

Atomic usage + cost
// The model task gets an ID when it starts
const billingEventId = modelTaskId;

// When the response comes back, total the tokens used
const { input_tokens, output_tokens } = providerResponse.usage;
const totalTokens = input_tokens + output_tokens;

// Your rate for this model: provider cost + your margin,
// in 1e8-ths of a dollar per token
const rate = 600n; // $0.000006 per token

// cost = total tokens x rate, computed as integers
const preciseCost = BigInt(totalTokens) * rate;

const requestMetadata = {
  task_id: modelTaskId,
  customer_id: customerId,
  provider: "anthropic",
  provider_request_id: providerResponse.id,
  model: "claude-sonnet",
  input_tokens,
  output_tokens,
  total_tokens: totalTokens,
};

await blnk.Transactions.createBulk({
  atomic: true,
  transactions: [
    {
      precise_amount: totalTokens,
      precision: 1,
      currency: "TOKENS",
      reference: `${billingEventId}:tokens`,
      source: "@ModelUsage",
      destination: customerTOKENS,
      allow_overdraft: true,
      meta_data: requestMetadata,
    },
    {
      precise_amount: preciseCost,
      precision: 100000000,
      currency: "USD",
      reference: `${billingEventId}:usd`,
      source: customerUSD,
      destination: "@AI-Revenue",
      allow_overdraft: true,
      meta_data: requestMetadata,
    },
  ],
});
  • atomic: true makes sure both legs succeed or fail together.
  • allow_overdraft: true on the USD leg is where you choose what happens when a customer’s USD balance runs out of money.

The rejection only comes after the provider has processed the request, so the tokens are spent either way. Once the balance is less than 0, you can prevent the customer from processing any subsequent requests until they opt in for usage based, or upgrade their account.

Rule 2: Never approximate the cost in your ledger

In most cases, customers do not measure their usage in tokens. However, with prices being 0.0003412/token, storing USD amounts as 2 decimal points is how you lose money. 1,000 model tasks at $0.001 per task rounds up to $0.00, and your customer’s balance remain untouched. If the tasks grow to 1 million, you’d have lost $1,000.00 and nowhere to show for it.

To solve this, record all USD amounts in your ledger in sub-dollar units, i.e. up to 8 -10 decimal places. Then charge overages on Stripe or other payment providers by rounding up the amount to the nearest 2 decimal places.

For example, if we decide to track up to 8 decimal places:

RatePrecise rateTokensLedger amount (sub USD)USD on Stripe
0.00002362360240,000566,400,000 ($5.664)$5.66
0.00001347134710,009,23113,482,434,157 ($134.82434157)$134.82
0.00008989009,341,19083,136,591,000 ($831.36591)$831.37

In practice, rate gets the same treatment first. Define it in dollars, convert it to your ledger's precision once, and every computation after that is integer math. Blnk sets its precision in multiples of 10, so a precision of 8 decimal places is 10^8.

Integer rate math
// 1. Define the rate for this model: provider cost + your margin
const ratePerToken = 0.000006; // dollars per token

// 2. Convert it to your ledger's precision (1e8 for USD)
const preciseRate = BigInt(Math.round(ratePerToken * 100_000_000)); // 600

// 3. Apply it to the total tokens from the model response
const preciseCost = BigInt(totalTokens) * preciseRate;
// 24,000 tokens x 600 = 14400000, which is $0.144 exactly

// 4. Record it in the ledger
await blnk.Transactions.create({
  precise_amount: preciseCost,
  precision: 100000000,
  currency: "USD",
  // ...
});

When displaying the amount in your product, you can convert it back to sub-dollar or USD amounts by dividing by the precision or rounding it up to 2 decimal places.

Blnk Cloud balance for Michael Carter in the Customer Usage Ledger, showing a USD current balance of -4.20500000 and Applied AIRevenue debit transactions.
Michael Carter’s included-usage USD balance in Blnk Cloud, stored at 8 decimal places.

Rule 3: Stamp attribution per transaction created

The totals so far answer one question: how much did this customer spend for this task? The harder question is what they spent it on: which model drove this particular spend, or which feature. By the time you need that answer, the requests are long gone, and your records are the only place it can come from.

While a request is running, your system knows which model is handling it. After it finishes, that answer survives in exactly one place: the transaction records. If the model name never makes it into the record, there is nothing to query at month end, and the breakdown by model is impossible.

So decide two things before you start recording. First, will customers see dollars, tokens, or both? Second, which cuts do they need: per model, per provider, per feature, per billing period?

Whatever you choose, stamp those values on every transaction. For example, if Mike’s month mixes Claude Sonnet and GPT-5 requests. The end-of-period job should simply fetch the month’s transactions and group them by model.

Using Blnk's filter API, it looks like this:

Usage by model
const byModel = new Map<string, { tokens: bigint; usd: bigint }>();
const limit = 100;
let offset = 0;

while (true) {
  // The filter API queries the database directly, 100 records at a time
  const txns = await blnk.Search.filter(
    {
      filters: [
        { field: "source", operator: "eq", value: customerUSD },
        { field: "currency", operator: "eq", value: "USD" },
        { field: "status", operator: "eq", value: "APPLIED" },
        { field: "created_at", operator: "gte", value: "2026-08-01T00:00:00Z" },
        { field: "created_at", operator: "lt", value: "2026-09-01T00:00:00Z" },
      ],
      limit,
      offset,
    },
    "transactions",
  );

  for (const txn of txns) {
    const meta = txn.meta_data;
    const entry = byModel.get(meta.model) ?? { tokens: 0n, usd: 0n };
    entry.usd += BigInt(txn.precise_amount); // stay in integers
    entry.tokens += BigInt(meta.total_tokens);
    byModel.set(meta.model, entry);
  }

  if (txns.length < limit) break;
  offset += limit;
}

The result is the breakdown Mike sees:

Mike's usage by model
August 2026 · tokens and dollar cost
Claude Sonnet
$8.10
2,140,000 tokens
GPT-5
$3.48
610,000 tokens
Total 2,750,000 tokens · $11.58
ModelTokensCost
Claude Sonnet2,140,000$8.10
GPT-5610,000$3.48
Total2,750,000$11.58

The total is the same $11.58; the split changes how it's presented, not what it adds up to. Every sum stays an integer until it's displayed. Without the model label on each event, you could only tell Mike “you spent $11.58” and stop there.

This code guesses nothing. It reads the model labels written on the events and sums them. You can make it faster however you like: paginate the reads, precompute the sums, cache the results. But no version of it can produce a label that was never written.

What could go wrong in production

Billing never stays static. Sooner or later, production forces a few changes on it: price changes, new models launch, refunds, etc. If the design is right, none of them should be a redesign. The three rules above keep each one as a routine operation on your ledger.

Price changes

A new price simply means you change the rate, whether you are increasing the included usage or the price itself. Your ledger design remains untouched.

A recommended pattern is to keep track of your price versions in a separate small table, then note what price version was used for a particular task. Old charges keep the version that computed them, new requests pick up the new version, and history is never rewritten.

Price version metadata
// Fetch the active price version from your prices table
const price = await db.prices.findFirst({
  where: { model: "claude-sonnet", active: true },
});

// Stamp it on the transaction like every other field
const requestMetadata = {
  task_id: modelTaskId,
  customer_id: customerId,
  model: "claude-sonnet",
  price_version: price.version, // the version active when this task ran
  input_tokens,
  output_tokens,
  total_tokens: totalTokens,
};

Adding a new model

Say a new model launches and you want to include it. You simply add one rate entry for it, converted to your ledger's precision like every other rate, and its name starts getting stamped in the metadata of new transactions.

Add a model rate
// The only new code: one entry in your prices table
await db.prices.create({
  data: {
    model: "gpt-5-mini",
    rate: 340n, // $0.0000034 per token at 1e8 precision
    version: "v4",
    active: true,
  },
});

// Tasks on it record exactly like any other model:
// the rate comes from the prices table, the model name goes in the metadata

Nothing else about the pipeline changes: the bulk write records its usage the same way, and the month-end breakdown picks it up as a new row next to Claude Sonnet and GPT-5. Dropping one changes nothing for the past: old events keep the model and price that applied when they ran.

Handling refunds

A refund or service credit is a reversing transaction: a new event that moves dollars back into the allowance, linked to the original event ID with the reason in its metadata. Never edit the original charge.

Because the original charge is two legs under one bulk ID, you don't reverse anything yourself. You call the refund endpoint with the bulk ID:

Refund a bulk charge
// The bulk ID returned when the charge was recorded
await blnk.Transactions.refund(bulkId, {
  description: "Refund for the model task",
  meta_data: { reason: "service_credit" },
});

Blnk uses the bulk ID to link both legs, so one call refunds the whole event: the dollars return from @AI-Revenue to the customer's USD balance, and the token usage reverses from the customer's token balance back to @ModelUsage.

Conclusion

At this point you might ask why you're building any of this yourself when Stripe can meter usage. It can, and it supports decimal pricing up to twelve places, so sub-cent precision alone is not the reason. The reason is what your ledger can tell you before any invoice exists: how much allowance Mike has left after any request, what price each request was charged at, and how to undo a charge without editing the past. Stripe remains where money is collected and invoices are sent. Your ledger decides what those invoices contain.

The rules stay the same whatever you build on: record both sides of every task atomically, never approximate the cost, and stamp attribution on every transaction. Get those right and the questions that used to be support escalations become reads: what has Mike spent, on which models, and how much allowance is left. The same records drive the customer dashboard, the overage invoices, and your own model-cost margins.

From here you can build whatever your pricing needs next: spend caps that stop requests at zero, alerts when a customer's burn rate spikes, margin reports per model, or finance-ready exports at month end. The fastest way to run this design is Blnk Core: record each task as an atomic pair of legs, and let the ledger keep the precision, idempotency, and history for you.