10 min read

How to Build an AI Billing Ledger

How to build an AI billing ledger
On this page

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: in 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:

Request
// 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,
};

const bulkResponse = 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,
    },
  ],
});
200 OK
{
  "batch_id": "bulk_c62f200b-905f-4983-a349-cadd279234aa",
  "status": "applied",
  "transaction_count": 2
}
  • 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 batch_id identifies the whole batch. Save it: it is how you retrieve, refund, or void every transaction in the batch later.

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 models being priced at sub cent values (e.g. $0.0003412 per 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-cent units, i.e. up to 8 -10 decimal places. Your ledger should neither assume or approximate.

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:

Claude Sonnet drove most of Mike's August usage

Aug 1–31 · 2.75M tokens across both models

Claude Sonnet GPT-5
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

Of course, prices might change at some point. Your provider reprices a model, or you adjust your margin. New tasks must charge the new rate, and everything already recorded keeps the old one. To handle this, use a separate append-only prices table. A new price gets a new row and a new version. Never update old rows. Every price that ever applied stays in the table, and any charge can be traced back to the exact version that computed it.

Insert a new price
// Claude Sonnet's price goes up: insert a new row, leave the old one alone
await db.prices.create({
  data: {
    model: "claude-sonnet",
    rate: 700n, // $0.000007 per token at 1e8 precision
    version: "v5",
  },
});

When a task runs, the code simply takes the latest version for that model. Nothing else in the pipeline changes.

Latest price version
// Latest row wins: the newest version for this model
const price = await db.prices.findFirst({
  where: { model: "claude-sonnet" },
  orderBy: { version: "desc" },
});

// 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,
};

Old charges keep the version that computed them, new tasks pick up the new version, and history is never rewritten.

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",
  },
});

// 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 batch, you don't reverse anything yourself. You pass the batch_id you saved in Rule 1 to the refund endpoint:

Refund a batch
// The batch_id you saved when the charge was recorded
await blnk.Transactions.refund(batchId, {
  description: "Refund for the model task",
  meta_data: { reason: "service_credit" },
});

Blnk uses the batch 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

An invoice should not be the first place an AI charge becomes clear. Before it is created, your system should already know which task produced the charge, which model handled it, which price applied, and how much allowance the customer has left.

That is what the three rules give you: record tokens and dollars together, keep the cost at full precision, and attach attribution to every transaction. The result is one set of records that can power the customer dashboard, calculate overage, support refunds, and show your margin per model.

Stripe can collect the money and send the invoice. Blnk Core can maintain the ledger that determines what belongs on it, with each task recorded as an atomic pair of transactions. By the time the invoice is created, every amount is already calculated, attributed, and ready to explain.