Recipes

Prepaid AI credits and holds

Sell prepaid credit for AI usage: a credit unit, credit packs and plan credits, an access check before each call, and holds that stop parallel calls from spending the same credit.

This recipe continues Bill AI tokens. There, usage bills on the invoice at the end of the period. Here, the customer pays first: they buy credit, your app checks the credit before a model call, and the usage that the credit covers costs them nothing more on the invoice.

How credit works

A credit grant gives one customer an amount of credit. The credit is money, or an amount of your own credit unit, for example 1 credit = USD 0.01.

PromotionalPaid
The customer paysNothingAn invoice for the credit
StartsAt onceWhen the invoice is paid
Cost basis0The net price on the invoice
AppliesBefore taxBefore tax if taxed at purchase, after tax if taxed at use
Ends earlyVoidA credit note on its invoice

When an invoice is finalised, grants pay its usage lines in this order: restored credit first, then the lowest priority, then the soonest expiry, promotional before paid, then the oldest. Usage that no grant covers bills as usual.

Create a credit unit

Skip this step to sell credit in money. A unit lets you show "1,000 credits" instead of "USD 10.00". minor_per_unit is in minor units, so "1" is one cent:

curl
curl -X POST $TMBILLING_URL/v1/businesses/$BIZ/credit_units \
  -H "Authorization: Bearer $TMBILLING_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "credit",
    "name": "credit",
    "plural_name": "credits",
    "currency": "USD",
    "minor_per_unit": "1",
    "decimals": 0
  }'

The first grant in the unit fixes currency, minor_per_unit and decimals. After that you can change only the names. In the dashboard: Settings → Credit units.

Sell credit packs

A credit pack is a one-time price with grants_credit. The buyer pays through checkout, a payment link, the portal's Buy credits list or an invoice that you create. Each pack line on a paid invoice gives one paid grant:

curl
curl -X POST $TMBILLING_URL/v1/businesses/$BIZ/prices \
  -H "Authorization: Bearer $TMBILLING_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "product": "ai-credits",
    "name": "1,000 credits",
    "type": "one_time",
    "model": "flat",
    "currency": "USD",
    "amount_minor": 900,
    "grants_credit": {
      "kind": "pack",
      "unit": "credit",
      "amount": 1000,
      "category": "paid",
      "expires_after": { "days": 365 },
      "tax_treatment": "taxed_at_purchase"
    }
  }'

This pack sells 1,000 credits (face value USD 10.00) for USD 9.00. The cost basis is USD 9.00, and USD 1.00 is a discount that the ledger records when the credit is used.

tax_treatment decides when tax is charged:

  • taxed_at_purchase: the pack invoice is taxed. Usage that the credit pays has no tax.
  • taxed_at_use: the pack invoice has no tax. Usage is taxed, and the credit pays the tax too. Only for credit in money.

To set a default for all packs, set credit_config.default_tax_treatment on the business.

Include credits in a plan

On a recurring price, grants_credit with kind: "per_cycle" gives promotional credit each billing period. rollover decides what happens to credit that is not used:

"grants_credit": {
  "kind": "per_cycle",
  "unit": "credit",
  "amount": 5000,
  "rollover": { "mode": "carry", "percent": 50, "cap": 2000, "expire_after_cycles": 1 }
}

At each renewal, half of the unused credit (at most 2,000) moves to a new grant that lasts one more period. Carried credit does not move a second time. "mode": "none" lets unused credit expire, and "expire_after_cycles": null keeps carried credit until it is used.

Give credit by hand

Promotional credit, for example a welcome bonus:

curl
curl -X POST $TMBILLING_URL/v1/businesses/$BIZ/customers/$CUS/credit_grants \
  -H "Authorization: Bearer $TMBILLING_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "category": "promotional", "unit": "credit", "amount": 500, "expires_at": "2027-01-01T00:00:00Z" }'

Paid credit always has an invoice. Send category: "paid", the net price_minor and a tax_treatment. We create the invoice, and the grant starts when the invoice is paid. If you already have the money, add mark_paid and the grant is active in the response:

{
  "category": "paid",
  "unit": "credit",
  "amount": 1000,
  "price_minor": 900,
  "tax_treatment": "taxed_at_purchase",
  "mark_paid": { "method": "bank_transfer", "reference": "Transfer 0042" }
}

A paid grant needs an API key with credits:manage and invoices:manage.

Check before each model call

Ask the access check with the meter and an estimate of the cost in minor units. It writes nothing:

curl
curl "$TMBILLING_URL/v1/businesses/$BIZ/customers/$CUS/access?meter=ai_tokens&estimate=25" \
  -H "Authorization: Bearer $TMBILLING_API_KEY"
{
  "object": "access_check",
  "allowed": true,
  "code": "ok",
  "currency": "USD",
  "available_minor": 1240,
  "available_units": [{ "unit": "credit", "units": 1240 }],
  "balance": {
    "grants_minor": 1500,
    "unbilled_usage_minor": 210,
    "open_holds_minor": 50,
    "overdraft_limit_minor": 0
  },
  "on_exhausted": { "policy": "block", "source": "business_default" }
}

The answer is always 200. Read allowed, and code when it is false:

codeMeans
okGo ahead.
no_subscriptionNo live subscription bills this meter.
not_entitledThe plan does not include the feature, or its limit is used up.
spend_limit_reachedA spend limit stops more usage. limits names it and when it resets. See Spend limits and automatic top-up.
in_flight_limitOpen holds already use the permitted part of the balance.
insufficient_creditThe customer's policy blocks, and the credit is used up.

If your app knows the customer only by a usage key, call GET /v1/businesses/$BIZ/access?customer_key=workspace-4821&meter=ai_tokens.

95% of access checks answer in less than 100 ms, measured at our server. Network time is not included.

When the access check blocks

Blocking is off until you turn it on. With it off, insufficient_credit never occurs and all usage bills on the invoice. Turn it on with credit_config.blocking_enabled on the business (in the dashboard: Settings → Credits). Then the first of these sets the policy:

  1. an override on the customer (credit_settings.on_exhausted), until its override_expires_at;
  2. a live grant for the meter with on_exhausted: "block";
  3. the on_exhausted of the plan price's grants_credit;
  4. the business default, credit_config.on_exhausted_default.

An overdraft limit lets the balance go below zero before the block, for example USD 2.00 for a trusted customer.

Hold, then capture or release

Many calls can run at the same time. A check alone lets two calls see the same credit. A hold reserves the estimate first:

curl
curl -X POST $TMBILLING_URL/v1/businesses/$BIZ/customers/$CUS/credit_holds \
  -H "Authorization: Bearer $TMBILLING_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "amount_minor": 25, "meter": "ai_tokens", "expires_in": 120, "scope": { "workspace": "workspace-4821" } }'

A refused hold answers 402 with code: "in_flight_limit" or "insufficient_credit". Do not make the model call.

After the call, capture the hold with the usage event. We record the event and close the hold in one step, so the balance never counts both or neither:

curl
curl -X POST $TMBILLING_URL/v1/businesses/$BIZ/credit_holds/$HOLD/capture \
  -H "Authorization: Bearer $TMBILLING_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "usage_event": {
      "key": "msg_XXXXXXXXXXXX01:output",
      "customer": "workspace-4821",
      "event_name": "ai_tokens",
      "properties": { "model": "small-v1", "token_type": "output", "tokens": 1200 }
    }
  }'

If the call fails, release the hold with POST …/credit_holds/$HOLD/release. A hold that you do not capture or release expires after expires_in seconds (default 60, at most 86,400).

Usage above the hold is accepted, because the tokens were used. If it takes the balance past the overdraft limit, the hold has overdraft: true and the rest bills on the next invoice.

Hold events (credit_hold_created, credit_hold_captured, …) go only to webhook endpoints that list them by name. An endpoint that receives all events does not get them, so a busy app does not send one webhook for each model call.

See every movement

GET /v1/businesses/$BIZ/customers/$CUS/credit_transactions lists each grant, draw, expiry, void, restore and rollover, newest first. Filter by grant, kind, category, unit, invoice and created[gte] / created[lt]. On an invoice, grant_credits_pre_tax_minor and grant_credits_post_tax_minor show what the grants paid. In the dashboard, open the customer and select View credits.

Next

To limit spend, warn the customer before a limit, and buy credit for them before it runs out, see Spend limits and automatic top-up.

Copyright © 2026