Docs / Concepts / Credits

Credits

How QuotaStack stores, grants, debits, and tracks credits using millicredits, credit blocks, and an append-only ledger.

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
SOURCE Plan / Topup / Promo CREATES Credit Block UPDATES Account Balance CONSUMED BY Usage Events EVERY STEP RECORDS TO Append-Only Ledger

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.

Millicredits

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

Display valueStored value
1 credit1,000 mc
0.5 credits500 mc
99 credits99,000 mc
250.75 credits250,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.

FieldTypeDescription
balanceint64Total millicredits currently held across all active blocks.
reserved_balanceint64Millicredits held by active reservations (not yet committed or released).
effective_balanceint64balance - reserved_balance. This is the amount actually available for new charges.
lifetime_earnedint64Cumulative millicredits ever granted. Never decreases.
versionintOptimistic 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):

GET /v1/customers/{customer_id}/credits
GET /v1/customer-by-external-id/{external_id}/credits
curl https://api.quotastack.io/v1/customer-by-external-id/user_abc/credits \
  -H "X-API-Key: qs_live_..."

Response:

{
  "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.

FieldTypeDescription
original_amountint64Millicredits when the block was created.
remaining_amountint64Millicredits still available. Decreases as credits are consumed.
sourcestringHow the credits originated: plan_grant, topup, promotional, compensation, referral, manual, trial.
priorityint (0-255)Controls burn order. Lower numbers burn first. 0 = wallet deposits (burn first among same-expiry blocks). 10 = plan credits.
expires_attimestamp or nullWhen the block expires. null means never.
metadataobjectArbitrary 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:

BlockRemainingPriorityExpiresSource
A5,000 mc02025-02-01promotional
B20,000 mc0nulltopup
C10,000 mc102025-03-01plan_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.

FieldTypeDescription
deltaint64Millicredits added (positive) or removed (negative).
typestringThe kind of mutation (see table below).
sourcestring or nullCredit source for grants; null for debits.
credit_block_idstring or nullThe block affected.
billable_metric_keystring or nullThe metric that triggered a consumption entry.
idempotency_keystringEnsures the same logical event produces at most one entry.
reference_idstring or nullLinks to a reservation or other external reference.

Ledger entry types

TypeDeltaWhen 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
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"
  }'
FieldRequiredDescription
creditsyesMillicredits to grant. Must be positive.
sourceyesOne of promotional, compensation, referral, manual. For topup grants, use the topup/grant endpoint instead.
reasonyesHuman-readable explanation. Surfaces in the ledger entry’s metadata and the admin dashboard. No enforced max length — keep under 1 KB for readability.
prioritynoBurn-order priority 0–255. Default 0.
expires_atnoISO 8601 timestamp. Default null (never expires).
metadatanoArbitrary 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 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 (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
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:

ParameterDescription
typeFilter by entry type (consumption, topup, plan_grant, etc.).
sourceFilter by credit source.
billable_metric_keyFilter by the metric that triggered the entry.
from / toISO 8601 date range.

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

Common Mistakes

The mistakes developers typically make with this concept — and what to do instead.

×
Don't store credit amounts as floats in your database
Why
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
Why
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
Why
It's priority → expiry → free-before-paid → FIFO. A newer promotional block burns before an older wallet block.
🤖
Building with an AI agent?
Get this page as markdown: /docs/concepts/credits.md · Full index: /llms.txt