---
title: "Plan Purchase as a Usage Event"
description: "Buy plans by debiting wallet credits and granting time-limited plan credits — a two-step credit operation with deterministic idempotency."
order: 1
---

# 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.

**Pattern:** TWO-STEP CREDIT OP · DEBIT + GRANT

> **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

## 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.

```bash
curl -s \
  -H "X-API-Key: $API_KEY" \
  "https://api.quotastack.io/v1/customers/$CUSTOMER_ID/credits?include_blocks=true"
```

Response:

```json
{
  "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).

```bash
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):

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

```bash
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.

| Plan       | Wallet debit (units) | Credits granted | Expiry   |
|------------|---------------------|-----------------|----------|
| 1-hour     | 100,000 mc          | 50,000 mc       | +1 hour  |
| 24-hour    | 300,000 mc          | 200,000 mc      | +24 hours|
| Weekly     | 800,000 mc          | 600,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

```typescript
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

- [Credits](/docs/concepts/credits)
- [Topups & Wallets](/docs/concepts/topups-and-wallets)
- [Metering](/docs/concepts/metering)
- [Idempotency](/docs/concepts/idempotency)
