---
title: Topups and Wallets
description: How to grant credits after payment, model wallets and credit packs, configure topup packages, and control burn order with priority.
order: 5
---

# 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.

> **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

## Diagram

Credit blocks stack vertically. The top burns first, the bottom last. Trial credits sit at the top (burn first), then promo packs, then plan grants, and the wallet balance is at the bottom as the safety net that never expires.

| Position | Block | Priority | Expiry |
|---|---|---|---|
| Top (burns first) | Trial credits | P0 | Jan 15 |
|  | Promo pack | P0 | Mar 1 |
|  | Plan grant | P10 | Apr 1 |
| Bottom (burns last) | Wallet balance | P0 | never |

## 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.

```bash
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](/docs/concepts/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

```json
{
  "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](/docs/concepts/credits) 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`:

```bash
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](/docs/concepts/credits):

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`:

```bash
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:

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
```

```bash
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:

```bash
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](/docs/concepts/customer-identification) for when to use each.

```bash
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.

```bash
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:

```bash
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:

```bash
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):**

```bash
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):**

```bash
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

**✗ 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.
