Credits
How QuotaStack stores, grants, debits, and tracks credits using millicredits, credit blocks, and an append-only ledger.
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.
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 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):
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.
| 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:
- Priority ASC — lower priority number burns first.
- Expiry ASC (nulls last) — soonest-expiring blocks burn before never-expiring ones.
- Free before paid — within the same priority and expiry, promotional/compensation/referral/plan_grant blocks burn before topup blocks.
- 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:
- Block A: debit 5,000 mc (fully drained, remaining = 0).
- Block B: debit 3,000 mc (remaining drops from 20,000 to 17,000).
- 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
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 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 and the consumer pipeline computes the cost and debits the appropriate blocks.
Internally, the debit path:
- Serializes concurrent debits for the same customer — no race between two debits.
- Checks that
effective_balance >= cost. - Selects blocks in burn-down order.
- 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
priorityis0, defaultexpires_atisnull(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:
- Creates an
expiryledger entry with a negative delta equal to the remaining amount. - Sets the block’s remaining_amount to zero and marks it as voided.
- 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
The mistakes developers typically make with this concept — and what to do instead.
priority → expiry → free-before-paid → FIFO. A newer promotional block burns before an older wallet block.