quotastack Docs
Docs / Cookbook / Auto-Purchasing Plans When Credits Run Out

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.

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 (stack_after sequences behind any active plan)
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/topups/grant" \
  -d '{
    "external_customer_id": "user42:companion7",
    "credits": 50000,
    "price_paid": 0,
    "currency": "mc",
    "duration_seconds": 3600,
    "stack_after": {
      "metadata_match": { "source": "plan_grant", "plan_type": "1hr" },
      "fallback": "now"
    },
    "priority": 0,
    "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.