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.
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.
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
- Check entitlement — is the user allowed to perform this action?
- If
allowed: false, check wallet balance. - If wallet has enough for the cheapest plan, auto-buy it.
- Re-check entitlement — should now be
allowed: true. - 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 returnsallowed: 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 callbuyPlan()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
messageIdas 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 === 0andexpires_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.