Topups and Wallets
How to grant credits after payment, model wallets and credit packs, configure topup packages, and control burn order with priority.
Wallets and credit packs are stacked fuel tanks with different rules. The system drains the tank that expires soonest first, saving the customer's paid wallet for last. You handle payment; QuotaStack grants the credits.
Topups and Wallets
QuotaStack does not process payments. Your application handles payment through whatever provider you use (Stripe, Razorpay, DodoPayments, etc.). After the payment succeeds, you call QuotaStack to grant credits. This page covers that grant flow and the two primary patterns built on top of it: wallets and credit packs.
The topup grant endpoint
POST /v1/topup/grant
This is the primary way to add credits to a customer’s account after a confirmed payment.
curl -X POST https://api.quotastack.io/v1/topup/grant \
-H "X-API-Key: qs_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: pay_abc123-grant" \
-d '{
"external_customer_id": "user_abc",
"credits": 200000,
"price_paid": 5000,
"currency": "INR",
"external_payment_id": "pay_abc123",
"metadata": {
"source": "razorpay",
"order_id": "order_xyz"
}
}'
Request fields
| Field | Type | Required | Description |
|---|---|---|---|
external_customer_id | string | yes* | Your tenant identifier for the customer. Auto-created if not already known. See Customer identification. |
customer_id | string | yes* | Alternative: the QuotaStack UUID. Use this only if you already have one. |
credits | int64 | yes | Millicredits to grant. Must be positive. |
price_paid | int64 | no | Amount paid in the currency’s smallest unit (e.g., paise for INR, cents for USD). For audit and reporting. |
currency | string | no | ISO 4217 currency code. |
package_id | string | no | If granting from a pre-configured topup package, pass the package ID. |
external_payment_id | string | no | Your payment provider’s transaction ID. Stored for reconciliation. |
expires_at | timestamp | no | When these credits expire. Null means never. |
priority | int (0-255) | no | Burn-order priority. Default: 0 (burns first among same-expiry blocks). |
metadata | object | no | Arbitrary key-value pairs. |
*Exactly one of external_customer_id or customer_id is required — both or neither returns 400.
Response
{
"id": "top_01HXY...",
"tenant_id": "t_01...",
"customer_id": "019d6258-07ba-7418-83be-58f5fde53e4e",
"external_customer_id": "user_abc",
"environment": "live",
"credits_granted": 200000,
"price_paid": 5000,
"currency": "INR",
"external_payment_id": "pay_abc123",
"status": "completed",
"metadata": {
"source": "razorpay",
"order_id": "order_xyz"
},
"account": {
"balance": 350000,
"lifetime_earned": 550000
},
"created_at": "2025-01-15T10:30:00Z"
}
The grant creates a credit block with the specified amount, priority, and expiry, and a corresponding ledger entry of type topup.
Wallets
A wallet is simply a credit account where topup grants have no expiry and priority 0 (the default). There is no separate “wallet” entity in the API — it is a pattern built on the standard credit system.
When you grant credits with no expires_at and priority: 0:
curl -X POST https://api.quotastack.io/v1/topup/grant \
-H "X-API-Key: qs_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: wallet-topup-pay_def456" \
-d '{
"external_customer_id": "user_abc",
"credits": 200000,
"price_paid": 5000,
"currency": "INR",
"external_payment_id": "pay_def456"
}'
The resulting credit block never expires and sits at the lowest priority. It behaves exactly like a wallet balance — credits accumulate across multiple topups and are consumed only when higher-priority blocks (like plan credits or packs) are exhausted.
Credit packs
A credit pack is a topup grant with an expiry and typically a higher priority. Packs burn before wallet credits because of the burn-down order:
- Higher priority blocks burn first (lower number = higher priority).
- Among same priority, blocks with expiry burn before blocks without expiry.
- Among same priority and expiry, the soonest-expiring block burns first.
To create a pack-style grant, set expires_at and optionally priority:
curl -X POST https://api.quotastack.io/v1/topup/grant \
-H "X-API-Key: qs_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: pack-weekly-pay_ghi789" \
-d '{
"external_customer_id": "user_abc",
"credits": 24000,
"price_paid": 9900,
"currency": "INR",
"external_payment_id": "pay_ghi789",
"expires_at": "2025-01-22T00:00:00Z",
"priority": 0
}'
Because this block has an expiry and the wallet block does not, the pack credits burn first (same priority, expiry beats no-expiry). When the pack is exhausted or expires, usage falls through to the wallet balance.
Burn order in practice
Consider a customer with the following blocks:
| Block | Credits | Priority | Expires | Source |
|---|---|---|---|---|
| Weekly pack | 24,000 mc | 0 | 2025-01-22 | topup |
| Wallet deposit | 200,000 mc | 0 | never | topup |
| Plan credits | 50,000 mc | 10 | 2025-02-01 | plan_grant |
Burn order: Weekly pack (priority 0, has expiry), then Wallet deposit (priority 0, no expiry), then Plan credits (priority 10).
If the customer uses 30,000 mc:
- Weekly pack: debit 24,000 mc (fully drained).
- Wallet deposit: debit 6,000 mc (remaining: 194,000 mc).
- Plan credits: untouched.
The pack credits are consumed first because they expire. The wallet balance is preserved as long as possible.
Topup packages
Topup packages are optional pre-configured credit bundles. They simplify your integration by letting you define standard offerings once and reference them by ID when granting.
Creating a package
POST /v1/topup-packages
curl -X POST https://api.quotastack.io/v1/topup-packages \
-H "X-API-Key: qs_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: pkg-weekly-v1" \
-d '{
"name": "Weekly Pack - 24 Looks",
"credits": 24000,
"metadata": {
"display_price": "99",
"currency": "INR"
}
}'
Listing packages
GET /v1/topup-packages
Returns active packages by default. Pass active_only=false to include deactivated packages.
Updating a package
PATCH /v1/topup-packages/{id}
You can update the name, metadata, or deactivate a package by setting is_active: false.
Granting from a package
Pass the package_id in the grant request to link the topup to a specific package:
curl -X POST https://api.quotastack.io/v1/topup/grant \
-H "X-API-Key: qs_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: pack-purchase-pay_xyz" \
-d '{
"external_customer_id": "user_abc",
"package_id": "pkg_01HXY...",
"credits": 24000,
"price_paid": 9900,
"currency": "INR",
"external_payment_id": "pay_xyz",
"expires_at": "2025-01-22T00:00:00Z"
}'
Listing customer topups
Retrieve all topup grants for a customer. Two URL forms — pick the one that matches the ID you have:
GET /v1/customers/{customer_id}/topups
GET /v1/customer-by-external-id/{external_id}/topups
See Customer identification for when to use each.
curl https://api.quotastack.io/v1/customer-by-external-id/user_abc/topups \
-H "X-API-Key: qs_live_..."
Results are cursor-paginated with cursor and limit parameters (max 100).
Example: ClosetNow credit packs
ClosetNow is a fashion SaaS that charges per AI-generated outfit look (1 look = 1,000 mc = 1 credit).
Signup: 3 free looks granted as a promotional credit block.
curl -X POST https://api.quotastack.io/v1/customer-by-external-id/user_new/credits/grant \
-H "X-API-Key: qs_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: signup-bonus-user_new" \
-d '{
"credits": 3000,
"source": "promotional",
"reason": "Signup bonus - 3 free looks",
"priority": 0,
"expires_at": "2025-02-15T00:00:00Z"
}'
Weekly pack (24 looks for 99 INR): After payment confirmation:
curl -X POST https://api.quotastack.io/v1/topup/grant \
-H "X-API-Key: qs_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: weekly-pack-pay_w001" \
-d '{
"external_customer_id": "user_abc",
"credits": 24000,
"price_paid": 9900,
"currency": "INR",
"external_payment_id": "pay_w001",
"expires_at": "2025-01-22T00:00:00Z"
}'
Monthly pack (100 looks for 349 INR): After payment confirmation:
curl -X POST https://api.quotastack.io/v1/topup/grant \
-H "X-API-Key: qs_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: monthly-pack-pay_m001" \
-d '{
"external_customer_id": "user_abc",
"credits": 100000,
"price_paid": 34900,
"currency": "INR",
"external_payment_id": "pay_m001",
"expires_at": "2025-02-15T00:00:00Z"
}'
The signup bonus burns first (same priority, soonest expiry). Then the weekly pack. Then the monthly pack. If the customer buys both packs simultaneously, the weekly pack burns first because it expires sooner.
Example: OurVibe wallet topups
OurVibe is a social messaging app that uses a pure wallet model. Customers add money to their wallet and credits are deducted per message.
50 INR topup (200 credits):
curl -X POST https://api.quotastack.io/v1/topup/grant \
-H "X-API-Key: qs_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: wallet-topup-pay_ov50" \
-d '{
"external_customer_id": "user_vibe_123",
"credits": 200000,
"price_paid": 5000,
"currency": "INR",
"external_payment_id": "pay_ov50"
}'
100 INR topup (500 credits):
curl -X POST https://api.quotastack.io/v1/topup/grant \
-H "X-API-Key: qs_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: wallet-topup-pay_ov100" \
-d '{
"external_customer_id": "user_vibe_123",
"credits": 500000,
"price_paid": 10000,
"currency": "INR",
"external_payment_id": "pay_ov100"
}'
No expiry, no priority override. Credits accumulate in the wallet. Each message consumes 1,000 mc (1 credit) via a usage event. The customer can keep topping up as needed.
Idempotency
Always set the Idempotency-Key header on grant requests. A good pattern is to derive it from your payment provider’s transaction ID:
Idempotency-Key: grant-{external_payment_id}
This ensures that if your webhook handler fires twice for the same payment (which payment providers commonly do), the second call is a no-op. The customer receives credits exactly once.
Common Mistakes
The mistakes developers typically make with this concept — and what to do instead.
priority: 0 and no expiry. Special-casing wallet accounts adds code that the data model doesn't need.