Stacking Multiple Credit Packs
How credit blocks from multiple purchases coexist and burn in deterministic priority order — no merging, no special handling.
Buy a second pack while the first is still active and QuotaStack doesn't merge them — it just keeps two blocks and burns them down in priority + expiry order. For simple cases you write zero extra code. For plan stacking with queued activation, you compute the expiry client-side.
existing_block.expires_at + duration client-sideexpires_at to show "expires next" and "expires after that"Stacking Multiple Credit Packs
When a user buys a new credit pack while an existing one is still active, QuotaStack doesn’t merge them. Each purchase creates an independent credit block. The burn-down engine handles the rest: smallest priority number first, then soonest expiry, then oldest.
No special handling needed for simple cases. For plan stacking with queued activation, you compute the expiry client-side.
How burn-down ordering works
QuotaStack burns credit blocks in this order:
- Lowest priority number first (for example, priority 0 before priority 1)
- Soonest expiry first (Apr 18 before May 11, both before “never”)
- Oldest first (created_at tiebreaker)
This means time-limited credits always burn before permanent ones, and credits expiring sooner burn before those expiring later. Users never lose credits unnecessarily.
Example: three stacked blocks
A user has these credit blocks:
| Block | Amount | Priority | Expires | Source |
|---|---|---|---|---|
| Free signup bonus | 3,000 mc (3 looks) | 0 | never | signup grant |
| Weekly pack | 24,000 mc (24 looks) | 0 | Apr 18 | pack purchase |
| Monthly pack | 100,000 mc (100 looks) | 0 | May 11 | pack purchase |
Burn order: Weekly pack (priority 0, soonest expiry) then monthly pack (priority 0, later expiry) then free bonus (priority 0, no expiry).
The free bonus sits at the bottom as a permanent fallback. The weekly pack burns first because it expires soonest. The monthly pack burns next. The user sees a combined balance of 127,000 mc.
Checking the block breakdown
curl -s \
-H "X-API-Key: $API_KEY" \
"https://api.quotastack.io/v1/customers/$CUSTOMER_ID/credits?include_blocks=true"
Response:
{
"customer_id": "019d6258-07ba-7418-83be-58f5fde53e4e",
"external_customer_id": "user42:companion7",
"balance": 127000,
"reserved_balance": 0,
"pending_balance": 0,
"effective_balance": 127000,
"blocks": [
{
"id": "blk_free",
"original_amount": 3000,
"remaining_amount": 3000,
"priority": 0,
"expires_at": null,
"metadata": { "source": "signup_grant" }
},
{
"id": "blk_weekly",
"original_amount": 24000,
"remaining_amount": 24000,
"priority": 0,
"expires_at": "2026-04-18T00:00:00Z",
"metadata": { "source": "pack_purchase", "pack": "weekly" }
},
{
"id": "blk_monthly",
"original_amount": 100000,
"remaining_amount": 100000,
"priority": 0,
"expires_at": "2026-05-11T00:00:00Z",
"metadata": { "source": "pack_purchase", "pack": "monthly" }
}
]
}
The blocks array is returned in creation order. The burn-down engine sorts them internally — you don’t need to sort client-side to understand what will be consumed next.
Reading blocks for UI display
To show the user which pack is active and what’s remaining, filter and sort the blocks array:
interface CreditBlock {
id: string;
original_amount: number;
remaining_amount: number;
priority: number;
expires_at: string | null;
metadata: Record<string, string>;
}
function getActivePacks(blocks: CreditBlock[]): CreditBlock[] {
return blocks
.filter(b => b.remaining_amount > 0)
.sort((a, b) => {
// Sort by priority asc, then expiry asc (nulls last), then created order
if (a.priority !== b.priority) return a.priority - b.priority;
if (a.expires_at && b.expires_at) {
return new Date(a.expires_at).getTime() - new Date(b.expires_at).getTime();
}
if (a.expires_at && !b.expires_at) return -1;
if (!a.expires_at && b.expires_at) return 1;
return 0;
});
}
// Usage:
// const packs = getActivePacks(balance.blocks);
// packs[0] is the block currently being consumed
Plan stacking with queued activation
When a user buys a second plan while the first is still active, you want the new plan to start after the current one expires — not overlap.
Use stack_after on the grant call. The server atomically finds the latest expires_at among blocks matching your metadata filter and anchors the new block after it. No client-side read-then-write, no race conditions.
curl -s -X POST \
-H "X-API-Key: $API_KEY" \
-H "Idempotency-Key: plan-grant:weekly:order_456" \
-H "Content-Type: application/json" \
"https://api.quotastack.io/v1/topups/grant" \
-d '{
"external_customer_id": "user42:companion7",
"credits": 600000,
"price_paid": 0,
"currency": "mc",
"duration_seconds": 604800,
"stack_after": {
"metadata_match": { "source": "plan_weekly" },
"fallback": "now"
},
"priority": 0,
"metadata": { "source": "plan_weekly", "order_id": "order_456" }
}'
Response:
{
"credit_block_id": "blk_new",
"effective_at": "2026-04-25T00:00:00Z",
"expires_at": "2026-05-02T00:00:00Z",
"stacked_after_block_id": "blk_weekly",
"credits": 600000
}
The server found blk_weekly (expires Apr 25), set effective_at = Apr 25, and computed expires_at = Apr 25 + 7 days = May 2. The new block is granted but not consumable until the previous one expires.
duration_seconds vs expires_at: When using stack_after, provide duration_seconds instead of expires_at — the client doesn’t know when the block will start, so it provides a duration and the server computes the absolute times. The two fields are mutually exclusive.
fallback: "now" (default) uses the current time if no matching block exists — the first purchase works without special handling. "reject" returns 409 if no match exists, useful for renewal-only flows.
pending_balance and why stacked blocks can’t be dipped into early: Stacked blocks that haven’t started yet are included in balance but excluded from effective_balance via the pending_balance deduction. The burn-down engine and entitlement checks both skip blocks where effective_at > now() — they are invisible to the spending path until their time comes.
This is intentional. If a customer buys two 1-hour plans sequentially, each hour should provide its own credit budget. If the engine could dip into hour 2 during hour 1, the customer could exhaust both hours’ credits in 20 minutes and sit with nothing for the remaining 1h40m. Sequential stacking guarantees a consistent experience across each time window.
If the customer needs 1000 credits but only has 600 in effective_balance, the entitlement check returns allowed: false. The tenant’s app decides the next step — prompt to buy more, use a reservation for a partial amount, or wait until the pending block activates.
If the tenant wants both blocks immediately consumable, they grant without stack_after — two overlapping blocks, both effective immediately, combined balance available from the start. stack_after is specifically for “queue this, don’t overlap.”
async function buyStackedPlan(
customerID: string,
planType: string,
orderId: string
): Promise<void> {
const plan = PLANS[planType];
// Debit wallet
await fetch(`${API_BASE}/usage`, {
method: "POST",
headers: {
"X-API-Key": API_KEY,
"Idempotency-Key": `plan-debit:${planType}:${orderId}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
external_customer_id: customerID,
billable_metric_key: plan.metricKey,
units: plan.debitUnits,
}),
});
// Grant with stack_after — server handles sequencing atomically
await fetch(`${API_BASE}/topups/grant`, {
method: "POST",
headers: {
"X-API-Key": API_KEY,
"Idempotency-Key": `plan-grant:${planType}:${orderId}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
external_customer_id: customerID,
credits: plan.grantCredits,
price_paid: 0,
currency: "mc",
duration_seconds: plan.durationSeconds,
stack_after: {
metadata_match: { source: `plan_${planType}` },
fallback: "now",
},
priority: 0,
metadata: { source: `plan_${planType}`, order_id: orderId },
}),
});
}
Buying a weekly plan while a weekly plan is already active gives the user 2 weeks of credits total, with the second week’s block starting exactly when the first expires.
Gotchas
- Each block is independent. Don’t try to merge blocks or “top up” an existing block. QuotaStack doesn’t support partial block modifications — each grant creates a new block. This is by design: it keeps the ledger clean and auditable.
- Burn order is deterministic but not configurable per-block pair. You can’t say “burn block A before block B” if they have the same priority and expiry. The engine uses creation time as the final tiebreaker.
- Expired blocks vanish. When a block’s
expires_atpasses, its remaining credits are zeroed out by the expiry sweep. If the user had 50 remaining credits in an expired weekly pack, those 50 credits are gone — they don’t roll over to the next block. - Use
stack_afterfor sequential plans. Don’t compute stacked expiry client-side — usestack_afterwithduration_secondson the grant call and let the server handle it atomically. This eliminates the read-then-write race where two concurrent purchases both read the same “latest expiry.” - The balance endpoint returns the sum. The top-level
balancefield is the sum of allremaining_amountvalues across all active blocks. To show per-pack breakdown in your UI, read theblocksarray.