---
title: "Credits"
description: "Balances, grants, adjustments, and the ledger history for one customer."
---

# Credits

What a customer spends. QuotaStack tracks a balance for each one.

Credits are what a customer spends. QuotaStack keeps a balance for every
customer, in every environment.

A balance is not one number in a row. A balance is the sum of every active
[credit block](/docs/concepts/credits). Each block carries its own expiry, its
own priority, and its own record of where it came from.

You add credits with a grant. You spend them by recording usage. QuotaStack
writes every change to a [ledger](/docs/concepts/credits) that only ever grows.

## Which call do I want?

- You have QuotaStack's customer ID → `POST /v1/customers/{customer_id}/credits/grant`
- You have your own user ID → `POST /v1/customer-by-external-id/{external_id}/credits/grant`
- You want to read a balance and you have your own user ID → `GET /v1/customer-by-external-id/{external_id}/credits`
  On a valid ID, both grant endpoints do the same thing. You do not need to fetch the QuotaStack ID first. On an unknown ID, the two forms differ. The QuotaStack-ID form makes a new customer and grants to it. The `external_id` form returns a `404` instead.

10 of 10 operations documented in full.

## Get a customer's credit balance (optionally with blocks).

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

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

A balance is the sum of every active [credit block](/docs/concepts/credits) for one customer. QuotaStack returns one snapshot of the total. Add `include_blocks=true` to also see the blocks behind it.

### 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 |
|---|---|---|---|
| `include_blocks` | boolean | optional | Include the blocks that make up this balance. If you leave it out: QuotaStack skips the block list. |

### Response 200

```json
{
  "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
  "balance": 250000,
  "reserved_balance": 50000,
  "pending_balance": 0,
  "effective_balance": 200000,
  "lifetime_earned": 300000,
  "blocks": []
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string | optional | QuotaStack credit_account UUID. Empty string when no account row exists yet. |
| `tenant_id` | string (uuid) | required |  |
| `customer_id` | string (uuid) | required |  |
| `environment` | string | required | Which environment this resource lives in. Determined by the API key prefix used (`qs_live_…` → live, `qs_test_…` → sandbox). `live` `sandbox` |
| `balance` | integer (int64) | required | What the customer has, before holds. Unit: millicredits, where 1 credit is 1000 millicredits. |
| `reserved_balance` | integer (int64) | required | Credits held by an open [reservation](/docs/concepts/reservations). The customer cannot spend these until the reservation closes. Unit: millicredits. |
| `pending_balance` | integer (int64) | required | Credits in a block that starts later, through `stack_after`. QuotaStack counts them in `balance`, but not in `effective_balance`. Unit: millicredits. |
| `effective_balance` | integer (int64) | required | What a new charge can actually spend: `balance` minus `reserved_balance` minus `pending_balance`. Unit: millicredits. |
| `lifetime_earned` | integer (int64) | required | Total credits ever granted to this customer. Not reduced when credits are spent. Unit: millicredits, where 1 credit is 1000 millicredits. |
| `version` | integer | required |  |
| `updated_at` | string (date-time) | optional | Zero value (`0001-01-01T00:00:00Z`) when no account row exists yet. |
| `blocks` | array | required | Active credit blocks. Always an array (never null). Empty unless `include_blocks=true`. |
| `blocks.original_amount` | integer (int64) | required | How many credits this [credit block](/docs/concepts/credits) started with. Unit: millicredits. |
| `blocks.remaining_amount` | integer (int64) | required | Credits still left in this [credit block](/docs/concepts/credits). Unit: millicredits. |

### Errors

- `401` — Missing or invalid authentication.
- `404` — No customer here has that ID. A brand new customer still gets a `200`. Every balance in it is 0. See [not-found](/docs/api/errors/not-found).
- `429` — Rate limit exceeded.
- `500` — Unexpected server error.

### See also

- [Credits](/docs/concepts/credits)
- [Reservations](/docs/concepts/reservations)

## Grant credits to a customer

`POST /v1/customers/{customer_id}/credits/grant`

- **Idempotency-Key:** Required. Returns 422 without it.
- **Environment:** Sandbox and Live
- **Auth:** Tenant API key
- **Writes:** One new [credit block](/docs/concepts/credits) and one new [ledger](/docs/concepts/credits) entry.
- **Fires:** [`credit.granted`](/docs/api/events/credit.granted)

A grant adds [credits](/docs/concepts/credits) to one customer. QuotaStack creates a new [credit block](/docs/concepts/credits) and writes one [ledger](/docs/concepts/credits) entry. QuotaStack never edits an existing block. An unknown `customer_id` is not an error. QuotaStack creates a new customer instead. The value you sent becomes its `external_id`, not its `id`. QuotaStack assigns it a fresh `id`. Look it up again with the same value, on either path form, and QuotaStack finds it.

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

### Body parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `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. This field is required. Range: 1–∞. |
| `source` | string | required | Why you are adding these credits. QuotaStack stores it on the block and in the audit log. If you leave it out: QuotaStack rejects the call. This field is required. `promotional` — A free gift. Maybe a welcome bonus, or an ad you ran. `compensation` — You are making something right. An outage, or a support case. `referral` — A customer sent a friend your way. `manual` — A person added these by hand. `migration` — You moved these from an old system. |
| `reason` | string | required | Your own words about this grant. QuotaStack shows it in the audit log. If you leave it out: QuotaStack rejects the call. This field is required. |
| `expires_at` | string (date-time) | optional | When this [credit block](/docs/concepts/credits) stops counting toward the balance. If you leave it out: The block never expires. |
| `duration_seconds` | integer (int64) | optional | How long this [credit block](/docs/concepts/credits) lasts, counted from when it starts. If you leave it out: QuotaStack uses `expires_at` instead. Send neither and the block never expires. Send both and QuotaStack returns `422`. The error names `duration_seconds`, and QuotaStack grants nothing. Range: 1–315360000. |
| `stack_after` | object | optional | Starts this block right after another one ends, instead of right now. QuotaStack finds that other block by matching `metadata_match` against existing blocks. `stack_after` requires `duration_seconds`. Send `stack_after` without it and QuotaStack returns `422`, naming `duration_seconds`. If you leave it out: The new block starts now, not after another one. |
| `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 block 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. Within the same number, QuotaStack spends the block closest to expiring first. If you leave it out: By default, it is 0. Range: 0–255. |
| `idempotency_key` (deprecated) | string | optional | QuotaStack throws this field away. Send the value in the `Idempotency-Key` header instead. If you leave it out: Nothing changes. The header is what QuotaStack reads. |
| `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
{
  "credits": 100000,
  "source": "promotional",
  "reason": "Welcome bonus",
  "priority": 0
}
```

### Response 201

```json
{
  "transaction": {
    "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a32",
    "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
    "delta": 100000,
    "type": "adjustment",
    "source": "promotional",
    "credit_block_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a31",
    "created_at": "2026-07-27T10:00:00Z"
  },
  "block": {
    "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a31",
    "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
    "original_amount": 100000,
    "remaining_amount": 100000,
    "source": "promotional",
    "priority": 0,
    "expires_at": null,
    "created_at": "2026-07-27T10:00:00Z"
  },
  "account": {
    "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
    "balance": 100000,
    "reserved_balance": 0,
    "lifetime_earned": 100000,
    "version": 1
  }
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `transaction` | object | required | The [ledger](/docs/concepts/credits) entry this grant wrote. |
| `transaction.delta` | integer (int64) | required | How much this entry changed the balance by. Positive for a credit; negative for a debit. Unit: millicredits, where 1 credit is 1000 millicredits. |
| `transaction.cost_units` | integer (int64) | optional | Not used for a grant. QuotaStack leaves it empty. Unit: a count of metric units, not credits. |
| `block` | object | required | The [credit block](/docs/concepts/credits) this grant created. |
| `block.remaining_amount` | integer (int64) | required | Credits still available in this block. For a fresh grant, the same as `original_amount`. Unit: millicredits. |
| `account` | object | required | The account balance right after this grant. |
| `account.reserved_balance` | integer (int64) | required | Credits held by an open [reservation](/docs/concepts/reservations). Unchanged by this grant. Unit: millicredits. |
| `account.lifetime_earned` | integer (int64) | required | Total credits ever granted to this customer. Not reduced when credits are spent. Unit: millicredits, where 1 credit is 1000 millicredits. |

### Errors

- `400` — The `customer_id` you sent already belongs to a different tenant. Use your own `customer_id`, or send an `external_id` instead. See [bad-request](/docs/api/errors/bad-request).
- `401` — Missing or invalid authentication.
- `404` — An unknown `customer_id` does not trigger this. QuotaStack makes a new customer, and grants the credits to it. See [not-found](/docs/api/errors/not-found).
- `409` — Another grant or adjustment touched the same account at the same time. Send the request again with the same key. A `stack_after` grant with `fallback: reject` and no matching block also returns this. QuotaStack granted nothing in that case. See [conflict](/docs/api/errors/conflict).
- `422` — A field is missing or wrong. The response names the field. Sending `expires_at` and `duration_seconds` together also returns this. See [validation-error](/docs/api/errors/validation-error).
- `429` — Rate limit exceeded.
- `500` — Unexpected server error.

### See also

- [Credits](/docs/concepts/credits)
- [Free tier credits](/docs/cookbook/free-tier-credits)
- [Stacking credit packs](/docs/cookbook/pack-stacking)

## Apply a credit adjustment (positive or negative).

`POST /v1/customers/{customer_id}/credits/adjust`

- **Idempotency-Key:** Required. Returns 422 without it.
- **Environment:** Sandbox and Live
- **Auth:** Tenant API key
- **Writes:** A positive `delta` writes one new [credit block](/docs/concepts/credits) and one new [ledger](/docs/concepts/credits) entry, like a grant. A negative `delta` writes one entry per block it drains, and creates no new block.
- **Fires:** [`credit.granted`](/docs/api/events/credit.granted), [`credit.consumed`](/docs/api/events/credit.consumed), [`credit.low_balance`](/docs/api/events/credit.low_balance), [`credit.exhausted`](/docs/api/events/credit.exhausted)

An adjustment changes a customer's balance by a signed amount, called `delta`. QuotaStack grants credit when `delta` is a positive number, the same as `grantCustomerCredits`. But the new block always gets priority 0, and never expires. Use `grantCustomerCredits` if you need to set either one. QuotaStack takes credit away when `delta` is negative, spending down blocks in order. QuotaStack may write more than one [ledger](/docs/concepts/credits) entry for one debit, one per block it drains from.

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

### Body parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `delta` | integer (int64) | required | How much to change the balance by. A positive number grants credits; a negative number debits them. Unit: millicredits, where 1 credit is 1000 millicredits. If you leave it out: QuotaStack rejects the call if `delta` is 0. An omitted field decodes as 0 too. |
| `source` | string | required | Why you are changing the balance. QuotaStack stores it on the [ledger](/docs/concepts/credits) entry and in the audit log. If you leave it out: QuotaStack rejects the call. This field is required. `promotional` — A free gift. Maybe a welcome bonus, or an ad you ran. `compensation` — You are making something right. An outage, or a support case. `referral` — A customer sent a friend your way. `manual` — A person added these by hand. `migration` — You moved these from an old system. |
| `reason` | string | required | Your own words about this adjustment. QuotaStack shows it in the audit log. If you leave it out: QuotaStack rejects the call. This field is required. |
| `idempotency_key` | string | optional | QuotaStack throws this field away, the same as on a grant. Send the value in the `Idempotency-Key` header instead. If you leave it out: Nothing changes. The header is what QuotaStack reads. |
| `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
{
  "delta": -5000,
  "source": "manual",
  "reason": "Correcting a billing error"
}
```

### Response 200

```json
{
  "transactions": [
    {
      "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a32",
      "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
      "delta": -5000,
      "type": "adjustment",
      "credit_block_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a31",
      "created_at": "2026-07-27T10:05:00Z"
    }
  ],
  "block": null,
  "account": {
    "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
    "balance": 95000,
    "reserved_balance": 0,
    "lifetime_earned": 100000,
    "version": 2
  }
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `transactions` | array | required | One [ledger](/docs/concepts/credits) entry per block this adjustment touched. A positive `delta` always writes exactly one. |
| `transactions.delta` | integer (int64) | required | How much this entry changed the balance by. Positive for a credit; negative for a debit. Unit: millicredits, where 1 credit is 1000 millicredits. |
| `transactions.cost_units` | integer (int64) | optional | Not used for an adjustment. QuotaStack leaves it empty. Unit: a count of metric units, not credits. |
| `block` | unknown | optional | The new [credit block](/docs/concepts/credits) a positive `delta` created. QuotaStack sets this to `null` on a negative `delta`, since nothing new was created. |
| `account` | object | required | The account balance right after this change. |
| `account.reserved_balance` | integer (int64) | required | Credits held by an open [reservation](/docs/concepts/reservations). Unchanged by this adjustment. Unit: millicredits. |
| `account.lifetime_earned` | integer (int64) | required | Total credits ever granted to this customer. Not reduced when credits are spent. Unit: millicredits, where 1 credit is 1000 millicredits. |

### Errors

- `400` — The `customer_id` you sent already belongs to a different tenant. Use your own `customer_id`, or send an `external_id` instead. See [bad-request](/docs/api/errors/bad-request).
- `401` — Missing or invalid authentication.
- `404` — An unknown `customer_id` does not return this by itself. QuotaStack creates the customer first. A negative `delta` against that new, empty account returns this instead. There is nothing yet to debit. See [not-found](/docs/api/errors/not-found).
- `409` — QuotaStack returns this for two reasons. A negative `delta` more than the balance can cover is one. QuotaStack changes nothing in that case. A concurrent grant or adjustment on the same account is the other. Retry with the same key for that one. See [insufficient-credits](/docs/api/errors/insufficient-credits).
- `422` — A field is missing or wrong. The response names the field. A `delta` of 0 also returns this. An omitted `delta` decodes as 0 too. See [validation-error](/docs/api/errors/validation-error).
- `429` — Rate limit exceeded.
- `500` — Unexpected server error.

### See also

- [Credits](/docs/concepts/credits)
- [Low-balance alerts](/docs/cookbook/low-balance-alerts)

## List ledger entries for a customer (paginated).

`GET /v1/customers/{customer_id}/credits/history`

- **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 endpoint lists every [ledger](/docs/concepts/credits) entry for one customer, newest first. Filter by `type`, `source`, `billable_metric_key`, or a date range; each one is optional. An unknown customer gets a `404`. A bad `from` or `to` gets a `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 |
|---|---|---|---|
| `type` | string | optional | Only entries of this kind. If you leave it out: Every kind comes back, with no filter. `plan_grant` — QuotaStack granted this when a [subscription](/docs/concepts/subscriptions) started or renewed. `topup` — A customer bought this once, through a [topup](/docs/concepts/topups-and-wallets). `consumption` — A use of a [billable metric](/docs/concepts/metering) caused this, and QuotaStack set the type. `reservation` — QuotaStack held this amount for an open [reservation](/docs/concepts/reservations). `release` — QuotaStack returned held credit to the balance when a [reservation](/docs/concepts/reservations) ended. `expiry` — A [credit block](/docs/concepts/credits) reached its expiry date, or QuotaStack voided it. `adjustment` — A person changed the balance by hand, through a grant or an adjustment. |
| `source` | string | optional | Only entries with this cause. If you leave it out: Every cause comes back, with no filter. `plan_grant` — QuotaStack set this when a [subscription](/docs/concepts/subscriptions) granted the credits. `promotional` — A free gift. Maybe a welcome bonus, or an ad you ran. `compensation` — You are making something right. An outage, or a support case. `referral` — A customer sent a friend your way. `manual` — A person added these by hand. `topup` — QuotaStack set this when a customer bought credits through a [topup](/docs/concepts/topups-and-wallets). `migration` — You moved these from an old system. |
| `billable_metric_key` | string | optional | Only entries tied to one metric you bill for. If you leave it out: You get entries for every metric you have. |
| `from` | string (date-time) | optional | Only entries created at or after this time. If you leave it out: QuotaStack has no lower bound on time. |
| `to` | string (date-time) | optional | Only entries created before this time. If you leave it out: QuotaStack has no upper bound on time. |
| `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-4f6c8e1b3a32",
      "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
      "delta": -5000,
      "type": "consumption",
      "billable_metric_key": "chat_message",
      "cost_units": 5,
      "created_at": "2026-07-27T10:05:00Z"
    }
  ],
  "pagination": {
    "has_more": false,
    "next_cursor": null
  }
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `data` | array | required | One entry per balance change, newest first. |
| `data.delta` | integer (int64) | required | How much this entry changed the balance by. Positive for a credit; negative for a debit. Unit: millicredits, where 1 credit is 1000 millicredits. |
| `data.cost_units` | integer (int64) | optional | How much of a metric this entry used. Empty unless it comes from a [billable metric](/docs/concepts/metering). Unit: a count of metric units, not credits. |
| `pagination` | object | required | Cursor-based pagination envelope. Returned on every list endpoint. No `total` — cursor-only. |

### Errors

- `401` — Missing or invalid authentication.
- `429` — Rate limit exceeded.
- `500` — Unexpected server error.

### See also

- [Credits](/docs/concepts/credits)

## Per-metric overage totals for a customer over a period.

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

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

[Overage](/docs/concepts/credits) is credit spent past the balance, under an `allow` or `notify` rule. This endpoint adds it up by [billable metric](/docs/concepts/metering), over one time window. QuotaStack never makes a customer for a read. An unknown customer gets a `404`.

### 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 |
|---|---|---|---|
| `from` | string (date-time) | required | Return entries made at this moment or later. An RFC 3339 timestamp. If you leave it out: You start at the oldest entry. |
| `to` | string (date-time) | required | Return entries made before this moment. The moment itself is left out. If you leave it out: You run up to the newest entry. |

### Response 200

```json
{
  "data": [
    {
      "billable_metric_key": "chat_message",
      "overage_mc": 5000
    }
  ],
  "total_overage_mc": 5000,
  "period": {
    "from": "2026-07-01T00:00:00Z",
    "to": "2026-08-01T00:00:00Z"
  }
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `data` | array | required | Per-billable-metric overage totals, sorted by metric key. |
| `data.overage_mc` | integer (int64) | required | How much this metric ran over, in this window. Unit: millicredits. |
| `total_overage_mc` | integer (int64) | required | The sum of every metric's [overage](/docs/concepts/credits) in the window. |
| `period` | object | required | The half-open window `[from, to)` that was queried. |

### Errors

- `401` — Missing or invalid authentication.
- `404` — No customer here has that ID. See [not-found](/docs/api/errors/not-found).
- `422` — `from` or `to` is missing, or not a valid timestamp. `to` must come after `from`. QuotaStack rejects an equal or earlier value. See [validation-error](/docs/api/errors/validation-error).
- `429` — Rate limit exceeded.
- `500` — Unexpected server error.

### See also

- [Credits](/docs/concepts/credits)
- [Overage in arrears](/docs/cookbook/overage-billing-in-arrears)

## Get a customer's credit balance by your `external_id`.

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

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

A balance is the sum of every active [credit block](/docs/concepts/credits) for one customer, looked up by your own `external_id`. QuotaStack returns one snapshot of the total. Add `include_blocks=true` to also see the blocks behind it.

### 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 |
|---|---|---|---|
| `include_blocks` | boolean | optional | Include the blocks that make up this balance. If you leave it out: QuotaStack skips the block list. |

### Response 200

```json
{
  "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
  "balance": 250000,
  "reserved_balance": 50000,
  "pending_balance": 0,
  "effective_balance": 200000,
  "lifetime_earned": 300000,
  "blocks": []
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string | optional | QuotaStack credit_account UUID. Empty string when no account row exists yet. |
| `tenant_id` | string (uuid) | required |  |
| `customer_id` | string (uuid) | required |  |
| `environment` | string | required | Which environment this resource lives in. Determined by the API key prefix used (`qs_live_…` → live, `qs_test_…` → sandbox). `live` `sandbox` |
| `balance` | integer (int64) | required | What the customer has, before holds. Unit: millicredits, where 1 credit is 1000 millicredits. |
| `reserved_balance` | integer (int64) | required | Credits held by an open [reservation](/docs/concepts/reservations). The customer cannot spend these until the reservation closes. Unit: millicredits. |
| `pending_balance` | integer (int64) | required | Credits in a block that starts later, through `stack_after`. QuotaStack counts them in `balance`, but not in `effective_balance`. Unit: millicredits. |
| `effective_balance` | integer (int64) | required | What a new charge can actually spend: `balance` minus `reserved_balance` minus `pending_balance`. Unit: millicredits. |
| `lifetime_earned` | integer (int64) | required | Total credits ever granted to this customer. Not reduced when credits are spent. Unit: millicredits, where 1 credit is 1000 millicredits. |
| `version` | integer | required |  |
| `updated_at` | string (date-time) | optional | Zero value (`0001-01-01T00:00:00Z`) when no account row exists yet. |
| `blocks` | array | required | Active credit blocks. Always an array (never null). Empty unless `include_blocks=true`. |
| `blocks.original_amount` | integer (int64) | required | How many credits this [credit block](/docs/concepts/credits) started with. Unit: millicredits. |
| `blocks.remaining_amount` | integer (int64) | required | Credits still left in this [credit block](/docs/concepts/credits). Unit: millicredits. |

### Errors

- `401` — Missing or invalid authentication.
- `404` — No customer here has that `external_id`. A brand new customer still gets a `200`. Every balance in it is 0. See [not-found](/docs/api/errors/not-found).
- `429` — Rate limit exceeded.
- `500` — Unexpected server error.

### See also

- [Credits](/docs/concepts/credits)
- [Reservations](/docs/concepts/reservations)

## Grant credits to a customer by external ID

`POST /v1/customer-by-external-id/{external_id}/credits/grant`

- **Idempotency-Key:** Required. Returns 422 without it.
- **Environment:** Sandbox and Live
- **Auth:** Tenant API key
- **Writes:** One new [credit block](/docs/concepts/credits) and one new [ledger](/docs/concepts/credits) entry.
- **Fires:** [`credit.granted`](/docs/api/events/credit.granted)

A grant adds [credits](/docs/concepts/credits) to one customer, looked up by your own `external_id`. QuotaStack creates a new [credit block](/docs/concepts/credits) and writes one [ledger](/docs/concepts/credits) entry, exactly like `grantCustomerCredits`. QuotaStack never edits an existing block.

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

### Body parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `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. This field is required. Range: 1–∞. |
| `source` | string | required | Why you are adding these credits. QuotaStack stores it on the block and in the audit log. If you leave it out: QuotaStack rejects the call. This field is required. `promotional` — A free gift. Maybe a welcome bonus, or an ad you ran. `compensation` — You are making something right. An outage, or a support case. `referral` — A customer sent a friend your way. `manual` — A person added these by hand. `migration` — You moved these from an old system. |
| `reason` | string | required | Your own words about this grant. QuotaStack shows it in the audit log. If you leave it out: QuotaStack rejects the call. This field is required. |
| `expires_at` | string (date-time) | optional | When this [credit block](/docs/concepts/credits) stops counting toward the balance. If you leave it out: The block never expires. |
| `duration_seconds` | integer (int64) | optional | How long this [credit block](/docs/concepts/credits) lasts, counted from when it starts. If you leave it out: QuotaStack uses `expires_at` instead. Send neither and the block never expires. Send both and QuotaStack returns `422`. The error names `duration_seconds`, and QuotaStack grants nothing. Range: 1–315360000. |
| `stack_after` | object | optional | Starts this block right after another one ends, instead of right now. QuotaStack finds that other block by matching `metadata_match` against existing blocks. `stack_after` requires `duration_seconds`. Send `stack_after` without it and QuotaStack returns `422`, naming `duration_seconds`. If you leave it out: The new block starts now, not after another one. |
| `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 block 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. Within the same number, QuotaStack spends the block closest to expiring first. If you leave it out: By default, it is 0. Range: 0–255. |
| `idempotency_key` (deprecated) | string | optional | QuotaStack throws this field away. Send the value in the `Idempotency-Key` header instead. If you leave it out: Nothing changes. The header is what QuotaStack reads. |
| `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
{
  "credits": 100000,
  "source": "promotional",
  "reason": "Welcome bonus",
  "priority": 0
}
```

### Response 201

```json
{
  "transaction": {
    "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a32",
    "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
    "delta": 100000,
    "type": "adjustment",
    "source": "promotional",
    "credit_block_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a31",
    "created_at": "2026-07-27T10:00:00Z"
  },
  "block": {
    "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a31",
    "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
    "original_amount": 100000,
    "remaining_amount": 100000,
    "source": "promotional",
    "priority": 0,
    "expires_at": null,
    "created_at": "2026-07-27T10:00:00Z"
  },
  "account": {
    "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
    "balance": 100000,
    "reserved_balance": 0,
    "lifetime_earned": 100000,
    "version": 1
  }
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `transaction` | object | required | The [ledger](/docs/concepts/credits) entry this grant wrote. |
| `transaction.delta` | integer (int64) | required | How much this entry changed the balance by. Positive for a credit; negative for a debit. Unit: millicredits, where 1 credit is 1000 millicredits. |
| `transaction.cost_units` | integer (int64) | optional | Not used for a grant. QuotaStack leaves it empty. Unit: a count of metric units, not credits. |
| `block` | object | required | The [credit block](/docs/concepts/credits) this grant created. |
| `block.remaining_amount` | integer (int64) | required | Credits still available in this block. For a fresh grant, the same as `original_amount`. Unit: millicredits. |
| `account` | object | required | The account balance right after this grant. |
| `account.reserved_balance` | integer (int64) | required | Credits held by an open [reservation](/docs/concepts/reservations). Unchanged by this grant. Unit: millicredits. |
| `account.lifetime_earned` | integer (int64) | required | Total credits ever granted to this customer. Not reduced when credits are spent. Unit: millicredits, where 1 credit is 1000 millicredits. |

### Errors

- `400` — Malformed request.
- `401` — Missing or invalid authentication.
- `404` — No customer here has that `external_id`. See [not-found](/docs/api/errors/not-found).
- `409` — Another grant or adjustment touched the same account at the same time. Send the request again with the same key. A `stack_after` grant with `fallback: reject` and no matching block also returns this. QuotaStack granted nothing in that case. See [conflict](/docs/api/errors/conflict).
- `422` — A field is missing or wrong. The response names the field. Sending `expires_at` and `duration_seconds` together also returns this. See [validation-error](/docs/api/errors/validation-error).
- `429` — Rate limit exceeded.
- `500` — Unexpected server error.

### See also

- [Credits](/docs/concepts/credits)
- [Free tier credits](/docs/cookbook/free-tier-credits)
- [Stacking credit packs](/docs/cookbook/pack-stacking)

## Apply a credit adjustment by your `external_id`.

`POST /v1/customer-by-external-id/{external_id}/credits/adjust`

- **Idempotency-Key:** Required. Returns 422 without it.
- **Environment:** Sandbox and Live
- **Auth:** Tenant API key
- **Writes:** A positive `delta` writes one new [credit block](/docs/concepts/credits) and one new [ledger](/docs/concepts/credits) entry, like a grant. A negative `delta` writes one entry per block it drains, and creates no new block.
- **Fires:** [`credit.granted`](/docs/api/events/credit.granted), [`credit.consumed`](/docs/api/events/credit.consumed), [`credit.low_balance`](/docs/api/events/credit.low_balance), [`credit.exhausted`](/docs/api/events/credit.exhausted)

An adjustment changes a customer's balance by a signed amount, called `delta`, looked up by your own `external_id`. QuotaStack grants credit when `delta` is a positive number, the same as `grantCustomerCreditsByExternalId`. But the new block always gets priority 0, and never expires. Use `grantCustomerCreditsByExternalId` if you need to set either one. QuotaStack takes credit away when `delta` is negative, spending down blocks in order. QuotaStack may write more than one [ledger](/docs/concepts/credits) entry for one debit, one per block it drains from.

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

### Body parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `delta` | integer (int64) | required | How much to change the balance by. A positive number grants credits; a negative number debits them. Unit: millicredits, where 1 credit is 1000 millicredits. If you leave it out: QuotaStack rejects the call if `delta` is 0. An omitted field decodes as 0 too. |
| `source` | string | required | Why you are changing the balance. QuotaStack stores it on the [ledger](/docs/concepts/credits) entry and in the audit log. If you leave it out: QuotaStack rejects the call. This field is required. `promotional` — A free gift. Maybe a welcome bonus, or an ad you ran. `compensation` — You are making something right. An outage, or a support case. `referral` — A customer sent a friend your way. `manual` — A person added these by hand. `migration` — You moved these from an old system. |
| `reason` | string | required | Your own words about this adjustment. QuotaStack shows it in the audit log. If you leave it out: QuotaStack rejects the call. This field is required. |
| `idempotency_key` | string | optional | QuotaStack throws this field away, the same as on a grant. Send the value in the `Idempotency-Key` header instead. If you leave it out: Nothing changes. The header is what QuotaStack reads. |
| `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
{
  "delta": -5000,
  "source": "manual",
  "reason": "Correcting a billing error"
}
```

### Response 200

```json
{
  "transactions": [
    {
      "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a32",
      "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
      "delta": -5000,
      "type": "adjustment",
      "credit_block_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a31",
      "created_at": "2026-07-27T10:05:00Z"
    }
  ],
  "block": null,
  "account": {
    "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
    "balance": 95000,
    "reserved_balance": 0,
    "lifetime_earned": 100000,
    "version": 2
  }
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `transactions` | array | required | One [ledger](/docs/concepts/credits) entry per block this adjustment touched. A positive `delta` always writes exactly one. |
| `transactions.delta` | integer (int64) | required | How much this entry changed the balance by. Positive for a credit; negative for a debit. Unit: millicredits, where 1 credit is 1000 millicredits. |
| `transactions.cost_units` | integer (int64) | optional | Not used for an adjustment. QuotaStack leaves it empty. Unit: a count of metric units, not credits. |
| `block` | unknown | optional | The new [credit block](/docs/concepts/credits) a positive `delta` created. QuotaStack sets this to `null` on a negative `delta`, since nothing new was created. |
| `account` | object | required | The account balance right after this change. |
| `account.reserved_balance` | integer (int64) | required | Credits held by an open [reservation](/docs/concepts/reservations). Unchanged by this adjustment. Unit: millicredits. |
| `account.lifetime_earned` | integer (int64) | required | Total credits ever granted to this customer. Not reduced when credits are spent. Unit: millicredits, where 1 credit is 1000 millicredits. |

### Errors

- `400` — Malformed request.
- `401` — Missing or invalid authentication.
- `404` — No customer here has that `external_id`. A customer with no credit yet gets this too, if `delta` is negative. See [not-found](/docs/api/errors/not-found).
- `409` — QuotaStack returns this for two reasons. A negative `delta` more than the balance can cover is one. QuotaStack changes nothing in that case. A concurrent grant or adjustment on the same account is the other. Retry with the same key for that one. See [insufficient-credits](/docs/api/errors/insufficient-credits).
- `422` — A field is missing or wrong. The response names the field. A `delta` of 0 also returns this. An omitted `delta` decodes as 0 too. See [validation-error](/docs/api/errors/validation-error).
- `429` — Rate limit exceeded.
- `500` — Unexpected server error.

### See also

- [Credits](/docs/concepts/credits)
- [Low-balance alerts](/docs/cookbook/low-balance-alerts)

## List ledger entries by your `external_id`.

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

- **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 endpoint is the `external_id` twin of `listCustomerCreditsHistory`, newest first too. The same `type`, `source`, `billable_metric_key`, `from`, and `to` filters work here too, though they are not listed below. An unknown `external_id` gets a `404`. A bad `from` or `to` gets a `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-4f6c8e1b3a32",
      "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
      "delta": -5000,
      "type": "consumption",
      "billable_metric_key": "chat_message",
      "cost_units": 5,
      "created_at": "2026-07-27T10:05:00Z"
    }
  ],
  "pagination": {
    "has_more": false,
    "next_cursor": null
  }
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `data` | array | required | One entry per balance change, newest first. |
| `data.delta` | integer (int64) | required | How much this entry changed the balance by. Positive for a credit; negative for a debit. Unit: millicredits, where 1 credit is 1000 millicredits. |
| `data.cost_units` | integer (int64) | optional | How much of a metric this entry used. Empty unless it comes from a [billable metric](/docs/concepts/metering). Unit: a count of metric units, not credits. |
| `pagination` | object | required | Cursor-based pagination envelope. Returned on every list endpoint. No `total` — cursor-only. |

### Errors

- `401` — Missing or invalid authentication.
- `429` — Rate limit exceeded.
- `500` — Unexpected server error.

### See also

- [Credits](/docs/concepts/credits)

## Per-metric overage totals by your `external_id` over a period.

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

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

[Overage](/docs/concepts/credits) is credit spent past the balance, under an `allow` or `notify` rule. This endpoint is the `external_id` twin of `getCustomerOverage`. The window and the totals work the same way. Only the lookup differs. QuotaStack never makes a customer for a read. An unknown `external_id` gets a `404`.

### 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 |
|---|---|---|---|
| `from` | string (date-time) | required | Return entries made at this moment or later. An RFC 3339 timestamp. If you leave it out: You start at the oldest entry. |
| `to` | string (date-time) | required | Return entries made before this moment. The moment itself is left out. If you leave it out: You run up to the newest entry. |

### Response 200

```json
{
  "data": [
    {
      "billable_metric_key": "chat_message",
      "overage_mc": 5000
    }
  ],
  "total_overage_mc": 5000,
  "period": {
    "from": "2026-07-01T00:00:00Z",
    "to": "2026-08-01T00:00:00Z"
  }
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `data` | array | required | Per-billable-metric overage totals, sorted by metric key. |
| `data.overage_mc` | integer (int64) | required | How much this metric ran over, in this window. Unit: millicredits. |
| `total_overage_mc` | integer (int64) | required | The sum of every metric's [overage](/docs/concepts/credits) in the window. |
| `period` | object | required | The half-open window `[from, to)` that was queried. |

### Errors

- `401` — Missing or invalid authentication.
- `404` — No customer here has that `external_id`. See [not-found](/docs/api/errors/not-found).
- `422` — `from` or `to` is missing, or not a valid timestamp. `to` must come after `from`. QuotaStack rejects an equal or earlier value. See [validation-error](/docs/api/errors/validation-error).
- `429` — Rate limit exceeded.
- `500` — Unexpected server error.

### See also

- [Credits](/docs/concepts/credits)
- [Overage in arrears](/docs/cookbook/overage-billing-in-arrears)
