Docs / Concepts / Topups and Wallets

Topups and Wallets

How to grant credits after payment, model wallets and credit packs, configure topup packages, and control burn order with priority.

Mental Model

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.

Quick Take
QuotaStack does not process payments — you handle payment, then call grant
Wallets: persistent balance, no expiry, lowest priority (safety net)
Credit packs: time-limited bundles that burn before wallet
Burn order: expiring free credits first, paid wallet balance last
BURNS TOP → BOTTOM Trial credits P0 · expires Jan 15 Promo pack P0 · expires Mar 1 Plan grant P10 · expires Apr 1 Wallet balance P0 · never expires

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

FieldTypeRequiredDescription
external_customer_idstringyes*Your tenant identifier for the customer. Auto-created if not already known. See Customer identification.
customer_idstringyes*Alternative: the QuotaStack UUID. Use this only if you already have one.
creditsint64yesMillicredits to grant. Must be positive.
price_paidint64noAmount paid in the currency’s smallest unit (e.g., paise for INR, cents for USD). For audit and reporting.
currencystringnoISO 4217 currency code.
package_idstringnoIf granting from a pre-configured topup package, pass the package ID.
external_payment_idstringnoYour payment provider’s transaction ID. Stored for reconciliation.
expires_attimestampnoWhen these credits expire. Null means never.
priorityint (0-255)noBurn-order priority. Default: 0 (burns first among same-expiry blocks).
metadataobjectnoArbitrary 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:

  1. Higher priority blocks burn first (lower number = higher priority).
  2. Among same priority, blocks with expiry burn before blocks without expiry.
  3. 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:

BlockCreditsPriorityExpiresSource
Weekly pack24,000 mc02025-01-22topup
Wallet deposit200,000 mc0nevertopup
Plan credits50,000 mc102025-02-01plan_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:

  1. Weekly pack: debit 24,000 mc (fully drained).
  2. Wallet deposit: debit 6,000 mc (remaining: 194,000 mc).
  3. 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.

×
Don't grant credits before payment confirms
Why
If payment fails after you grant, you're eating the cost. Grant on successful payment webhook, using the payment ID as your idempotency key.
×
Don't model wallets as a separate account type
Why
A wallet is just a credit block with priority: 0 and no expiry. Special-casing wallet accounts adds code that the data model doesn't need.
🤖
Building with an AI agent?
Get this page as markdown: /docs/concepts/topups-and-wallets.md · Full index: /llms.txt