Docs / Cookbook / Stacking Multiple Credit Packs
MULTIPLE BLOCKS · AUTO-BURN-DOWN

Stacking Multiple Credit Packs

How credit blocks from multiple purchases coexist and burn in deterministic priority order — no merging, no special handling.

Mental Model

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.

Quick Take
New purchases create independent credit blocks — no merging
Burn order: priority ASC → expiry ASC → free-before-paid → created ASC
For queued plan activation, compute expiry as existing_block.expires_at + duration client-side
UI tip: fetch blocks sorted by expires_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:

  1. Lowest priority number first (for example, priority 0 before priority 1)
  2. Soonest expiry first (Apr 18 before May 11, both before “never”)
  3. 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:

BlockAmountPriorityExpiresSource
Free signup bonus3,000 mc (3 looks)0neversignup grant
Weekly pack24,000 mc (24 looks)0Apr 18pack purchase
Monthly pack100,000 mc (100 looks)0May 11pack 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_at passes, 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_after for sequential plans. Don’t compute stacked expiry client-side — use stack_after with duration_seconds on 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 balance field is the sum of all remaining_amount values across all active blocks. To show per-pack breakdown in your UI, read the blocks array.

Concepts used in this recipe

🤖
Building with an AI agent?
Get this page as markdown: /docs/cookbook/pack-stacking.md · Full index: /llms.txt