quotastack Docs
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 can confirm payment and call the manual grant API, or let a payment connector apply a mapped package after verified provider success.

Quick take

  • Manual flow: your payment integration confirms payment, then calls the top-up grant API
  • Connector flow: QuotaStack creates hosted checkout and grants the mapped package after a verified provider event
  • Wallets: persistent balance, no expiry, so expiring packs burn before it
  • 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 P0 · expires Apr 1 Wallet balance P0 · never expires

Topups and Wallets

QuotaStack does not store card details or move funds. You can take payment in two ways:

  • Manual flow: your app checks payment with its provider. It then asks QuotaStack to grant credits.
  • Connector flow: QuotaStack starts hosted checkout. It grants the mapped pack after a signed event proves payment.

This page shows the manual grant API. It also shows wallet and credit pack flows. See Payment Connectors to use hosted checkout.

The manual topup grant endpoint

POST /v1/topups/grant

Use this route after your own payment code has proved the charge was paid. A payment connector has its own grant path. Do not call this route for the same checkout.

curl -X POST https://api.quotastack.io/v1/topups/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 usually use the default priority 0. 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/topups/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, so expiring packs and plan credits at the same priority burn before it. It behaves exactly like a wallet balance — credits accumulate across multiple topups and are consumed after the customer’s time-limited credits are exhausted.

Credit packs

A credit pack is a topup grant with an expiry. Packs burn before no-expiry wallet credits because of the burn-down order:

  1. Lower priority numbers burn first.
  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/topups/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 mc02025-02-01plan_grant

Burn order: Weekly pack (priority 0, expires first), then Plan credits (priority 0, expires later), then Wallet deposit (priority 0, no expiry).

If the customer uses 30,000 mc:

  1. Weekly pack: debit 24,000 mc (fully drained).
  2. Plan credits: debit 6,000 mc (remaining: 44,000 mc).
  3. Wallet deposit: 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/topups/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/topups/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/topups/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 weekly pack burns first because it expires soonest. Then the signup bonus and monthly pack share the same expiry; the promotional signup bonus burns before the paid monthly topup. If the customer buys both packs simultaneously, the weekly pack still burns first because it expires sooner.

Example: Acme Chat wallet topups

Acme Chat is a 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/topups/grant \
  -H "X-API-Key: qs_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: wallet-topup-pay_ac50" \
  -d '{
    "external_customer_id": "user_alice",
    "credits": 200000,
    "price_paid": 5000,
    "currency": "INR",
    "external_payment_id": "pay_ac50"
  }'

100 INR topup (500 credits):

curl -X POST https://api.quotastack.io/v1/topups/grant \
  -H "X-API-Key: qs_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: wallet-topup-pay_ac100" \
  -d '{
    "external_customer_id": "user_alice",
    "credits": 500000,
    "price_paid": 10000,
    "currency": "INR",
    "external_payment_id": "pay_ac100"
  }'

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

For manual grant requests, always set the Idempotency-Key header. 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.

For connector checkout, QuotaStack links the payment ID to its provider account. It then grants the mapped pack once. Do not add a manual grant as a backup.

Common mistakes

Don't grant credits before payment confirms

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

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.