Plan Purchase as a Usage Event
Buy plans by debiting wallet credits and granting time-limited plan credits — a two-step credit operation with deterministic idempotency.
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.
purchase_idpriority: 10 on the plan grant so it burns before wallet creditsPlan 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.
- Debit the plan price from the wallet (usage event).
- 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.
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) withper_unitpricing at 1 millicredit per unit. This lets you pass the plan price asunits.
Step 1: Check wallet balance
Before attempting the purchase, verify the customer can afford the plan.
curl -s \
-H "X-API-Key: $API_KEY" \
"https://api.quotastack.io/v1/customers/$CUSTOMER_ID/credits?include_blocks=true"
Response:
{
"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).
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):
{
"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.
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
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: 0on the grant is intentional. The customer didn’t pay money for this grant — they paid wallet credits. Theprice_paidfield tracks external payments (e.g., USD via a payment provider).