---
title: "Topups"
description: "A customer pays once and gets credits once. Define a bundle you sell repeatedly, or grant an ad-hoc amount after your payment provider settles."
---

# Topups

A customer buys credits once. No [subscription](/docs/concepts/subscriptions) is involved.

A [topup](/docs/concepts/topups-and-wallets) is one purchase. The customer
pays, and you give them [credits](/docs/concepts/credits) once. Nothing
repeats.

For a manual integration, your provider tells you the money arrived, and then
you call `grantTopup`. That call records the purchase and adds the credits in
one step.

A [payment connector](/docs/concepts/payment-connectors) uses a different path.
QuotaStack creates hosted checkout and applies the mapped package after a
verified provider event. Do not also call `grantTopup` for that checkout.

Two ways to run this. Define a package when you sell the same bundle over and
over, so the credit amount lives in one place. Skip the package and send
`credits` directly when the amount changes every time.

A package is only a template. Creating one grants nothing. Retiring one with
`is_active: false` blocks new grants, and never touches credits you already
granted.

Send `credits` on every grant, even when you name a `package_id`. The package
records which bundle was bought. The field never fills in the amount for you.

Topup credits burn before plan credits. A topup block sits at priority 0 and a
plan grant sits at 10, so QuotaStack spends the money the customer paid for
first.

Want credits to arrive again every month? That is a
[subscription](/docs/concepts/subscriptions), not a topup.

## Which call do I want?

- You sell a fixed bundle of credits, and you want to name it once → `POST /v1/topup-packages`
- A customer paid, and you are giving them the credits → `POST /v1/topups/grant`
  A [topup](/docs/concepts/topups-and-wallets) is one purchase. A [subscription](/docs/concepts/subscriptions) grants credits again every period. Use this call when the customer pays once.
- You are retiring a bundle and want to stop new purchases of it → `PATCH /v1/topup-packages/{id}`
  Set `is_active` to false. Old grants keep their credits; only new grants are blocked.
- You want to see what one customer has bought → `GET /v1/customers/{customer_id}/topups`
  Two endpoints do this. `listCustomerTopups` takes the QuotaStack `customer_id`. `listCustomerTopupsByExternalId` takes your own ID. Only the lookup differs; the reply is the same.
- You want the bundles you have defined, not what anyone bought → `GET /v1/topup-packages`

6 of 6 operations documented in full.

## List topup packages

`GET /v1/topup-packages`

- **Idempotency-Key:** Not used. This is a read.
- **Environment:** Sandbox and Live
- **Auth:** Tenant API key
- **Writes:** Nothing. A read has no side effects.
- **Fires:** No webhooks.

This call returns the bundles you have defined. The list holds no purchases at all. Use `listCustomerTopups` to see what a customer bought. QuotaStack hides packages you turned off, unless you ask for them. Send `active_only=false` to see every package. openapi.yaml says this one defaults to `false`. The spec is wrong here. The real default is `true`. A cursor QuotaStack cannot read returns `400`.

### Query parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `active_only` | boolean | optional | Whether to hide packages you turned off. If you leave it out: QuotaStack hides those packages. The spec says the default is `false`. The code says `true`. Only the exact word `false` shows every package. `0` and `no` both mean active only. |
| `cursor` | string | optional | Opaque pagination cursor from the previous page's `pagination.next_cursor`. Omit to start at the first page. |
| `limit` | integer | optional | Page size. Default 20, max 100. |

### Response 200

```json
{
  "data": [
    {
      "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a72",
      "name": "10,000 credits",
      "credits": 10000000,
      "is_active": true,
      "expires_after_seconds": null,
      "metadata": {},
      "created_at": "2026-07-28T09:00:00Z",
      "updated_at": "2026-07-28T09:00:00Z"
    }
  ],
  "pagination": {
    "has_more": false,
    "next_cursor": null
  }
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `data` | array | required |  |
| `data.name` | string | required | What you call this package. QuotaStack shows it back to you, and never to your customer. |
| `data.credits` | integer (int64) | required | How many credits one purchase of this package grants. Unit: millicredits, where 1 credit is 1000 millicredits. |
| `data.is_active` | boolean | required | Whether you can still grant this package. An inactive package stays readable and stops being grantable. If you leave it out: A new package is active. |
| `data.expires_after_seconds` | integer (int64) | optional | How long the credits from this package last, counted from the moment you grant them. QuotaStack uses this only when the grant itself names no expiry. If you leave it out: The credits never expire. No request field sets this today, so a package you create through this API always reads `null` here. Set an expiry on the grant instead, with `expires_at` or `duration_seconds`. |
| `data.metadata` | object | required | Your own key-value tags. QuotaStack stores them unchanged and returns them on every read. If you leave it out: An empty object is stored instead. |
| `pagination` | object | required | Cursor-based pagination envelope. Returned on every list endpoint. No `total` — cursor-only. |

### See also

- [Topups and wallets](/docs/concepts/topups-and-wallets)

## Create a topup package

`POST /v1/topup-packages`

- **Idempotency-Key:** Required. Returns 422 without it.
- **Environment:** Sandbox and Live
- **Auth:** Tenant API key
- **Writes:** One new [topup](/docs/concepts/topups-and-wallets) package. No customer is touched, and no credits move.
- **Fires:** No webhooks.

This call defines a bundle you sell more than once. Name it, say how many credits it grants, and grant it later with `grantTopup`. A package is a template. Creating one grants nothing and charges nobody. You do not need a package at all — `grantTopup` works without one. Use a package when you sell the same bundle repeatedly and want one place to change it. QuotaStack cannot set an expiry on a package through this API. The reply carries `expires_after_seconds`, and no request field writes it. Put the expiry on the grant instead. openapi.yaml declares only `201` here. A missing `name`, or `credits` at zero or below, returns `422`, and the error names the field. A body QuotaStack cannot read returns `400`.

### Header parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `Idempotency-Key` | string | required | A unique string you choose, up to 256 characters. It stops a retry from doing the work twice. This header is required on every POST and PATCH. Leave it out and the request fails with `422`, before any change is made. Send the same key twice and QuotaStack replays the first response. The second call's status and body match the first, and the response carries `X-Idempotent-Replayed: true`. Replays are available for 24 hours. Derive the key from the business event rather than a random value, so a retry from anywhere reuses it — for example `topup:{payment_intent_id}` or `renewal:{subscription_id}:{period_start}`. |

### Body parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `name` | string | required | What you call this package. QuotaStack shows it back to you, and never to your customer. If you leave it out: QuotaStack rejects the call with `422`. This field is required. |
| `credits` | integer (int64) | required | How many credits one purchase of this package grants. Unit: millicredits, where 1 credit is 1000 millicredits. If you leave it out: QuotaStack rejects the call with `422`. This field is required, and must be more than zero. Range: 1–∞. |
| `metadata` | object | optional | Your own key-value tags. QuotaStack stores them unchanged and returns them on every read. If you leave it out: An empty object is stored instead. |

### Request

```json
{
  "name": "10,000 credits",
  "credits": 10000000
}
```

### Response 201

```json
{
  "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a72",
  "name": "10,000 credits",
  "credits": 10000000,
  "is_active": true,
  "expires_after_seconds": null,
  "metadata": {},
  "created_at": "2026-07-28T09:00:00Z",
  "updated_at": "2026-07-28T09:00:00Z"
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string (uuid) | required |  |
| `tenant_id` | string (uuid) | required |  |
| `name` | string | required | What you call this package. QuotaStack shows it back to you, and never to your customer. |
| `credits` | integer (int64) | required | How many credits one purchase of this package grants. Unit: millicredits, where 1 credit is 1000 millicredits. |
| `is_active` | boolean | required | Whether you can still grant this package. An inactive package stays readable and stops being grantable. If you leave it out: A new package is active. |
| `expires_after_seconds` | integer (int64) | optional | How long the credits from this package last, counted from the moment you grant them. QuotaStack uses this only when the grant itself names no expiry. If you leave it out: The credits never expire. No request field sets this today, so a package you create through this API always reads `null` here. Set an expiry on the grant instead, with `expires_at` or `duration_seconds`. |
| `metadata` | object | required | Your own key-value tags. QuotaStack stores them unchanged and returns them on every read. If you leave it out: An empty object is stored instead. |
| `created_at` | string (date-time) | required |  |
| `updated_at` | string (date-time) | required |  |

### See also

- [Topups and wallets](/docs/concepts/topups-and-wallets)
- [Idempotency](/docs/concepts/idempotency)

## Update a topup package

`PATCH /v1/topup-packages/{id}`

- **Idempotency-Key:** Required. Returns 422 without it.
- **Environment:** Sandbox and Live
- **Auth:** Tenant API key
- **Writes:** The package itself. No customer is touched, and no credits move.
- **Fires:** No webhooks.

This call changes a package you already defined. You can change `name`, `is_active`, and `metadata`. You cannot change `credits`. Create a new package when the bundle size changes, so old purchases keep their real number. Setting `is_active` to false stops new grants of this package. Credits already granted from it stay exactly where they are. openapi.yaml declares only `200` here. An `id` you do not own returns `404`. A body QuotaStack cannot read returns `400`.

### Header parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `Idempotency-Key` | string | required | A unique string you choose, up to 256 characters. It stops a retry from doing the work twice. This header is required on every POST and PATCH. Leave it out and the request fails with `422`, before any change is made. Send the same key twice and QuotaStack replays the first response. The second call's status and body match the first, and the response carries `X-Idempotent-Replayed: true`. Replays are available for 24 hours. Derive the key from the business event rather than a random value, so a retry from anywhere reuses it — for example `topup:{payment_intent_id}` or `renewal:{subscription_id}:{period_start}`. |

### Path parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string (uuid) | required |  |

### Body parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `name` | string | optional | What you call this package. QuotaStack shows it back to you, and never to your customer. If you leave it out: The name stays as it was. |
| `is_active` | boolean | optional | Whether the package can still be granted. Set it to false to retire the bundle. If you leave it out: The setting stays as it was. |
| `metadata` | object | optional | Your own key-value tags. QuotaStack stores them unchanged and returns them on every read. If you leave it out: An empty object is stored instead. |

### Request

```json
{
  "is_active": false
}
```

### Response 200

```json
{
  "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a72",
  "name": "10,000 credits",
  "credits": 10000000,
  "is_active": false,
  "expires_after_seconds": null,
  "metadata": {},
  "created_at": "2026-07-28T09:00:00Z",
  "updated_at": "2026-07-28T11:00:00Z"
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string (uuid) | required |  |
| `tenant_id` | string (uuid) | required |  |
| `name` | string | required | What you call this package. QuotaStack shows it back to you, and never to your customer. |
| `credits` | integer (int64) | required | How many credits one purchase of this package grants. Unit: millicredits, where 1 credit is 1000 millicredits. |
| `is_active` | boolean | required | Whether you can still grant this package. An inactive package stays readable and stops being grantable. If you leave it out: A new package is active. |
| `expires_after_seconds` | integer (int64) | optional | How long the credits from this package last, counted from the moment you grant them. QuotaStack uses this only when the grant itself names no expiry. If you leave it out: The credits never expire. No request field sets this today, so a package you create through this API always reads `null` here. Set an expiry on the grant instead, with `expires_at` or `duration_seconds`. |
| `metadata` | object | required | Your own key-value tags. QuotaStack stores them unchanged and returns them on every read. If you leave it out: An empty object is stored instead. |
| `created_at` | string (date-time) | required |  |
| `updated_at` | string (date-time) | required |  |

### See also

- [Topups and wallets](/docs/concepts/topups-and-wallets)
- [Idempotency](/docs/concepts/idempotency)

## Grant a topup

`POST /v1/topups/grant`

- **Idempotency-Key:** Required. Returns 422 without it.
- **Environment:** Sandbox and Live
- **Auth:** Tenant API key
- **Writes:** One [topup](/docs/concepts/topups-and-wallets) record, and one new [credit block](/docs/concepts/credits) for the customer. QuotaStack writes the sale down first, then grants the credits. A grant that fails after that step leaves a sale marked `completed` with no credits behind it. QuotaStack does this on purpose. A failure leaves a paper trail, instead of free credits. So read the balance when you check your books. The sale list alone can lie to you.
- **Fires:** [`credit.granted`](/docs/api/events/credit.granted)

This manual-integration call records a purchase and adds the credits. Call it after your payment provider tells you the money arrived. Do not call it for a payment-connector checkout. That path applies its mapped package after a verified provider event. Send `customer_id` or `external_customer_id`, never both. QuotaStack rejects the call with neither. An unrecognized value on either field creates a new customer, the same lookup `grantCustomerCredits` uses. Send `credits` every time, even when you send `package_id`. QuotaStack rejects a grant whose `credits` is zero or missing, before it ever reads the package. So `package_id` alone does not grant that package's credits. `package_id` records which bundle was bought, and sets the expiry when the package has one and the grant names none. Do not send `expires_at` and `duration_seconds` together. QuotaStack returns `422`, the error names `duration_seconds`, and nothing is granted. `stack_after` requires `duration_seconds`. Send it without one and QuotaStack returns the same `422`. `stack_after` with `fallback: reject` and no matching block returns `409`, and nothing is granted. This reply is not the plain `Topup` the field table below describes. The real reply adds a nested `account` with `balance` and `lifetime_earned`. openapi.yaml declares only `201` and `404` here. A bad `credits` value, or those mutually exclusive fields, returns `422`. Sending both customer fields, or neither, returns `400`.

### Header parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `Idempotency-Key` | string | required | A unique string you choose, up to 256 characters. It stops a retry from doing the work twice. This header is required on every POST and PATCH. Leave it out and the request fails with `422`, before any change is made. Send the same key twice and QuotaStack replays the first response. The second call's status and body match the first, and the response carries `X-Idempotent-Replayed: true`. Replays are available for 24 hours. Derive the key from the business event rather than a random value, so a retry from anywhere reuses it — for example `topup:{payment_intent_id}` or `renewal:{subscription_id}:{period_start}`. |

### Body parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `customer_id` | string | optional | QuotaStack's own ID for the customer who bought this. If you leave it out: Send `external_customer_id` instead. Send neither and QuotaStack returns `400`. |
| `external_customer_id` | string | optional | Your own ID for the customer who bought this. If you leave it out: Send `customer_id` instead. Send both and QuotaStack returns `400`. An ID QuotaStack has never seen creates a new customer. |
| `package_id` | string (uuid) | optional | Which bundle the customer bought. QuotaStack stores it on the [topup](/docs/concepts/topups-and-wallets). QuotaStack uses the package expiry when the grant names none. The field never fills in `credits` for you. If you leave it out: The sale belongs to no package. Send `credits` yourself, the same as always. |
| `credits` | integer (int64) | required | How many credits to add. Must be more than zero. Unit: millicredits, where 1 credit is 1000 millicredits. If you leave it out: QuotaStack rejects the call with `422`. This field is required even when you send `package_id`. Range: 1–∞. |
| `price_paid` | integer (int64) | required | What the customer paid you, in your own money. The number is cash, not credits. QuotaStack stores it for your records, and prices nothing from it. Unit: the minor unit of `currency`, so 500 means 5.00 US dollars when `currency` is `USD`. If you leave it out: QuotaStack stores 0. The credits are still granted. Range: 0–∞. |
| `currency` | string | required | Which currency `price_paid` is in. An ISO 4217 code, such as `USD`, `EUR`, or `INR`. If you leave it out: QuotaStack stores an empty string and the call still succeeds. The spec marks this field required, and no code enforces it. |
| `external_payment_id` | string | optional | The ID your own payment provider gave this charge. QuotaStack keeps it, so you can tie a [topup](/docs/concepts/topups-and-wallets) back to the money. If you leave it out: QuotaStack stores nothing here. Matching this sale to a charge gets harder later. |
| `expires_at` | string (date-time) | optional | When these credits stop counting toward the balance. If you leave it out: QuotaStack uses the package's `expires_after_seconds` when the grant names a package that has one. Otherwise the credits never expire. Send this with `duration_seconds` and QuotaStack returns `422`, naming `duration_seconds`. |
| `duration_seconds` | integer (int64) | optional | How long these credits last, counted from when the block starts. If you leave it out: QuotaStack uses `expires_at` instead. Send neither and the credits never expire. Send both and QuotaStack returns `422`. The error names `duration_seconds`, and nothing is granted. Range: 1–315360000. |
| `stack_after` | object | optional | Starts these credits right after another block ends, instead of right now. QuotaStack finds that other block by matching `metadata_match`. `stack_after` requires `duration_seconds`, and returns `422` without one. If you leave it out: The credits start now, not after another block. |
| `stack_after.metadata_match` | object | required | Key-value tags to match against a block. QuotaStack picks the one with the latest expiry as the anchor. If you leave it out: QuotaStack rejects the call. This field is required whenever you send `stack_after`. |
| `stack_after.fallback` | string | optional | What to do when no block matches `metadata_match`. If you leave it out: By default, QuotaStack uses `now` for this. `now` — Start the new credits right now, as if `stack_after` were not sent. `reject` — QuotaStack sends back a `409` instead, and grants nothing. |
| `priority` | integer | optional | Which [credit block](/docs/concepts/credits) gets spent first. A lower number goes first. These credits sit at 0 and plan credits sit at 10. So the credits a customer paid for burn before the ones a plan gave away. If you leave it out: By default, it is 0. These credits burn before plan credits. Range: 0–255. |
| `metadata` | object | optional | Your own key-value tags. QuotaStack stores them unchanged and returns them on every read. If you leave it out: An empty object is stored instead. |

### Request

```json
{
  "external_customer_id": "user_42",
  "package_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a72",
  "credits": 10000000,
  "price_paid": 1000,
  "currency": "USD",
  "external_payment_id": "pay_xyz789"
}
```

### Response 201

```json
{
  "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a71",
  "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
  "package_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a72",
  "environment": "live",
  "credits_granted": 10000000,
  "price_paid": 1000,
  "currency": "USD",
  "external_payment_id": "pay_xyz789",
  "expires_at": null,
  "status": "completed",
  "metadata": {},
  "created_at": "2026-07-28T09:05:00Z",
  "updated_at": "2026-07-28T09:05:00Z",
  "account": {
    "balance": 12000000,
    "lifetime_earned": 22000000
  }
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string (uuid) | required |  |
| `tenant_id` | string (uuid) | required |  |
| `customer_id` | string (uuid) | required |  |
| `package_id` | string (uuid) | optional | Which package this purchase came from. If you leave it out: The grant named no package. This [topup](/docs/concepts/topups-and-wallets) is a one-off. |
| `environment` | string | required | Which environment this resource lives in. Determined by the API key prefix used (`qs_live_…` → live, `qs_test_…` → sandbox). `live` `sandbox` |
| `credits_granted` | integer (int64) | required | How many credits went to the customer. Unit: millicredits, where 1 credit is 1000 millicredits. |
| `price_paid` | integer (int64) | required | What the customer paid you, in your own money. The number is cash, not credits. QuotaStack stores it for your records, and prices nothing from it. Unit: the minor unit of `currency`, so 500 means 5.00 US dollars when `currency` is `USD`. |
| `currency` | string | required | Which currency `price_paid` is in. An ISO 4217 code, such as `USD`, `EUR`, or `INR`. |
| `external_payment_id` | string | optional | The ID you sent when you made this sale. QuotaStack keeps it word for word. |
| `expires_at` | string (date-time) | optional | When these credits stop counting in the balance. If you leave it out: These credits never run out. |
| `status` | string | required | Where this purchase stands. `completed` — QuotaStack wrote the sale down. Every [topup](/docs/concepts/topups-and-wallets) from this API reads this way. `refunded` — You gave the money back. No call on this API sets this value. |
| `metadata` | object | required | Your own key-value tags. QuotaStack stores them unchanged and returns them on every read. If you leave it out: An empty object is stored instead. |
| `created_at` | string (date-time) | required |  |
| `updated_at` | string (date-time) | required |  |

### Errors

- `404` — No package here has this `package_id`, or the package is inactive. QuotaStack sends the same reply for both, so check `is_active` yourself. See [not-found](/docs/api/errors/not-found).

### See also

- [Topups and wallets](/docs/concepts/topups-and-wallets)
- [Credits](/docs/concepts/credits)
- [Credits](/docs/api/credits)
- [Subscriptions](/docs/api/subscriptions)
- [Idempotency](/docs/concepts/idempotency)

## List a customer topups

`GET /v1/customers/{customer_id}/topups`

- **Idempotency-Key:** Not used. This is a read.
- **Environment:** Sandbox and Live
- **Auth:** Tenant API key
- **Writes:** Nothing. A read has no side effects.
- **Fires:** No webhooks.

This call returns what one customer bought, newest first. The path takes the QuotaStack `customer_id`. Use `listCustomerTopupsByExternalId` when you hold your own ID instead. Both calls do the same work and send back the same shape. A [topup](/docs/concepts/topups-and-wallets) here means QuotaStack wrote the sale down. The row is not proof that the credits landed. Read the balance when you need to be sure. A cursor QuotaStack cannot read returns `400`.

### Path parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `customer_id` | string | required | Customer identifier. Can be either a QuotaStack UUID or your own `external_id` — the server resolves both. For strict external-id resolution, use the `/v1/customer-by-external-id/...` path family instead. |

### Query parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `cursor` | string | optional | Opaque pagination cursor from the previous page's `pagination.next_cursor`. Omit to start at the first page. |
| `limit` | integer | optional | Page size. Default 20, max 100. |

### Response 200

```json
{
  "data": [
    {
      "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a71",
      "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
      "package_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a72",
      "environment": "live",
      "credits_granted": 10000000,
      "price_paid": 1000,
      "currency": "USD",
      "external_payment_id": "pay_xyz789",
      "expires_at": null,
      "status": "completed",
      "metadata": {},
      "created_at": "2026-07-28T09:05:00Z",
      "updated_at": "2026-07-28T09:05:00Z"
    }
  ],
  "pagination": {
    "has_more": false,
    "next_cursor": null
  }
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `data` | array | required |  |
| `data.package_id` | string (uuid) | optional | Which package this purchase came from. If you leave it out: The grant named no package. This [topup](/docs/concepts/topups-and-wallets) is a one-off. |
| `data.credits_granted` | integer (int64) | required | How many credits went to the customer. Unit: millicredits, where 1 credit is 1000 millicredits. |
| `data.price_paid` | integer (int64) | required | What the customer paid you, in your own money. The number is cash, not credits. QuotaStack stores it for your records, and prices nothing from it. Unit: the minor unit of `currency`, so 500 means 5.00 US dollars when `currency` is `USD`. |
| `data.currency` | string | required | Which currency `price_paid` is in. An ISO 4217 code, such as `USD`, `EUR`, or `INR`. |
| `data.external_payment_id` | string | optional | The ID you sent when you made this sale. QuotaStack keeps it word for word. |
| `data.expires_at` | string (date-time) | optional | When these credits stop counting in the balance. If you leave it out: These credits never run out. |
| `data.status` | string | required | Where this purchase stands. `completed` — QuotaStack wrote the sale down. Every [topup](/docs/concepts/topups-and-wallets) from this API reads this way. `refunded` — You gave the money back. No call on this API sets this value. |
| `data.metadata` | object | required | Your own key-value tags. QuotaStack stores them unchanged and returns them on every read. If you leave it out: An empty object is stored instead. |
| `pagination` | object | required | Cursor-based pagination envelope. Returned on every list endpoint. No `total` — cursor-only. |

### See also

- [Topups and wallets](/docs/concepts/topups-and-wallets)
- [Credits](/docs/api/credits)

## List a customer topups by external ID

`GET /v1/customer-by-external-id/{external_id}/topups`

- **Idempotency-Key:** Not used. This is a read.
- **Environment:** Sandbox and Live
- **Auth:** Tenant API key
- **Writes:** Nothing. A read has no side effects.
- **Fires:** No webhooks.

This call returns what one customer bought, looked up by your own ID. The call is the twin of `listCustomerTopups`. Both do the same work and send back the same shape. Only the lookup differs. QuotaStack matches the `external_id` exactly, and never falls back to the QuotaStack UUID. A cursor QuotaStack cannot read returns `400`.

### Path parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `external_id` | string | required | Your own ID for this customer, set when you created it. QuotaStack looks up only this value, never the QuotaStack UUID. If you leave it out: QuotaStack rejects the call. This path segment is required. |

### Query parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `cursor` | string | optional | Opaque pagination cursor from the previous page's `pagination.next_cursor`. Omit to start at the first page. |
| `limit` | integer | optional | Page size. Default 20, max 100. |

### Response 200

```json
{
  "data": [
    {
      "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a71",
      "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
      "package_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a72",
      "environment": "live",
      "credits_granted": 10000000,
      "price_paid": 1000,
      "currency": "USD",
      "external_payment_id": "pay_xyz789",
      "expires_at": null,
      "status": "completed",
      "metadata": {},
      "created_at": "2026-07-28T09:05:00Z",
      "updated_at": "2026-07-28T09:05:00Z"
    }
  ],
  "pagination": {
    "has_more": false,
    "next_cursor": null
  }
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `data` | array | required |  |
| `data.package_id` | string (uuid) | optional | Which package this purchase came from. If you leave it out: The grant named no package. This [topup](/docs/concepts/topups-and-wallets) is a one-off. |
| `data.credits_granted` | integer (int64) | required | How many credits went to the customer. Unit: millicredits, where 1 credit is 1000 millicredits. |
| `data.price_paid` | integer (int64) | required | What the customer paid you, in your own money. The number is cash, not credits. QuotaStack stores it for your records, and prices nothing from it. Unit: the minor unit of `currency`, so 500 means 5.00 US dollars when `currency` is `USD`. |
| `data.currency` | string | required | Which currency `price_paid` is in. An ISO 4217 code, such as `USD`, `EUR`, or `INR`. |
| `data.external_payment_id` | string | optional | The ID you sent when you made this sale. QuotaStack keeps it word for word. |
| `data.expires_at` | string (date-time) | optional | When these credits stop counting in the balance. If you leave it out: These credits never run out. |
| `data.status` | string | required | Where this purchase stands. `completed` — QuotaStack wrote the sale down. Every [topup](/docs/concepts/topups-and-wallets) from this API reads this way. `refunded` — You gave the money back. No call on this API sets this value. |
| `data.metadata` | object | required | Your own key-value tags. QuotaStack stores them unchanged and returns them on every read. If you leave it out: An empty object is stored instead. |
| `pagination` | object | required | Cursor-based pagination envelope. Returned on every list endpoint. No `total` — cursor-only. |

### See also

- [Topups and wallets](/docs/concepts/topups-and-wallets)
- [Credits](/docs/api/credits)
