Docs / Cookbook / Auto-Purchasing Plans When Credits Run Out
ENTITLEMENT GATE · AUTO-RENEW FROM WALLET

Auto-Purchasing Plans When Credits Run Out

Automatically buy the next plan from the user's wallet when plan credits are exhausted — zero friction, no paywall interruption.

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

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.

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

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:

{
  "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

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:

{
  "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:

# 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

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

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

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). 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 in this recipe

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