---
title: Credits
description: How QuotaStack stores, grants, debits, and tracks credits using millicredits, credit blocks, and an append-only ledger.
order: 1
---

# Credits

Credits are the fundamental unit of value in QuotaStack. Every plan grant, wallet topup, usage charge, and adjustment is expressed in credits. This page covers how credits are stored, how they flow in and out of accounts, and the guarantees the system provides.

> **Mental Model:** Think of credits like **layered deposits in a bank account**. Each deposit (credit block) has its own terms — when it expires, whether it burns first, where it came from. The balance is just the sum of what's left in each deposit.

## Quick Take

- All values stored as **millicredits** (integers, 1 credit = 1,000 mc) — no floating-point math
- Every grant creates a **credit block** with its own priority, expiry, and source
- Blocks burn in deterministic order: **priority → expiry → free-before-paid → FIFO**
- Every mutation writes to an **append-only ledger** — full audit trail, always

## Diagram

A source (plan, topup, or promo) creates a Credit Block, which updates the Account Balance, which is consumed by Usage Events. Every step is recorded in an Append-Only Ledger.

```mermaid
flowchart LR
    S[Source<br/>Plan / Topup / Promo] --> B[Credit Block]
    B --> A[Account Balance]
    A --> U[Usage Events]
    S -.records.-> L[Append-Only Ledger]
    B -.records.-> L
    A -.records.-> L
    U -.records.-> L
```

## Millicredits

All credit values are stored as **millicredits (mc)** -- integers where 1 credit = 1,000 mc.

| Display value | Stored value |
|---|---|
| 1 credit | 1,000 mc |
| 0.5 credits | 500 mc |
| 99 credits | 99,000 mc |
| 250.75 credits | 250,750 mc |

Why integers? Floating-point arithmetic produces rounding errors that compound over millions of transactions. With millicredits, every operation is exact integer math. There is no precision loss, no rounding policy to document, and no edge case where two ledger entries that should cancel out leave a 0.0000001 residual.

Every API field that represents a credit amount -- `balance`, `delta`, `credits`, `estimated_cost`, `remaining_amount` -- is in millicredits.

## Credit accounts

A credit account is the balance record for a single customer within a tenant and environment. It is created automatically on the first grant or topup.

| Field | Type | Description |
|---|---|---|
| `balance` | int64 | Total millicredits currently held across all active blocks. |
| `reserved_balance` | int64 | Millicredits held by active reservations (not yet committed or released). |
| `effective_balance` | int64 | `balance - reserved_balance`. This is the amount actually available for new charges. |
| `lifetime_earned` | int64 | Cumulative millicredits ever granted. Never decreases. |
| `version` | int | Optimistic locking counter. Incremented on every balance mutation. |

The `version` field prevents lost updates. If two concurrent requests read the same version and both try to write, the second write fails with a conflict error and the caller retries. In practice, the debit path is serialized server-side within a single atomic operation, so conflicts are rare.

### Retrieving a balance

Two URL forms — pick the one matching the ID you have (see [Customer identification](/docs/concepts/customer-identification)):

```
GET /v1/customers/{customer_id}/credits
GET /v1/customer-by-external-id/{external_id}/credits
```

```bash
curl https://api.quotastack.io/v1/customer-by-external-id/user_abc/credits \
  -H "X-API-Key: qs_live_..."
```

Response:

```json
{
  "id": "cra_01HXY...",
  "balance": 150000,
  "reserved_balance": 10000,
  "effective_balance": 140000,
  "lifetime_earned": 500000,
  "version": 12
}
```

Add `?include_blocks=true` to include the list of active credit blocks in burn-down order.

## Credit blocks

Every grant of credits -- whether from a plan, a topup, a promotional offer, or a manual adjustment -- creates a **credit block**. A block is a discrete bucket with its own amount, source, priority, and optional expiry.

| Field | Type | Description |
|---|---|---|
| `original_amount` | int64 | Millicredits when the block was created. |
| `remaining_amount` | int64 | Millicredits still available. Decreases as credits are consumed. |
| `source` | string | How the credits originated: `plan_grant`, `topup`, `promotional`, `compensation`, `referral`, `manual`, `trial`. |
| `priority` | int (0-255) | Controls burn order. Lower numbers burn first. `0` = wallet deposits (burn first among same-expiry blocks). `10` = plan credits. |
| `expires_at` | timestamp or null | When the block expires. `null` means never. |
| `metadata` | object | Arbitrary key-value pairs set at grant time. |

Blocks are never edited after creation -- they are only debited (remaining_amount decreases) or expired (remaining_amount goes to zero and the block is voided).

Block IDs are UUID v7 (time-sortable, e.g. `019d8a20-4ff5-7be0-81da-e1454b3d6f64`). There is no dedicated "get block by ID" endpoint — blocks are returned inline from `GET /v1/customers/{id}/credits?include_blocks=true` and from the response of any grant or adjust call. For block-specific operations (refund a purchase, etc.), attach `external_payment_id` or a purchase ID to the block's `metadata` at grant time and filter on that when listing.

## Burn-down order

When credits are debited, QuotaStack selects blocks in a deterministic order:

1. **Priority ASC** -- lower priority number burns first.
2. **Expiry ASC (nulls last)** -- soonest-expiring blocks burn before never-expiring ones.
3. **Free before paid** -- within the same priority and expiry, promotional/compensation/referral/plan_grant blocks burn before topup blocks.
4. **Created ASC** -- oldest blocks burn first (FIFO tiebreaker).

### Example

A customer has three active blocks:

| Block | Remaining | Priority | Expires | Source |
|---|---|---|---|---|
| A | 5,000 mc | 0 | 2025-02-01 | promotional |
| B | 20,000 mc | 0 | null | topup |
| C | 10,000 mc | 10 | 2025-03-01 | plan_grant |

Burn order: **A** (priority 0, has expiry) then **B** (priority 0, no expiry, but topup source) then **C** (priority 10).

If the customer is charged 8,000 mc:

1. Block A: debit 5,000 mc (fully drained, remaining = 0).
2. Block B: debit 3,000 mc (remaining drops from 20,000 to 17,000).
3. Block C: untouched.

The expiring promotional credits are consumed first, preserving the customer's paid wallet balance as long as possible.

## Ledger entries

Every credit mutation creates one or more **ledger entries** -- append-only rows that are never updated or deleted. They form a complete, auditable history of every credit movement.

| Field | Type | Description |
|---|---|---|
| `delta` | int64 | Millicredits added (positive) or removed (negative). |
| `type` | string | The kind of mutation (see table below). |
| `source` | string or null | Credit source for grants; null for debits. |
| `credit_block_id` | string or null | The block affected. |
| `billable_metric_key` | string or null | The metric that triggered a consumption entry. |
| `idempotency_key` | string | Ensures the same logical event produces at most one entry. |
| `reference_id` | string or null | Links to a reservation or other external reference. |

### Ledger entry types

| Type | Delta | When created |
|---|---|---|
| `plan_grant` | + | Plan subscription grants credits. |
| `topup` | + | Customer tops up their wallet or purchases a pack. |
| `consumption` | - | Usage event debits credits. |
| `reservation` | - | Credits are held for a pending operation. |
| `release` | + | A reservation is released (cancelled or excess returned). |
| `expiry` | - | A block's remaining credits expire. |
| `adjustment` | +/- | Manual or programmatic credit adjustment. |

### The balance invariant

At all times, the following holds:

```
account.balance = SUM(blocks.remaining_amount) = SUM(ledger.delta)
```

If you sum every ledger entry's delta for a customer, you get their current balance. If you sum every active block's remaining_amount, you get the same number. This triple-equality is the system's fundamental consistency check.

## Operations

### Grant

Add credits to a customer's account. Creates a new block and a ledger entry. Two URL forms:

```
POST /v1/customers/{customer_id}/credits/grant
POST /v1/customer-by-external-id/{external_id}/credits/grant
```

```bash
curl -X POST https://api.quotastack.io/v1/customer-by-external-id/user_abc/credits/grant \
  -H "X-API-Key: qs_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: grant-welcome-user_abc" \
  -d '{
    "credits": 5000,
    "source": "promotional",
    "reason": "Welcome bonus",
    "priority": 0,
    "expires_at": "2025-04-01T00:00:00Z"
  }'
```

| Field | Required | Description |
|---|---|---|
| `credits` | yes | Millicredits to grant. Must be positive. |
| `source` | yes | One of `promotional`, `compensation`, `referral`, `manual`. For topup grants, use the [topup/grant endpoint](/docs/concepts/topups-and-wallets) instead. |
| `reason` | yes | Human-readable explanation. Surfaces in the ledger entry's metadata and the admin dashboard. No enforced max length — keep under 1 KB for readability. |
| `priority` | no | Burn-order priority 0–255. Default `0`. |
| `expires_at` | no | ISO 8601 timestamp. Default `null` (never expires). |
| `metadata` | no | Arbitrary key-value pairs. |

### Debit

Debiting happens automatically when a usage event is processed. You do not call a debit endpoint directly -- instead, you [record a usage event](/docs/concepts/metering) and the consumer pipeline computes the cost and debits the appropriate blocks.

Internally, the debit path:

1. Serializes concurrent debits for the same customer — no race between two debits.
2. Checks that `effective_balance >= cost`.
3. Selects blocks in burn-down order.
4. Writes debit entries to the ledger, decrements each block's remaining_amount, and updates the account balance — all applied atomically.

### Adjust

Add or remove credits outside the normal grant/usage flow.

- **Positive delta** creates a new block (like a grant). Default `priority` is `0`, default `expires_at` is `null` (never expires).
- **Negative delta** debits from existing blocks in the standard [burn-down order](#burn-down-order) (priority → expiry → free-before-paid → FIFO).

Unlike usage events, `adjust` **never** pushes a customer's balance negative. If the requested negative delta exceeds balance, the call returns `409 Conflict` — even when the tenant's `overage_policy` is `allow`. Overage policy only applies to consumption (usage events); manual credit operations are strict.

```
POST /v1/customers/{customer_id}/credits/adjust
POST /v1/customer-by-external-id/{external_id}/credits/adjust
```

```bash
curl -X POST https://api.quotastack.io/v1/customer-by-external-id/user_abc/credits/adjust \
  -H "X-API-Key: qs_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: adjust-refund-order-123" \
  -d '{
    "delta": 10000,
    "source": "compensation",
    "reason": "Refund for failed generation"
  }'
```

## Block expiry

Blocks with an `expires_at` timestamp are automatically swept by a background scheduler. The sweep runs periodically, finds blocks where `expires_at` is in the past and `remaining_amount > 0`, and for each:

1. Creates an `expiry` ledger entry with a negative delta equal to the remaining amount.
2. Sets the block's remaining_amount to zero and marks it as voided.
3. Updates the account balance.

The expiry sweep also refreshes the customer's entitlement summary, so subsequent entitlement checks immediately reflect the reduced balance.

## Ledger history

Retrieve the full audit trail for a customer:

```
GET /v1/customers/{customer_id}/credits/history
GET /v1/customer-by-external-id/{external_id}/credits/history
```

Supports filters:

| Parameter | Description |
|---|---|
| `type` | Filter by entry type (`consumption`, `topup`, `plan_grant`, etc.). |
| `source` | Filter by credit source. |
| `billable_metric_key` | Filter by the metric that triggered the entry. |
| `from` / `to` | ISO 8601 date range. |

Results are cursor-paginated. Pass `cursor` and `limit` (max 100) for pagination.

## Common Mistakes

**✗ Don't store credit amounts as floats in your database**

Floating-point math silently corrupts balances over millions of transactions. Use integer millicredits everywhere — QuotaStack's API already does.

**✗ Don't try to modify credit blocks directly**

Blocks are append-only. Use grant, debit (via usage events), or adjust endpoints — the ledger invariant depends on never editing existing blocks.

**✗ Don't assume burn order is FIFO**

It's `priority → expiry → free-before-paid → FIFO`. A newer promotional block burns before an older wallet block.
