---
title: "Auto-Purchasing Plans When Credits Run Out"
description: "Automatically buy the next plan from the user's wallet when plan credits are exhausted — zero friction, no paywall interruption."
order: 4
---

# Auto-Purchasing Plans When Credits Run Out

When a user's plan credits are exhausted, you can automatically buy the next plan from their wallet balance instead of showing a paywall. Zero friction — the user keeps going without interruption.

This pattern works well for consumption-heavy products where paywalls break the experience (chat apps, AI tools, gaming). The user pre-loads their wallet, and the system handles plan renewals automatically.

**Pattern:** ENTITLEMENT GATE · AUTO-RENEW FROM WALLET

> **Mental Model:** Check entitlement before a paid action. If the customer has plan credits — go. If they're out but have wallet funds, **silently buy the next plan from the wallet** and continue. Zero friction, no paywall interruption. Great for consumption-heavy products (chat, AI tools, games) where pause-to-pay breaks the experience.

## Quick Take

- **Entitlement-first flow:** check → proceed, or check → auto-buy → proceed
- Reuse the [Plan Purchase](/docs/cookbook/wallet-and-plans) two-step op as the auto-buy primitive
- Surface this as a **user preference** — some users want a paywall, some want frictionless auto-renew
- Guard against wallet-empty case: fall through to a real paywall when the wallet can't cover the next plan

## The flow

1. Check entitlement — is the user allowed to perform this action?
2. If `allowed: false`, check wallet balance.
3. If wallet has enough for the cheapest plan, auto-buy it.
4. Re-check entitlement — should now be `allowed: true`.
5. Record the usage event and proceed.

## Step by step

### Step 1: Check entitlement

```bash
curl -s \
  -H "X-API-Key: $API_KEY" \
  "https://api.quotastack.io/v1/customers/user42:companion7/entitlements/chat_message"
```

Response when plan credits are exhausted:

```json
{
  "allowed": false,
  "customer_id": "019d6258-07ba-7418-83be-58f5fde53e4e",
  "external_customer_id": "user42:companion7",
  "billable_metric_key": "chat_message",
  "units": 1,
  "balance": 500000,
  "reserved_balance": 0,
  "effective_balance": 500000,
  "estimated_cost": 1000,
  "balance_after": -1000,
  "overage_policy": "block"
}
```

Note: `balance` may still be positive — those are wallet credits (priority 0). The entitlement check considers metering rules, which may require plan-level credits.

### Step 2: Check wallet balance

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

Filter the blocks to find wallet credits — blocks with `priority === 0` and no expiry:

```json
{
  "balance": 500000,
  "effective_balance": 500000,
  "blocks": [
    {
      "id": "blk_wallet",
      "remaining_amount": 500000,
      "priority": 0,
      "expires_at": null,
      "metadata": { "source": "wallet_recharge" }
    }
  ]
}
```

The user has 500,000 mc in their wallet. Enough for a 1-hour plan at 100,000 mc.

### Step 3: Auto-buy the plan

Use the `buyPlan()` function from the [Plan Purchase recipe](/docs/cookbook/wallet-and-plans):

```bash
# Debit wallet
curl -s -X POST \
  -H "X-API-Key: $API_KEY" \
  -H "Idempotency-Key: plan-debit:1hr:auto_msg_abc123" \
  -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": "auto_msg_abc123", "trigger": "auto_buy" }
  }'

# Grant plan credits
curl -s -X POST \
  -H "X-API-Key: $API_KEY" \
  -H "Idempotency-Key: plan-grant:1hr:auto_msg_abc123" \
  -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": "auto_msg_abc123", "trigger": "auto_buy" }
  }'
```

### Step 4: Record usage and proceed

```bash
curl -s -X POST \
  -H "X-API-Key: $API_KEY" \
  -H "Idempotency-Key: usage:msg_abc123" \
  -H "Content-Type: application/json" \
  "https://api.quotastack.io/v1/usage" \
  -d '{
    "external_customer_id": "user42:companion7",
    "billable_metric_key": "chat_message",
    "units": 1
  }'
```

## Full code example

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

const PLAN_COST = 100000;       // 100 credits in millicredits
const PLAN_TYPE = "1hr";

interface EntitlementResult {
  allowed: boolean;
  customer_id: string;
  billable_metric_key: string;
  balance: number;
  effective_balance: number;
  estimated_cost: number;
}

interface BalanceResponse {
  balance: number;
  effective_balance: number;
  blocks: Array<{
    id: string;
    remaining_amount: number;
    priority: number;
    expires_at: string | null;
    metadata: Record<string, string>;
  }>;
}

async function checkEntitlement(
  customerID: string,
  metric: string
): Promise<EntitlementResult> {
  const res = await fetch(
    `${API_BASE}/customers/${customerID}/entitlements/${metric}`,
    { headers: { "X-API-Key": API_KEY } }
  );
  return res.json();
}

async function getBalance(customerID: string): Promise<BalanceResponse> {
  const res = await fetch(
    `${API_BASE}/customers/${customerID}/credits?include_blocks=true`,
    { headers: { "X-API-Key": API_KEY } }
  );
  return res.json();
}

async function recordUsage(
  customerID: string,
  metric: string,
  units: number,
  idempotencyKey: string
): Promise<void> {
  const res = await fetch(`${API_BASE}/usage`, {
    method: "POST",
    headers: {
      "X-API-Key": API_KEY,
      "Idempotency-Key": idempotencyKey,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      external_customer_id: customerID,
      billable_metric_key: metric,
      units,
    }),
  });
  if (!res.ok && res.status !== 402) {
    throw new Error(`Usage recording failed: ${res.status}`);
  }
}

function generateOrderId(): string {
  return `auto_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}

async function sendMessage(
  userId: string,
  companionId: string,
  messageId: string
): Promise<{ allowed: boolean; reason?: string }> {
  const customerID = `${userId}:${companionId}`;

  // Step 1: Check entitlement
  const ent = await checkEntitlement(customerID, "chat_message");

  if (ent.allowed) {
    // User has plan credits — record usage and proceed
    await recordUsage(customerID, "chat_message", 1, `usage:${messageId}`);
    return { allowed: true };
  }

  // Step 2: Check wallet balance
  const balance = await getBalance(customerID);
  const walletCredits = balance.blocks
    .filter(b => b.priority === 0 && !b.expires_at)
    .reduce((sum, b) => sum + b.remaining_amount, 0);

  if (walletCredits < PLAN_COST) {
    // Wallet is empty — show paywall
    return { allowed: false, reason: "empty_wallet" };
  }

  // Step 3: Auto-buy plan from wallet
  const orderId = generateOrderId();
  const buyResult = await buyPlan(customerID, PLAN_TYPE, orderId);

  if (!buyResult.success) {
    return { allowed: false, reason: buyResult.reason };
  }

  // Step 4: Verify entitlement after purchase
  const entAfter = await checkEntitlement(customerID, "chat_message");

  if (!entAfter.allowed) {
    // Something is wrong — don't loop, surface the error
    return { allowed: false, reason: "entitlement_failed_after_purchase" };
  }

  // Step 5: Record usage and proceed
  await recordUsage(customerID, "chat_message", 1, `usage:${messageId}`);
  return { allowed: true };
}
```

The `buyPlan()` function is defined in the [Plan Purchase recipe](/docs/cookbook/wallet-and-plans). It handles the two-step debit + grant with deterministic idempotency keys.

## Making auto-buy configurable

Not every user wants automatic purchases. Some prefer to choose their plan manually. Store a per-user preference and check it before auto-buying:

```typescript
async function sendMessageWithPreference(
  userId: string,
  companionId: string,
  messageId: string
): Promise<{ allowed: boolean; reason?: string }> {
  const customerID = `${userId}:${companionId}`;
  const ent = await checkEntitlement(customerID, "chat_message");

  if (ent.allowed) {
    await recordUsage(customerID, "chat_message", 1, `usage:${messageId}`);
    return { allowed: true };
  }

  // Check user preference
  const autoBuyEnabled = await getUserPreference(userId, "auto_buy_enabled");

  if (!autoBuyEnabled) {
    return { allowed: false, reason: "plan_expired" };  // show plan picker UI
  }

  // Proceed with auto-buy flow...
  const balance = await getBalance(customerID);
  const walletCredits = balance.blocks
    .filter(b => b.priority === 0 && !b.expires_at)
    .reduce((sum, b) => sum + b.remaining_amount, 0);

  if (walletCredits < PLAN_COST) {
    return { allowed: false, reason: "empty_wallet" };
  }

  const orderId = generateOrderId();
  await buyPlan(customerID, PLAN_TYPE, orderId);
  await recordUsage(customerID, "chat_message", 1, `usage:${messageId}`);
  return { allowed: true };
}
```

## Gotchas

- **Don't auto-buy in a loop.** If `buyPlan()` succeeds but the entitlement check still returns `allowed: false`, something is misconfigured (wrong metering rule, wrong billable metric key, wrong customer ID). Don't retry the purchase — surface the error and investigate. A loop here means burning through the user's wallet on plans that don't grant the expected entitlement.
- **Race condition with concurrent requests.** Two messages arrive at the same time. Both see `allowed: false`. Both check the wallet. Both call `buyPlan()` with different order IDs. Both succeed — the user now has two plans stacked. This is usually fine (the second plan extends the first, as described in [Pack Stacking](/docs/cookbook/pack-stacking)). If it's undesirable for your use case, use a distributed lock on the customer ID before the auto-buy check.
- **The order ID for auto-buy should not be the message ID.** If you use `messageId` as the order ID and two messages trigger auto-buy simultaneously, the idempotency keys will differ and both purchases will execute. Use a separate auto-generated order ID so each purchase attempt is independently idempotent.
- **Filter wallet blocks correctly.** Wallet credits are blocks with `priority === 0` and `expires_at === null`. Don't sum all blocks — that would include plan credits which can't be used to buy new plans (they're consumed by the metering rules, not by usage events against the plan-purchase metric).
- **Log auto-buy events.** Add `"trigger": "auto_buy"` to the metadata on both the debit and grant. This makes it easy to distinguish manual purchases from automatic ones in your analytics and in the QuotaStack ledger.

## Concepts Used

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