---
title: "Prepaid AI credits and holds"
description: "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."
canonical_url: "https://twominutebilling.com/help/developers/recipes/prepaid-ai-credits"
---
# 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](https://twominutebilling.com/help/developers/recipes/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.

<table>
<thead>
  <tr>
    <th>
      
    </th>
    
    <th>
      Promotional
    </th>
    
    <th>
      Paid
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      The customer pays
    </td>
    
    <td>
      Nothing
    </td>
    
    <td>
      An invoice for the credit
    </td>
  </tr>
  
  <tr>
    <td>
      Starts
    </td>
    
    <td>
      At once
    </td>
    
    <td>
      When the invoice is paid
    </td>
  </tr>
  
  <tr>
    <td>
      Cost basis
    </td>
    
    <td>
      0
    </td>
    
    <td>
      The net price on the invoice
    </td>
  </tr>
  
  <tr>
    <td>
      Applies
    </td>
    
    <td>
      Before tax
    </td>
    
    <td>
      Before tax if taxed at purchase, after tax if taxed at use
    </td>
  </tr>
  
  <tr>
    <td>
      Ends early
    </td>
    
    <td>
      Void
    </td>
    
    <td>
      A credit note on its invoice
    </td>
  </tr>
</tbody>
</table>

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:

```bash [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:

```bash [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:

```json
"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:

```bash [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:

```json
{
  "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:

```bash [curl]
curl "$TMBILLING_URL/v1/businesses/$BIZ/customers/$CUS/access?meter=ai_tokens&estimate=25" \
  -H "Authorization: Bearer $TMBILLING_API_KEY"
```

```json
{
  "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:

<table>
<thead>
  <tr>
    <th>
      <code>
        code
      </code>
    </th>
    
    <th>
      Means
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        ok
      </code>
    </td>
    
    <td>
      Go ahead.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        no_subscription
      </code>
    </td>
    
    <td>
      No live subscription bills this meter.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        not_entitled
      </code>
    </td>
    
    <td>
      The plan does not include the feature, or its limit is used up.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        spend_limit_reached
      </code>
    </td>
    
    <td>
      A spend limit stops more usage. <code>
        limits
      </code>
      
       names it and when it resets. See <a href="https://twominutebilling.com/help/developers/recipes/spend-limits-and-auto-top-up">
        Spend limits and automatic top-up
      </a>
      
      .
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        in_flight_limit
      </code>
    </td>
    
    <td>
      Open holds already use the permitted part of the balance.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        insufficient_credit
      </code>
    </td>
    
    <td>
      The customer's policy blocks, and the credit is used up.
    </td>
  </tr>
</tbody>
</table>

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:

```bash [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:

```bash [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.

<note>

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.

</note>

## 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](https://twominutebilling.com/help/developers/recipes/spend-limits-and-auto-top-up).


## Sitemap

See the full [sitemap](https://twominutebilling.com/sitemap.md) for all pages.
