---
title: "Reservations"
description: "Hold credits before you know the exact cost of a job, then commit what you used or release the hold."
---

# Reservations

Hold credits while a long job runs.

A [reservation](/docs/concepts/reservations) holds credits before you know the
real cost. You reserve an estimate, run the job, then settle the true number
afterward.

Long AI jobs are why this pattern exists. Nobody knows how many tokens a job
will burn until it finishes. A plain charge cannot wait that long.

Reserving locks `estimated_cost` inside `reserved_balance`. `balance` stays
untouched — the hold only blocks those same credits from being spent twice.
`effective_balance` drops by the same amount, since a new charge can never
spend a held credit.

Two ways to close a reservation. Commit it once you know the real cost.
QuotaStack charges `actual_units`, then returns whatever is left over. Release
it when the job fails, or costs nothing. QuotaStack returns the whole hold
and charges nothing.

A hold is not a charge. Commit charges the balance; release gives the hold
back.

Nobody has to close a reservation by hand. Every reservation carries an
`expires_at`. QuotaStack's scheduler sweeps for expired holds once a minute,
and releases them the same way a manual release would.

Already know the cost? Skip the hold. Call `consumeEntitlement` instead.

## Which call do I want?

- You are about to start a long job and want the credits held → `POST /v1/reservations`
- You want to check on a hold before committing or releasing it → `GET /v1/reservations/{id}`
- The job finished and you know what it cost → `POST /v1/reservations/{id}/commit`
- The job failed, or cost nothing → `POST /v1/reservations/{id}/release`
  A hold is not a charge. Credits stay in the balance the whole time it is open; the hold only stops them from being spent twice. Commit charges the balance. Release gives the hold back. Neither one is reversible.
- You need every hold for one customer, not only the one you already have the ID for → `GET /v1/customers/{customer_id}/reservations`

6 of 6 operations documented in full.

## Reserve credits

`POST /v1/reservations`

- **Idempotency-Key:** Required. Returns 422 without it.
- **Environment:** Sandbox and Live
- **Auth:** Tenant API key
- **Writes:** One new [reservation](/docs/concepts/reservations), and one zero-amount [ledger](/docs/concepts/credits) entry, type `reservation`, for the audit trail. Raises `reserved_balance` by `estimated_cost`; `balance` is unchanged.
- **Fires:** [`credit.low_balance`](/docs/api/events/credit.low_balance), [`credit.exhausted`](/docs/api/events/credit.exhausted)

This call holds credits before you know the exact cost of a job. 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. QuotaStack prices `estimated_units` against the metric's active [metering rule](/docs/concepts/metering). QuotaStack holds that price in `reserved_balance`. `balance` itself stays the same. Leave out `ttl_seconds` and the hold stays open for 1800 seconds, 30 minutes. Send your own value, from 1 second up to 86400, 24 hours. A hold lowers `effective_balance`, the same as a real charge. So this call can trigger the low-balance and exhausted [webhooks](/docs/concepts/webhooks) too, best-effort. This reply is not the full `Reservation` object the field table below describes. The real reply adds a nested `account` balance and drops `actual_units`, `actual_cost`, `reference_id`, `committed_at`, and `released_at`. openapi.yaml declares `404` and `402` here. Neither one is real. An unrecognized customer ID never returns `404` — QuotaStack makes the customer instead. A `billable_metric_key` with no active [metering rule](/docs/concepts/metering) returns `500`, not `404`. A real insufficient-credit failure returns `409`, type `insufficient-credits`, never `402`.

### 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 this customer, or your own lenient ID. Send this or `external_customer_id`, never both. If you leave it out: Send `external_customer_id` instead. QuotaStack rejects the call with neither. |
| `external_customer_id` | string | optional | Your own ID for this customer. Looked up, or created, strictly by this value — no UUID fallback. If you leave it out: Send `customer_id` instead. QuotaStack rejects the call with neither. |
| `billable_metric_key` | string | required | Which [billable metric](/docs/concepts/metering) to price the hold against. Needs an active [metering rule](/docs/concepts/metering). If you leave it out: QuotaStack rejects the call. This field is required. |
| `estimated_units` | integer (int64) | required | How many units you expect the job to use. Unit: a count of billable metric units, not credits. If you leave it out: QuotaStack rejects the call. Must be at least 1. Range: 1–∞. |
| `ttl_seconds` | integer | optional | How long the hold stays open before it lapses on its own. Unit: seconds. If you leave it out: The hold stays open for 1800 seconds — 30 minutes — by default. Range: 1–86400. |
| `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",
  "billable_metric_key": "chat_message",
  "estimated_units": 500,
  "ttl_seconds": 1800
}
```

### Response 201

```json
{
  "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a41",
  "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
  "billable_metric_key": "chat_message",
  "estimated_units": 500,
  "estimated_cost": 250000,
  "environment": "live",
  "status": "active",
  "expires_at": "2026-07-27T10:30:00Z",
  "account": {
    "balance": 500000,
    "reserved_balance": 250000,
    "effective_balance": 250000
  },
  "metadata": {},
  "created_at": "2026-07-27T10:00:00Z"
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string (uuid) | required |  |
| `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` |
| `billable_metric_key` | string | required | Which [billable metric](/docs/concepts/metering) this hold is priced against. |
| `estimated_units` | integer (int64) | required | How many units you expect the job to use. QuotaStack prices the hold from this number. Unit: a count of billable metric units, not credits. |
| `estimated_cost` | integer (int64) | required | What the hold costs, at today's price for `estimated_units`. QuotaStack subtracts this from `effective_balance` while the [reservation](/docs/concepts/reservations) stays active. Unit: millicredits, where 1 credit is 1000 millicredits. |
| `actual_units` | integer (int64) | optional | How many units the job really used. `null` until you commit the [reservation](/docs/concepts/reservations). Unit: a count of billable metric units, not credits. |
| `actual_cost` | integer (int64) | optional | What QuotaStack charged for `actual_units`. Priced when you commit, which can differ from the rate used to estimate the hold. `null` until you commit it. Can read higher than what QuotaStack actually collected, if the charge outran both the hold and your free balance. Unit: millicredits. |
| `status` | string | required | The hold's current state, right now. `active` — The hold is open. Commit it, release it, or wait for it to expire. `committed` — You committed it. QuotaStack charged `actual_units` and returned the rest of the hold. `released` — You released it before it was committed. QuotaStack returned the whole hold. `expired` — Nobody closed it in time. QuotaStack's scheduler released the hold on its own. |
| `reference_id` | string (uuid) | required | QuotaStack's own ID for this hold's [ledger](/docs/concepts/credits) entries. The hold, the commit, and any release all share it. |
| `expires_at` | string (date-time) | required | When the hold lapses on its own, if nobody commits or releases it first. Set once, from `ttl_seconds`, at creation. |
| `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 |  |
| `committed_at` | string (date-time) | optional | When you committed it. `null` unless `status` is `committed`. |
| `released_at` | string (date-time) | optional | When the hold was given back. `null` unless `status` is `released` or `expired` — committing with `actual_units: 0` sets `committed_at` instead, not this field. |

### Errors

- `400` — Sending both `customer_id` and `external_customer_id`, or neither, returns this. So does a `customer_id` UUID that belongs to a different tenant. See [bad-request](/docs/api/errors/bad-request).
- `402` — openapi.yaml lists this status here. QuotaStack never sends it. A real failure returns `409` instead, type `insufficient-credits`. This call's own spec entry never lists `409` at all. See [insufficient-credits](/docs/api/errors/insufficient-credits).
- `404` — openapi.yaml lists this status. Nothing here returns it. An unrecognized `customer_id` makes a new customer instead. So does an unrecognized `external_customer_id`. A `billable_metric_key` needs a priced [metering rule](/docs/concepts/metering). Without one, this call returns `500` instead. See [not-found](/docs/api/errors/not-found).

### See also

- [Reservations](/docs/concepts/reservations)
- [Metering](/docs/concepts/metering)
- [Credits](/docs/concepts/credits)
- [Entitlements](/docs/api/entitlements)

## Retrieve a reservation

`GET /v1/reservations/{id}`

- **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 one [reservation](/docs/concepts/reservations) by its own `id`. Check whether a hold is still active before committing or releasing it, or use this to debug one that seems stuck. This lookup is not scoped by environment. A sandbox key can read a hold created under a live key for the same tenant, and the reverse. That gap also applies to `commitReservation` and `releaseReservation`, both of which fetch the same way.

### Path parameters

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

### Response 200

```json
{
  "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a41",
  "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
  "environment": "live",
  "billable_metric_key": "chat_message",
  "estimated_units": 500,
  "estimated_cost": 250000,
  "actual_units": null,
  "actual_cost": null,
  "status": "active",
  "expires_at": "2026-07-27T10:30:00Z",
  "metadata": {},
  "created_at": "2026-07-27T10:00:00Z",
  "committed_at": null,
  "released_at": null
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string (uuid) | required |  |
| `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` |
| `billable_metric_key` | string | required | Which [billable metric](/docs/concepts/metering) this hold is priced against. |
| `estimated_units` | integer (int64) | required | How many units you expect the job to use. QuotaStack prices the hold from this number. Unit: a count of billable metric units, not credits. |
| `estimated_cost` | integer (int64) | required | What the hold costs, at today's price for `estimated_units`. QuotaStack subtracts this from `effective_balance` while the [reservation](/docs/concepts/reservations) stays active. Unit: millicredits, where 1 credit is 1000 millicredits. |
| `actual_units` | integer (int64) | optional | How many units the job really used. `null` until you commit the [reservation](/docs/concepts/reservations). Unit: a count of billable metric units, not credits. |
| `actual_cost` | integer (int64) | optional | What QuotaStack charged for `actual_units`. Priced when you commit, which can differ from the rate used to estimate the hold. `null` until you commit it. Can read higher than what QuotaStack actually collected, if the charge outran both the hold and your free balance. Unit: millicredits. |
| `status` | string | required | The hold's current state, right now. `active` — The hold is open. Commit it, release it, or wait for it to expire. `committed` — You committed it. QuotaStack charged `actual_units` and returned the rest of the hold. `released` — You released it before it was committed. QuotaStack returned the whole hold. `expired` — Nobody closed it in time. QuotaStack's scheduler released the hold on its own. |
| `reference_id` | string (uuid) | required | QuotaStack's own ID for this hold's [ledger](/docs/concepts/credits) entries. The hold, the commit, and any release all share it. |
| `expires_at` | string (date-time) | required | When the hold lapses on its own, if nobody commits or releases it first. Set once, from `ttl_seconds`, at creation. |
| `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 |  |
| `committed_at` | string (date-time) | optional | When you committed it. `null` unless `status` is `committed`. |
| `released_at` | string (date-time) | optional | When the hold was given back. `null` unless `status` is `released` or `expired` — committing with `actual_units: 0` sets `committed_at` instead, not this field. |

### Errors

- `404` — No [reservation](/docs/concepts/reservations) here has this `id`. See [not-found](/docs/api/errors/not-found).

### See also

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

## Commit a reservation

`POST /v1/reservations/{id}/commit`

- **Idempotency-Key:** Required. Returns 422 without it.
- **Environment:** Sandbox and Live
- **Auth:** Tenant API key
- **Writes:** Debits `actual_cost` from the balance and releases the whole hold, in one step. Writes one [ledger](/docs/concepts/credits) entry, type `consumption`, for the debit. Fires `credit.consumed`, plus the low-balance and exhausted alerts — the same as a usage debit. `actual_units: 0` skips all of that. No debit happens, and `credit.consumed` never fires. QuotaStack writes a zero-amount `release` entry instead.
- **Fires:** [`credit.consumed`](/docs/api/events/credit.consumed), [`credit.low_balance`](/docs/api/events/credit.low_balance), [`credit.exhausted`](/docs/api/events/credit.exhausted)

Commit closes an active [reservation](/docs/concepts/reservations) once you know the real cost. QuotaStack prices `actual_units` at today's rate. That rate can differ from the one used to estimate the hold. QuotaStack charges that amount, then returns the rest. Using less than you reserved needs no separate release call. The leftover comes back in this same call. Using more than the hold covers, QuotaStack also tries your free balance for the difference. The call still succeeds if the hold plus your free balance covers `actual_units`. In that case, `actual_cost` in the response is still the full computed number. That can read higher than what QuotaStack actually collected. Send `actual_units: 0` for a job that made nothing. QuotaStack returns the whole hold and charges nothing, the same as `releaseReservation`. But `status` reads `committed` here, not `released`. A negative `actual_units` returns `422`, a status this call does not declare. This reply is not the full `Reservation` object the field table below describes. The real reply uses `reservation_id`, not `id`. The real reply drops most of the other fields too. Send a key unique to this hold, for example `commit:{id}` — the [idempotency](/docs/concepts/idempotency) page's own convention. QuotaStack scopes a key by your tenant and its value alone, never by the reservation in the path. Reuse a key across two holds with the same body. The second call replays the first instead of committing 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 |
|---|---|---|---|
| `id` | string (uuid) | required |  |

### Body parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `actual_units` | integer (int64) | required | How many units the job really used. Unit: a count of billable metric units, not credits. If you leave it out: QuotaStack rejects the call. This field is required; 0 is a valid value and releases the whole hold. Range: 0–∞. |
| `metadata` | object | optional | Your own key-value tags. QuotaStack merges them into this hold's stored tags. If you leave it out: Nothing changes. QuotaStack also discards `metadata` when `actual_units` is 0 — that path never merges it. |

### Request

```json
{
  "actual_units": 420
}
```

### Response 200

```json
{
  "reservation_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a41",
  "status": "committed",
  "estimated_units": 500,
  "actual_units": 420,
  "estimated_cost": 250000,
  "actual_cost": 210000,
  "released": 40000,
  "transaction": {
    "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a32",
    "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
    "delta": -210000,
    "type": "consumption",
    "billable_metric_key": "chat_message",
    "cost_units": 420,
    "created_at": "2026-07-27T10:05:00Z"
  },
  "account": {
    "balance": 290000,
    "reserved_balance": 0,
    "effective_balance": 290000
  }
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string (uuid) | required |  |
| `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` |
| `billable_metric_key` | string | required | Which [billable metric](/docs/concepts/metering) this hold is priced against. |
| `estimated_units` | integer (int64) | required | How many units you expect the job to use. QuotaStack prices the hold from this number. Unit: a count of billable metric units, not credits. |
| `estimated_cost` | integer (int64) | required | What the hold costs, at today's price for `estimated_units`. QuotaStack subtracts this from `effective_balance` while the [reservation](/docs/concepts/reservations) stays active. Unit: millicredits, where 1 credit is 1000 millicredits. |
| `actual_units` | integer (int64) | optional | How many units the job really used. `null` until you commit the [reservation](/docs/concepts/reservations). Unit: a count of billable metric units, not credits. |
| `actual_cost` | integer (int64) | optional | What QuotaStack charged for `actual_units`. Priced when you commit, which can differ from the rate used to estimate the hold. `null` until you commit it. Can read higher than what QuotaStack actually collected, if the charge outran both the hold and your free balance. Unit: millicredits. |
| `status` | string | required | The hold's current state, right now. `active` — The hold is open. Commit it, release it, or wait for it to expire. `committed` — You committed it. QuotaStack charged `actual_units` and returned the rest of the hold. `released` — You released it before it was committed. QuotaStack returned the whole hold. `expired` — Nobody closed it in time. QuotaStack's scheduler released the hold on its own. |
| `reference_id` | string (uuid) | required | QuotaStack's own ID for this hold's [ledger](/docs/concepts/credits) entries. The hold, the commit, and any release all share it. |
| `expires_at` | string (date-time) | required | When the hold lapses on its own, if nobody commits or releases it first. Set once, from `ttl_seconds`, at creation. |
| `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 |  |
| `committed_at` | string (date-time) | optional | When you committed it. `null` unless `status` is `committed`. |
| `released_at` | string (date-time) | optional | When the hold was given back. `null` unless `status` is `released` or `expired` — committing with `actual_units: 0` sets `committed_at` instead, not this field. |

### Used less than reserved

The common case for an AI job. QuotaStack charges the real cost and returns the difference.

```json
{
  "reservation_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a41",
  "status": "committed",
  "estimated_units": 500,
  "actual_units": 420,
  "estimated_cost": 250000,
  "actual_cost": 210000,
  "released": 40000,
  "transaction": {
    "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a32",
    "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
    "delta": -210000,
    "type": "consumption",
    "billable_metric_key": "chat_message",
    "cost_units": 420,
    "created_at": "2026-07-27T10:05:00Z"
  },
  "account": {
    "balance": 290000,
    "reserved_balance": 0,
    "effective_balance": 290000
  }
}
```

### Used exactly what you reserved

Nothing comes back. `released` reads 0.

```json
{
  "reservation_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a41",
  "status": "committed",
  "estimated_units": 500,
  "actual_units": 500,
  "estimated_cost": 250000,
  "actual_cost": 250000,
  "released": 0,
  "transaction": {
    "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a32",
    "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
    "delta": -250000,
    "type": "consumption",
    "billable_metric_key": "chat_message",
    "cost_units": 500,
    "created_at": "2026-07-27T10:05:00Z"
  },
  "account": {
    "balance": 250000,
    "reserved_balance": 0,
    "effective_balance": 250000
  }
}
```

### The job produced nothing

`actual_units: 0` returns the whole hold and charges nothing. `status` reads `committed`, not `released`.

```json
{
  "reservation_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a41",
  "status": "committed",
  "estimated_units": 500,
  "actual_units": 0,
  "estimated_cost": 250000,
  "actual_cost": 0,
  "released": 250000,
  "transaction": {
    "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a32",
    "delta": 0,
    "type": "release",
    "reference_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a41",
    "created_at": "2026-07-27T10:05:00Z"
  },
  "account": {
    "balance": 500000,
    "reserved_balance": 0,
    "effective_balance": 500000
  }
}
```

### Errors

- `402` — openapi.yaml lists this status here. QuotaStack never sends it. A real failure returns `409` instead. See that row below. See [insufficient-credits](/docs/api/errors/insufficient-credits).
- `404` — No [reservation](/docs/concepts/reservations) here has this `id`. See [not-found](/docs/api/errors/not-found).
- `409` — QuotaStack returns this for three reasons. Committing after `expires_at`, before the scheduler's sweep catches up, is `reservation-expired`. That window lasts about a minute. Once the sweep runs, `status` already reads `expired`. Committing then returns the plainer `conflict` cause instead — same root problem, less specific. The hold leaving `active` some other way is `conflict` too — already committed, released, or expired. Reusing an `Idempotency-Key` whose stored request matches this one exactly is `conflict` as well. QuotaStack replays the first response instead of failing. The hold plus your free balance falling short of `actual_cost` is `insufficient-credits`, the third reason. The [reservation](/docs/concepts/reservations) stays active in every case here. See [reservation-expired](/docs/api/errors/reservation-expired).

### See also

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

## Release a reservation

`POST /v1/reservations/{id}/release`

- **Idempotency-Key:** Required. Returns 422 without it.
- **Environment:** Sandbox and Live
- **Auth:** Tenant API key
- **Writes:** Returns the whole hold to the balance and writes one zero-amount [ledger](/docs/concepts/credits) entry, type `release`, for the audit trail. Charges nothing and fires no [webhook](/docs/concepts/webhooks).
- **Fires:** No webhooks.

Release closes an active [reservation](/docs/concepts/reservations) without charging anything. QuotaStack returns the whole `estimated_cost` to the balance. This call takes no request body. Release never checks `expires_at`. Releasing a hold that already expired, but is not yet swept, still succeeds. That produces the same result the sweep would have. This call always sends an empty body, so every release request hashes the same. Send a key unique to this hold, for example `release:{id}`. Reuse a key across two [reservations](/docs/concepts/reservations), and the second call always replays the first. QuotaStack never returns a conflict here, since the body never changes.

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

### Response 200

```json
{
  "reservation_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a41",
  "status": "released",
  "estimated_cost": 250000,
  "released": 250000,
  "transaction": {
    "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a32",
    "delta": 0,
    "type": "release",
    "reference_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a41",
    "created_at": "2026-07-27T10:10:00Z"
  },
  "account": {
    "balance": 500000,
    "reserved_balance": 0,
    "effective_balance": 500000
  }
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string (uuid) | required |  |
| `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` |
| `billable_metric_key` | string | required | Which [billable metric](/docs/concepts/metering) this hold is priced against. |
| `estimated_units` | integer (int64) | required | How many units you expect the job to use. QuotaStack prices the hold from this number. Unit: a count of billable metric units, not credits. |
| `estimated_cost` | integer (int64) | required | What the hold costs, at today's price for `estimated_units`. QuotaStack subtracts this from `effective_balance` while the [reservation](/docs/concepts/reservations) stays active. Unit: millicredits, where 1 credit is 1000 millicredits. |
| `actual_units` | integer (int64) | optional | How many units the job really used. `null` until you commit the [reservation](/docs/concepts/reservations). Unit: a count of billable metric units, not credits. |
| `actual_cost` | integer (int64) | optional | What QuotaStack charged for `actual_units`. Priced when you commit, which can differ from the rate used to estimate the hold. `null` until you commit it. Can read higher than what QuotaStack actually collected, if the charge outran both the hold and your free balance. Unit: millicredits. |
| `status` | string | required | The hold's current state, right now. `active` — The hold is open. Commit it, release it, or wait for it to expire. `committed` — You committed it. QuotaStack charged `actual_units` and returned the rest of the hold. `released` — You released it before it was committed. QuotaStack returned the whole hold. `expired` — Nobody closed it in time. QuotaStack's scheduler released the hold on its own. |
| `reference_id` | string (uuid) | required | QuotaStack's own ID for this hold's [ledger](/docs/concepts/credits) entries. The hold, the commit, and any release all share it. |
| `expires_at` | string (date-time) | required | When the hold lapses on its own, if nobody commits or releases it first. Set once, from `ttl_seconds`, at creation. |
| `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 |  |
| `committed_at` | string (date-time) | optional | When you committed it. `null` unless `status` is `committed`. |
| `released_at` | string (date-time) | optional | When the hold was given back. `null` unless `status` is `released` or `expired` — committing with `actual_units: 0` sets `committed_at` instead, not this field. |

### Errors

- `404` — No [reservation](/docs/concepts/reservations) here has this `id`. See [not-found](/docs/api/errors/not-found).
- `409` — QuotaStack returns this for two reasons. The hold already left `active`, either committed, released, or expired. Or you sent an `Idempotency-Key` that matches an old request. QuotaStack replays the old reply instead of failing. See [conflict](/docs/api/errors/conflict).

### See also

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

## List a customer's reservations

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

- **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 lists every [reservation](/docs/concepts/reservations) for one customer, newest first. Filter by `status`. Leave it out for every status. This lookup is lenient. `customer_id` takes your own `external_id` or QuotaStack's own `id`. This lookup never makes a new customer. An unrecognized `customer_id` returns `404` here. openapi.yaml does not declare that; only `200` is listed. An invalid `cursor` returns `400`, also undeclared. An unrecognized `status` value returns `500`, also undeclared. `status` binds to a raw database column. QuotaStack never checks it first. This call returns the real `Reservation` shape. `reserveCredits`, `commitReservation`, and `releaseReservation` do not.

### 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 |
|---|---|---|---|
| `status` | string | optional | Only holds in this state. If you leave it out: Every status comes back, with no filter. `active` — The hold is open. Commit it, release it, or wait for it to expire. `committed` — You committed it. QuotaStack charged `actual_units` and returned the rest of the hold. `released` — You released it before it was committed. QuotaStack returned the whole hold. `expired` — Nobody closed it in time. QuotaStack's scheduler released the hold on its own. |
| `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-4f6c8e1b3a41",
      "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
      "environment": "live",
      "billable_metric_key": "chat_message",
      "estimated_units": 500,
      "estimated_cost": 250000,
      "actual_units": null,
      "actual_cost": null,
      "status": "active",
      "expires_at": "2026-07-27T10:30:00Z",
      "metadata": {},
      "created_at": "2026-07-27T10:00:00Z",
      "committed_at": null,
      "released_at": null
    }
  ],
  "pagination": {
    "has_more": false,
    "next_cursor": null
  }
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `data` | array | required |  |
| `data.billable_metric_key` | string | required | Which [billable metric](/docs/concepts/metering) this hold is priced against. |
| `data.estimated_units` | integer (int64) | required | How many units you expect the job to use. QuotaStack prices the hold from this number. Unit: a count of billable metric units, not credits. |
| `data.estimated_cost` | integer (int64) | required | What the hold costs, at today's price for `estimated_units`. QuotaStack subtracts this from `effective_balance` while the [reservation](/docs/concepts/reservations) stays active. Unit: millicredits, where 1 credit is 1000 millicredits. |
| `data.actual_units` | integer (int64) | optional | How many units the job really used. `null` until you commit the [reservation](/docs/concepts/reservations). Unit: a count of billable metric units, not credits. |
| `data.actual_cost` | integer (int64) | optional | What QuotaStack charged for `actual_units`. Priced when you commit, which can differ from the rate used to estimate the hold. `null` until you commit it. Can read higher than what QuotaStack actually collected, if the charge outran both the hold and your free balance. Unit: millicredits. |
| `data.status` | string | required | The hold's current state, right now. `active` — The hold is open. Commit it, release it, or wait for it to expire. `committed` — You committed it. QuotaStack charged `actual_units` and returned the rest of the hold. `released` — You released it before it was committed. QuotaStack returned the whole hold. `expired` — Nobody closed it in time. QuotaStack's scheduler released the hold on its own. |
| `data.reference_id` | string (uuid) | required | QuotaStack's own ID for this hold's [ledger](/docs/concepts/credits) entries. The hold, the commit, and any release all share it. |
| `data.expires_at` | string (date-time) | required | When the hold lapses on its own, if nobody commits or releases it first. Set once, from `ttl_seconds`, at creation. |
| `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. |
| `data.committed_at` | string (date-time) | optional | When you committed it. `null` unless `status` is `committed`. |
| `data.released_at` | string (date-time) | optional | When the hold was given back. `null` unless `status` is `released` or `expired` — committing with `actual_units: 0` sets `committed_at` instead, not this field. |
| `pagination` | object | required | Cursor-based pagination envelope. Returned on every list endpoint. No `total` — cursor-only. |

### See also

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

## List a customer's reservations by external ID

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

- **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 is the `external_id` twin of `listCustomerReservations`. Only the lookup differs. This one is strict, with no auto-create. An unrecognized `external_id` returns a real `404` here. openapi.yaml does not declare that either; only `200` is listed. An unrecognized `status` filter value still returns `500`, the same undeclared gap the `id` form has.

### 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-4f6c8e1b3a41",
      "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
      "environment": "live",
      "billable_metric_key": "chat_message",
      "estimated_units": 500,
      "estimated_cost": 250000,
      "actual_units": null,
      "actual_cost": null,
      "status": "active",
      "expires_at": "2026-07-27T10:30:00Z",
      "metadata": {},
      "created_at": "2026-07-27T10:00:00Z",
      "committed_at": null,
      "released_at": null
    }
  ],
  "pagination": {
    "has_more": false,
    "next_cursor": null
  }
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `data` | array | required |  |
| `data.billable_metric_key` | string | required | Which [billable metric](/docs/concepts/metering) this hold is priced against. |
| `data.estimated_units` | integer (int64) | required | How many units you expect the job to use. QuotaStack prices the hold from this number. Unit: a count of billable metric units, not credits. |
| `data.estimated_cost` | integer (int64) | required | What the hold costs, at today's price for `estimated_units`. QuotaStack subtracts this from `effective_balance` while the [reservation](/docs/concepts/reservations) stays active. Unit: millicredits, where 1 credit is 1000 millicredits. |
| `data.actual_units` | integer (int64) | optional | How many units the job really used. `null` until you commit the [reservation](/docs/concepts/reservations). Unit: a count of billable metric units, not credits. |
| `data.actual_cost` | integer (int64) | optional | What QuotaStack charged for `actual_units`. Priced when you commit, which can differ from the rate used to estimate the hold. `null` until you commit it. Can read higher than what QuotaStack actually collected, if the charge outran both the hold and your free balance. Unit: millicredits. |
| `data.status` | string | required | The hold's current state, right now. `active` — The hold is open. Commit it, release it, or wait for it to expire. `committed` — You committed it. QuotaStack charged `actual_units` and returned the rest of the hold. `released` — You released it before it was committed. QuotaStack returned the whole hold. `expired` — Nobody closed it in time. QuotaStack's scheduler released the hold on its own. |
| `data.reference_id` | string (uuid) | required | QuotaStack's own ID for this hold's [ledger](/docs/concepts/credits) entries. The hold, the commit, and any release all share it. |
| `data.expires_at` | string (date-time) | required | When the hold lapses on its own, if nobody commits or releases it first. Set once, from `ttl_seconds`, at creation. |
| `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. |
| `data.committed_at` | string (date-time) | optional | When you committed it. `null` unless `status` is `committed`. |
| `data.released_at` | string (date-time) | optional | When the hold was given back. `null` unless `status` is `released` or `expired` — committing with `actual_units: 0` sets `committed_at` instead, not this field. |
| `pagination` | object | required | Cursor-based pagination envelope. Returned on every list endpoint. No `total` — cursor-only. |

### See also

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