Docs / Cookbook / Plan Purchase as a Usage Event
TWO-STEP CREDIT OP · DEBIT + GRANT

Plan Purchase as a Usage Event

Buy plans by debiting wallet credits and granting time-limited plan credits — a two-step credit operation with deterministic idempotency.

Mental Model

Buying a plan is just two credit operations in a row: debit the wallet for the price, then grant the plan credits. The wallet is your universal currency; plans are just credit blocks with priority and expiry. No subscription state machine needed.

Quick Take
Pattern: usage event (debit wallet) + topup grant (issue plan credits)
Use deterministic idempotency keys so retries are safe — derive them from a single purchase_id
Set priority: 10 on the plan grant so it burns before wallet credits
No separate payment flow or subscription lifecycle — everything flows through credits

Plan Purchase as a Usage Event

A common pattern in credit-native billing: the user’s wallet holds purchased credits, and buying a plan is a two-step credit operation.

  1. Debit the plan price from the wallet (usage event).
  2. Grant plan credits with expiry and high priority (topup grant).

The wallet acts as a universal currency. Plans are just credit blocks with a priority and expiry. No separate payment flow, no subscription state machine — just credits in, credits out.

Prerequisites

  • The customer has a wallet with sufficient credits (priority 0, no expiry).
  • You have configured a billable metric for plan purchases (e.g. plan_purchase_1hr) with per_unit pricing at 1 millicredit per unit. This lets you pass the plan price as units.

Step 1: Check wallet balance

Before attempting the purchase, verify the customer can afford the plan.

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": 500000,
  "reserved_balance": 0,
  "effective_balance": 500000,
  "blocks": [
    {
      "id": "blk_abc",
      "original_amount": 500000,
      "remaining_amount": 500000,
      "priority": 0,
      "expires_at": null,
      "source": "topup",
      "metadata": { "source": "wallet_recharge" }
    }
  ]
}

The customer has 500,000 mc (500 credits) in their wallet. Enough for a plan that costs 100 credits.

Step 2: Debit the plan cost from the wallet

Record a usage event to debit the plan price. The units field is the price in credits (converted to millicredits by the metering rule).

curl -s -X POST \
  -H "X-API-Key: $API_KEY" \
  -H "Idempotency-Key: plan-debit:1hr:order_98765" \
  -H "Content-Type: application/json" \
  "https://api.quotastack.io/v1/usage" \
  -d '{
    "external_customer_id": "user42:companion7",
    "billable_metric_key": "plan_purchase_1hr",
    "units": 100000,
    "metadata": {
      "plan_type": "1hr",
      "order_id": "order_98765"
    }
  }'

Response (success):

{
  "event_id": "evt_xyz",
  "idempotency_key": "plan-debit:1hr:order_98765",
  "status": "accepted",
  "estimated_cost": 100000,
  "duplicate": false
}

If the wallet doesn’t have enough credits, this returns HTTP 402. Handle that by showing a recharge paywall.

Step 3: Grant plan credits

Now grant the plan’s credit allocation with an expiry and high priority so these credits burn before wallet credits.

curl -s -X POST \
  -H "X-API-Key: $API_KEY" \
  -H "Idempotency-Key: plan-grant:1hr:order_98765" \
  -H "Content-Type: application/json" \
  "https://api.quotastack.io/v1/topup/grant" \
  -d '{
    "external_customer_id": "user42:companion7",
    "credits": 50000,
    "price_paid": 0,
    "currency": "mc",
    "expires_at": "2026-04-13T14:00:00Z",
    "priority": 10,
    "metadata": {
      "source": "plan_grant",
      "plan_type": "1hr",
      "order_id": "order_98765"
    }
  }'

The priority: 10 ensures plan credits burn before wallet credits (priority: 0). The expires_at sets when unused plan credits vanish.

Idempotency keys

Use deterministic keys derived from the plan type and order ID:

  • Debit: plan-debit:{planType}:{orderId}
  • Grant: plan-grant:{planType}:{orderId}

This guarantees that retrying a failed purchase never double-charges or double-grants. The order ID should be generated client-side before the first attempt.

Variable pricing

Different plans can cost different amounts. The same flow works — just change the units value on the usage event and the credits/expires_at on the grant.

PlanWallet debit (units)Credits grantedExpiry
1-hour100,000 mc50,000 mc+1 hour
24-hour300,000 mc200,000 mc+24 hours
Weekly800,000 mc600,000 mc+7 days

You can also vary pricing per context (e.g., per companion in a chat app). The API calls are identical — your backend just looks up the right price before calling QuotaStack.

Error handling

Step 2 returns 402 (insufficient credits): The wallet is empty or doesn’t have enough credits. Show a recharge paywall. No credits were debited — nothing to roll back.

Step 3 fails (network error, timeout, 5xx): The debit in step 2 already happened. Retry step 3 — the idempotency key ensures the grant executes exactly once. Do not skip the retry; the customer paid but hasn’t received their plan credits yet.

Step 3 returns a duplicate response: The grant was already applied (previous retry succeeded). You’re done — proceed normally.

Full code example

const API_BASE = "https://api.quotastack.io/v1";

interface PlanConfig {
  debitUnits: number;      // millicredits to deduct from wallet
  grantCredits: number;    // millicredits of plan credits to grant
  durationMs: number;      // plan duration in milliseconds
  metricKey: string;       // billable metric key for the debit
}

const PLANS: Record<string, PlanConfig> = {
  "1hr":    { debitUnits: 100000, grantCredits: 50000,  durationMs: 3600000,    metricKey: "plan_purchase_1hr" },
  "24hr":   { debitUnits: 300000, grantCredits: 200000, durationMs: 86400000,   metricKey: "plan_purchase_24hr" },
  "weekly": { debitUnits: 800000, grantCredits: 600000, durationMs: 604800000,  metricKey: "plan_purchase_weekly" },
};

async function buyPlan(
  customerID: string,
  planType: string,
  orderId: string
): Promise<{ success: boolean; reason?: string }> {
  const plan = PLANS[planType];
  if (!plan) {
    return { success: false, reason: "unknown_plan" };
  }

  // Step 1: Check wallet balance
  const balanceRes = await fetch(
    `${API_BASE}/customers/${customerID}/credits?include_blocks=true`,
    { headers: { "X-API-Key": API_KEY } }
  );
  const balance = await balanceRes.json();

  if (balance.effective_balance < plan.debitUnits) {
    return { success: false, reason: "insufficient_wallet" };
  }

  // Step 2: Debit plan cost from wallet
  const debitRes = 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,
      metadata: { plan_type: planType, order_id: orderId },
    }),
  });

  if (debitRes.status === 402) {
    return { success: false, reason: "insufficient_wallet" };
  }
  if (!debitRes.ok) {
    throw new Error(`Debit failed: ${debitRes.status}`);
  }

  // Step 3: Grant plan credits (retry-safe via idempotency key)
  const expiresAt = new Date(Date.now() + plan.durationMs).toISOString();

  const grantRes = await fetch(`${API_BASE}/topup/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",
      expires_at: expiresAt,
      priority: 10,
      metadata: { source: "plan_grant", plan_type: planType, order_id: orderId },
    }),
  });

  if (!grantRes.ok) {
    // Critical: debit succeeded but grant failed. Retry this step.
    throw new Error(`Grant failed: ${grantRes.status} — retry with same orderId`);
  }

  return { success: true };
}

Gotchas

  • Always retry step 3 on failure. The debit already happened. If the grant fails and you don’t retry, the customer loses credits with nothing to show for it. The idempotency key makes retries safe.
  • Generate the orderId before step 2. Both the debit and grant keys derive from it. If you generate it between steps, you lose the ability to correlate them.
  • Don’t use the wallet balance check as a lock. Between the balance check and the debit, another request could drain the wallet. The usage event itself is the atomic check — a 402 from step 2 is the authoritative “can’t afford it” signal.
  • price_paid: 0 on the grant is intentional. The customer didn’t pay money for this grant — they paid wallet credits. The price_paid field tracks external payments (e.g., USD via a payment provider).

Concepts used in this recipe

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