---
title: "Plans"
description: "What a customer can buy, and what it grants — credits, feature flags, and usage caps, bundled into one purchasable unit."
---

# Plans

What a customer can buy, and what it grants.

A plan names one product tier, like "Pro" or "Team." A plan holds no price
and no billing details of its own.

A [plan variant](/docs/concepts/plan-variant-entitlements) is what a customer
actually subscribes to. "Pro, billed monthly" and "Pro, billed yearly" are
two variants of the same "Pro" plan. Each variant sets its own billing cycle,
trial length, and billing mode. A variant can carry recurring
[credit grants](/docs/concepts/subscriptions) and its own
[entitlements](/docs/concepts/entitlements).

## Which call do I want?

- You are naming a product tier, like "Pro" or "Team" → `POST /v1/plans`
- You are setting a billing cycle, trial length, or billing mode for that tier → `POST /v1/plans/{id}/variants`
  A plan holds no price or billing details. A variant does. "Pro" is a plan; "Pro, billed monthly" is one of its variants.
- You want every subscriber on a variant to receive credits on a schedule → `POST /v1/plans/{id}/variants/{vid}/grants`
  This call only defines the schedule. The scheduler grants the credits, at each renewal — not this call.
- You want every subscriber on a variant to get a feature or cap that credits do not price → `POST /v1/plans/{id}/variants/{vid}/entitlements`
  Use a [metering rule](/docs/concepts/metering) instead when the feature should cost credits per use, not a flat switch.
- You want a plan, its variants, and their credit grants in one response → `GET /v1/plans`
  Add `include_variants=true` to see them. `getPlan` alone never returns variants; `listPlanVariants` alone never returns grants.
- You already have a plan variant’s `id` and only need that one variant → `GET /v1/plan-variants/{id}`
  Reads `/v1/plan-variants/{id}` directly, with no plan `id` needed. `listPlanVariants` needs the parent plan’s `id` too.

13 of 13 operations documented in full.

## List plans

`GET /v1/plans`

- **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 plan for your tenant, newest first. Left out, `status` defaults to `active` only. Add `status=retired` to see retired plans too. Add `include_variants=true` to nest each plan’s variants and their credit grants in one response. That one call is the only way to see all three levels at once. A bad `cursor` returns `400`. openapi.yaml does not declare that status here.

### Query parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `status` | string | optional | Only plans in this state. If you leave it out: QuotaStack returns only `active` plans by default. Add `status=retired` to also see retired ones. `active` — The normal state. Nothing here blocks a new variant or a new [subscription](/docs/concepts/subscriptions). `retired` — A lifecycle flag only. Creating a variant under this plan still works — retiring does not lock it. |
| `include_variants` | boolean | optional | Nest each plan’s variants, and each variant’s credit grants, in the response. If you leave it out: Left out, `data[].variants` is an empty array. |
| `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-4f6c8e1b3a11",
      "name": "Pro",
      "is_default": true,
      "status": "active",
      "metadata": {},
      "created_at": "2026-07-20T09:00:00Z",
      "updated_at": "2026-07-20T09:00:00Z",
      "variants": [
        {
          "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a12",
          "plan_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a11",
          "billing_cycle": "monthly",
          "trial_days": 0,
          "status": "active",
          "billing_mode": "prepaid",
          "renewal_due_days": 3,
          "grace_period_days": 3,
          "allow_usage_while_overdue": true,
          "metadata": {},
          "created_at": "2026-07-20T09:05:00Z",
          "updated_at": "2026-07-20T09:05:00Z",
          "grants": [
            {
              "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a61",
              "plan_variant_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a12",
              "credits": 10000,
              "grant_interval": "monthly",
              "expires_after_seconds": null,
              "accumulation_cap": 15000,
              "rollover_percentage": 50,
              "max_rollover_cycles": 3,
              "grant_type": "recurring",
              "source": "promotional",
              "metadata": null,
              "created_at": "2026-07-20T09:10:00Z",
              "updated_at": "2026-07-20T09:10:00Z"
            }
          ]
        }
      ]
    }
  ],
  "pagination": {
    "has_more": false,
    "next_cursor": null
  }
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `data` | array | required | One row per plan, newest first. |
| `data.name` | string | required | The tier’s name, shown in the dashboard and in every read. |
| `data.is_default` | boolean | required | Whether new customers land on this plan when nothing else applies. |
| `data.status` | string | required | This plan is `active`, or it is `retired`. `active` — The normal state. Nothing here blocks a new variant or a new [subscription](/docs/concepts/subscriptions). `retired` — A lifecycle flag only. Creating a variant under this plan still works — retiring does not lock it. |
| `data.variants` | array | required | Every variant under this plan. Empty unless you sent `include_variants=true`. |
| `data.variants.billing_cycle` | string | required | How often this variant renews. Fixed forever once the variant is created. `daily` — Renews once a day. `weekly` — Renews once a week. `monthly` — Renews on the same day each month. `yearly` — Renews on the same day each year. `one_time` — Never renews. One purchase, and nothing repeats after it. |
| `data.variants.status` | string | required | This variant is `active`, or it is `retired`. `active` — The normal state. `retired` — A lifecycle flag only. This variant keeps its [subscriptions](/docs/concepts/subscriptions), grants, and [entitlements](/docs/concepts/entitlements) exactly as they were. |
| `data.variants.billing_mode` | string | required | Who advances the billing period. Fixed forever once the variant is created. `prepaid` — You call [renew](/docs/concepts/subscriptions) after you charge the customer. QuotaStack waits for that call before it moves the period forward. `postpaid` — QuotaStack moves the period forward on its own. QuotaStack then reports usage back to you, to invoice. |
| `data.variants.grants` | array | optional | Every credit grant tied to this variant. |
| `data.variants.grants.credits` | integer (int64) | required | How many credits this grant issues each time it fires. Unit: millicredits, where 1 credit is 1000 millicredits. If you leave it out: QuotaStack rejects the call. This field is required, and must be more than zero. |
| `data.variants.grants.grant_interval` | string | required | When this grant fires. Use one of the five keywords below, or an ISO 8601 duration like `PT5H` for a custom cadence that runs on its own clock, apart from billing. QuotaStack accepts nothing shorter than 5 minutes. See [subscriptions](/docs/concepts/subscriptions) for the full duration rules. If you leave it out: QuotaStack rejects the call. This field is required. |
| `data.variants.grants.grant_type` | string | required | How often this grant fires overall. Pairing `recurring` with `grant_interval: on_activation` is invalid. Use `one_time` for a grant that fires only at activation. If you leave it out: QuotaStack rejects the call. This field is required. `recurring` — Fires every time `grant_interval` comes around, for as long as the [subscription](/docs/concepts/subscriptions) stays active. `one_time` — Fires exactly once, on `grant_interval`’s next turn, then never again. `trial` — Fires once, meant for a [subscription](/docs/concepts/subscriptions)’s trial period. |
| `data.variants.grants.source` | string | required | Why this grant exists. If you leave it out: By default, it is `plan_grant`. `plan_grant` — The default. Use this for a grant tied to the variant’s normal schedule. `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. |
| `data.variants.grants.metadata` | object | required | QuotaStack always returns `null` here. There is no way to attach metadata to a credit grant through this API. |
| `pagination` | object | required | Cursor-based pagination envelope. Returned on every list endpoint. No `total` — cursor-only. |

### See also

- [Plan-variant entitlements](/docs/concepts/plan-variant-entitlements)

## Create a plan

`POST /v1/plans`

- **Idempotency-Key:** Required. Returns 422 without it.
- **Environment:** Sandbox and Live
- **Auth:** Tenant API key
- **Writes:** One new plan, `status: active`. If `is_default` is `true`, QuotaStack also clears the previous default plan’s flag.
- **Fires:** No webhooks.

A plan names one product tier, like "Pro" or "Team." A plan holds no price and no billing cadence — add a [plan variant](/docs/concepts/plan-variant-entitlements) for that. Sending `is_default: true` also clears the tenant’s old default plan, in this same call.

### Header parameters

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

### Body parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `name` | string | required | The tier’s name. If you leave it out: QuotaStack rejects the call. This field is required. |
| `is_default` | boolean | optional | Whether new customers land on this plan when nothing else applies. Making this plan the default clears any previous one. If you leave it out: By default, it is `false`. |
| `metadata` | object | optional | Your own key-value tags. QuotaStack stores them unchanged and returns them on every read. If you leave it out: Left out, QuotaStack stores `{}`. |

### Request

```json
{
  "name": "Pro"
}
```

### Response 201

```json
{
  "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a11",
  "name": "Pro",
  "is_default": false,
  "status": "active",
  "metadata": {},
  "created_at": "2026-07-20T09:00:00Z",
  "updated_at": "2026-07-20T09:00:00Z"
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string (uuid) | required |  |
| `tenant_id` | string (uuid) | required |  |
| `name` | string | required | The tier’s name, shown in the dashboard and in every read. |
| `is_default` | boolean | required | Whether new customers land on this plan when nothing else applies. |
| `status` | string | required | This plan is `active`, or it is `retired`. `active` — The normal state. Nothing here blocks a new variant or a new [subscription](/docs/concepts/subscriptions). `retired` — A lifecycle flag only. Creating a variant under this plan still works — retiring does not lock it. |
| `metadata` | object | required |  |
| `created_at` | string (date-time) | required |  |
| `updated_at` | string (date-time) | required |  |

### Errors

- `422` — `name` is missing or empty. The response names the field. See [validation-error](/docs/api/errors/validation-error).

### See also

- [Plan-variant entitlements](/docs/concepts/plan-variant-entitlements)
- [Subscriptions](/docs/concepts/subscriptions)

## Retrieve a plan

`GET /v1/plans/{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 endpoint returns one plan by its `id`. The reply never includes variants — list `listPlanVariants` on its own, or call `listPlans` with `include_variants=true`.

### Path parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string (uuid) | required | The plan’s UUID. If you leave it out: QuotaStack rejects the call. This path segment is required. |

### Response 200

```json
{
  "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a11",
  "name": "Pro",
  "is_default": false,
  "status": "active",
  "metadata": {},
  "created_at": "2026-07-20T09:00:00Z",
  "updated_at": "2026-07-20T09:00:00Z"
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string (uuid) | required |  |
| `tenant_id` | string (uuid) | required |  |
| `name` | string | required | The tier’s name, shown in the dashboard and in every read. |
| `is_default` | boolean | required | Whether new customers land on this plan when nothing else applies. |
| `status` | string | required | This plan is `active`, or it is `retired`. `active` — The normal state. Nothing here blocks a new variant or a new [subscription](/docs/concepts/subscriptions). `retired` — A lifecycle flag only. Creating a variant under this plan still works — retiring does not lock it. |
| `metadata` | object | required |  |
| `created_at` | string (date-time) | required |  |
| `updated_at` | string (date-time) | required |  |

### Errors

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

### See also

- [Plan-variant entitlements](/docs/concepts/plan-variant-entitlements)

## Update a plan

`PATCH /v1/plans/{id}`

- **Idempotency-Key:** Required. Returns 422 without it.
- **Environment:** Sandbox and Live
- **Auth:** Tenant API key
- **Writes:** Replaces whichever of `name`, `status`, `is_default`, and `metadata` you send; `metadata` is replaced outright, not merged. Setting `is_default: true` also clears the previous default plan’s flag, in the same call.
- **Fires:** No webhooks.

This endpoint changes one or more fields on an existing plan. A field you skip keeps its old value. Setting `is_default` to `true` clears the old default plan’s flag; nothing else about that plan changes. A missing plan returns `404`. Retiring the default plan returns `409`. A bad `status`, or a blank `name`, returns `422`. openapi.yaml declares only `200` here, so none of the three shows below.

### 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 | The plan’s UUID. If you leave it out: QuotaStack rejects the call. This path segment is required. |

### Body parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `name` | string | optional | The tier’s new name. Unlike a customer’s `display_name`, an empty string is rejected — a plan always needs a real name. If you leave it out: The old value stays. QuotaStack changes nothing. |
| `status` | string | optional | This plan’s new lifecycle state. If you leave it out: The old value stays. QuotaStack changes nothing. `active` — The normal state. Nothing here blocks a new variant or a new [subscription](/docs/concepts/subscriptions). `retired` — A lifecycle flag only. Creating a variant under this plan still works — retiring does not lock it. `null` |
| `is_default` | boolean | optional | Set `true` to make this plan the default, clearing whichever plan held that flag before. If you leave it out: The old value stays. QuotaStack changes nothing. |
| `metadata` | object | optional | Your own key-value tags. Sending any value here replaces the whole stored object — QuotaStack does not merge keys in. If you leave it out: The old value stays. QuotaStack changes nothing. |

### Request

```json
{
  "is_default": true
}
```

### Response 200

```json
{
  "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a11",
  "name": "Pro",
  "is_default": true,
  "status": "active",
  "metadata": {},
  "created_at": "2026-07-20T09:00:00Z",
  "updated_at": "2026-07-20T09:00:00Z"
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string (uuid) | required |  |
| `tenant_id` | string (uuid) | required |  |
| `name` | string | required | The tier’s name, shown in the dashboard and in every read. |
| `is_default` | boolean | required | Whether new customers land on this plan when nothing else applies. |
| `status` | string | required | This plan is `active`, or it is `retired`. `active` — The normal state. Nothing here blocks a new variant or a new [subscription](/docs/concepts/subscriptions). `retired` — A lifecycle flag only. Creating a variant under this plan still works — retiring does not lock it. |
| `metadata` | object | required |  |
| `created_at` | string (date-time) | required |  |
| `updated_at` | string (date-time) | required |  |

### See also

- [Plan-variant entitlements](/docs/concepts/plan-variant-entitlements)

## List plan variants

`GET /v1/plans/{id}/variants`

- **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 variant under one plan, oldest first. Both active and retired variants come back, with no paging — every variant comes back in one call. QuotaStack never checks that the plan itself is real. An unknown plan `id` returns `200` with an empty list, not `404`. The reply never includes a variant’s credit grants — call `listPlans` with `include_variants=true` for those.

### Path parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string (uuid) | required | The plan’s UUID. If you leave it out: QuotaStack rejects the call. This path segment is required. |

### Response 200

```json
{
  "data": [
    {
      "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a12",
      "plan_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a11",
      "billing_cycle": "monthly",
      "trial_days": 0,
      "status": "active",
      "billing_mode": "prepaid",
      "renewal_due_days": 3,
      "grace_period_days": 3,
      "allow_usage_while_overdue": true,
      "metadata": {},
      "created_at": "2026-07-20T09:05:00Z",
      "updated_at": "2026-07-20T09:05:00Z"
    }
  ]
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `data` | array | required | One row per variant, oldest first. Both active and retired variants come back. |
| `data.billing_cycle` | string | required | How often this variant renews. Fixed forever once the variant is created. `daily` — Renews once a day. `weekly` — Renews once a week. `monthly` — Renews on the same day each month. `yearly` — Renews on the same day each year. `one_time` — Never renews. One purchase, and nothing repeats after it. |
| `data.status` | string | required | This variant is `active`, or it is `retired`. `active` — The normal state. `retired` — A lifecycle flag only. This variant keeps its [subscriptions](/docs/concepts/subscriptions), grants, and [entitlements](/docs/concepts/entitlements) exactly as they were. |
| `data.billing_mode` | string | required | Who advances the billing period. Fixed forever once the variant is created. `prepaid` — You call [renew](/docs/concepts/subscriptions) after you charge the customer. QuotaStack waits for that call before it moves the period forward. `postpaid` — QuotaStack moves the period forward on its own. QuotaStack then reports usage back to you, to invoice. |

### See also

- [Plan-variant entitlements](/docs/concepts/plan-variant-entitlements)

## Create a plan variant

`POST /v1/plans/{id}/variants`

- **Idempotency-Key:** Required. Returns 422 without it.
- **Environment:** Sandbox and Live
- **Auth:** Tenant API key
- **Writes:** One new [plan variant](/docs/concepts/plan-variant-entitlements), `status: active`. The new variant carries no credit grants or [entitlements](/docs/concepts/entitlements) yet — add those with separate calls.
- **Fires:** No webhooks.

A [plan variant](/docs/concepts/plan-variant-entitlements) is a priced, billed instance of a plan. "Pro, billed monthly" and "Pro, billed yearly" are two variants of the same "Pro" plan. `billing_cycle`, `billing_mode`, and the renewal timing fields are all fixed forever once this call returns. A missing plan returns `404` and a bad `billing_cycle` returns `422`, though openapi.yaml declares only `201` here.

### 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 | The plan’s UUID. If you leave it out: QuotaStack rejects the call. This path segment is required. |

### Body parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `billing_cycle` | string | required | How often this variant renews. If you leave it out: QuotaStack rejects the call. This field is required. `daily` — Renews once a day. `weekly` — Renews once a week. `monthly` — Renews on the same day each month. `yearly` — Renews on the same day each year. `one_time` — Never renews. One purchase, and nothing repeats after it. |
| `trial_days` | integer | required | How many days a new [subscription](/docs/concepts/subscriptions) spends in `trialing` before its first charge. If you leave it out: openapi.yaml marks this field required, but QuotaStack does not enforce that. Left out, it defaults to 0 — no trial. Range: 0–∞. |
| `billing_mode` | string | optional | Who advances the billing period. Fixed forever once you create the variant. If you leave it out: By default, it is `prepaid`. `prepaid` — You call [renew](/docs/concepts/subscriptions) after you charge the customer. QuotaStack waits for that call before it moves the period forward. `postpaid` — QuotaStack moves the period forward on its own. QuotaStack then reports usage back to you, to invoice. |
| `renewal_due_days` | integer | optional | Days before the period ends that QuotaStack warns you to renew. [Prepaid](/docs/concepts/subscriptions) only. If you leave it out: By default, it is 3. |
| `grace_period_days` | integer | optional | How many days late a [prepaid](/docs/concepts/subscriptions) plan can go before it ends. If you leave it out: By default, it is 3. |
| `allow_usage_while_overdue` | boolean | optional | Whether a customer can spend credit while their bill is late. If you leave it out: By default, it is `true`. |
| `metadata` | object | optional | Your own key-value tags. QuotaStack stores them unchanged and returns them on every read. If you leave it out: Left out, QuotaStack stores `{}`. |

### Request

```json
{
  "billing_cycle": "monthly",
  "trial_days": 14
}
```

### Response 201

```json
{
  "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a12",
  "plan_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a11",
  "billing_cycle": "monthly",
  "trial_days": 0,
  "status": "active",
  "billing_mode": "prepaid",
  "renewal_due_days": 3,
  "grace_period_days": 3,
  "allow_usage_while_overdue": true,
  "metadata": {},
  "created_at": "2026-07-20T09:05:00Z",
  "updated_at": "2026-07-20T09:05:00Z"
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string (uuid) | required |  |
| `plan_id` | string (uuid) | required |  |
| `tenant_id` | string (uuid) | required |  |
| `billing_cycle` | string | required | How often this variant renews. Fixed forever once the variant is created. `daily` — Renews once a day. `weekly` — Renews once a week. `monthly` — Renews on the same day each month. `yearly` — Renews on the same day each year. `one_time` — Never renews. One purchase, and nothing repeats after it. |
| `trial_days` | integer | required | How many days a new [subscription](/docs/concepts/subscriptions) on this variant spends in `trialing` before its first charge. Range: 0–∞. |
| `status` | string | required | This variant is `active`, or it is `retired`. `active` — The normal state. `retired` — A lifecycle flag only. This variant keeps its [subscriptions](/docs/concepts/subscriptions), grants, and [entitlements](/docs/concepts/entitlements) exactly as they were. |
| `billing_mode` | string | required | Who advances the billing period. Fixed forever once the variant is created. `prepaid` — You call [renew](/docs/concepts/subscriptions) after you charge the customer. QuotaStack waits for that call before it moves the period forward. `postpaid` — QuotaStack moves the period forward on its own. QuotaStack then reports usage back to you, to invoice. |
| `renewal_due_days` | integer | required | Days before the period ends that QuotaStack warns you to renew. [Prepaid](/docs/concepts/subscriptions) only. |
| `grace_period_days` | integer | required | How many days late a [prepaid](/docs/concepts/subscriptions) plan can go before it ends. |
| `allow_usage_while_overdue` | boolean | required | Whether a customer can spend credit while their bill is late. |
| `metadata` | object | required | Your own key-value tags, unchanged since the last write. |
| `created_at` | string (date-time) | required |  |
| `updated_at` | string (date-time) | required |  |

### See also

- [Plan-variant entitlements](/docs/concepts/plan-variant-entitlements)
- [Subscriptions](/docs/concepts/subscriptions)

## Update a plan variant

`PATCH /v1/plans/{id}/variants/{vid}`

- **Idempotency-Key:** Required. Returns 422 without it.
- **Environment:** Sandbox and Live
- **Auth:** Tenant API key
- **Writes:** Replaces whichever of `status`, `trial_days`, and `metadata` you send; a field left out keeps its old value. `metadata` is replaced outright, not merged.
- **Fires:** No webhooks.

This endpoint changes only `status`, `trial_days`, and `metadata`. Every other field is fixed at creation — make a new variant to change one of those. A missing variant returns `404`. A bad `status` returns `422`. openapi.yaml declares only `200` here.

### 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 | The plan’s UUID. If you leave it out: QuotaStack rejects the call. This path segment is required. |
| `vid` | string (uuid) | required | The [plan variant](/docs/concepts/plan-variant-entitlements)’s UUID. If you leave it out: QuotaStack rejects the call. This path segment is required. |

### Body parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `status` | string | optional | This variant’s new state: `active` or `retired`. If you leave it out: The old value stays. QuotaStack changes nothing. `active` — The normal state. `retired` — A lifecycle flag only. This variant keeps its [subscriptions](/docs/concepts/subscriptions), grants, and [entitlements](/docs/concepts/entitlements) exactly as they were. `null` |
| `trial_days` | integer | optional | How many trial days this variant grants a new [subscription](/docs/concepts/subscriptions). If you leave it out: The old value stays. QuotaStack changes nothing. |
| `metadata` | object | optional | Your own key-value tags. Sending any value here replaces the whole stored object — QuotaStack does not merge keys in. If you leave it out: The old value stays. QuotaStack changes nothing. |

### Request

```json
{
  "status": "retired"
}
```

### Response 200

```json
{
  "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a12",
  "plan_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a11",
  "billing_cycle": "monthly",
  "trial_days": 0,
  "status": "retired",
  "billing_mode": "prepaid",
  "renewal_due_days": 3,
  "grace_period_days": 3,
  "allow_usage_while_overdue": true,
  "metadata": {},
  "created_at": "2026-07-20T09:05:00Z",
  "updated_at": "2026-07-20T09:05:00Z"
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string (uuid) | required |  |
| `plan_id` | string (uuid) | required |  |
| `tenant_id` | string (uuid) | required |  |
| `billing_cycle` | string | required | How often this variant renews. Fixed forever once the variant is created. `daily` — Renews once a day. `weekly` — Renews once a week. `monthly` — Renews on the same day each month. `yearly` — Renews on the same day each year. `one_time` — Never renews. One purchase, and nothing repeats after it. |
| `trial_days` | integer | required | How many days a new [subscription](/docs/concepts/subscriptions) on this variant spends in `trialing` before its first charge. Range: 0–∞. |
| `status` | string | required | This variant is `active`, or it is `retired`. `active` — The normal state. `retired` — A lifecycle flag only. This variant keeps its [subscriptions](/docs/concepts/subscriptions), grants, and [entitlements](/docs/concepts/entitlements) exactly as they were. |
| `billing_mode` | string | required | Who advances the billing period. Fixed forever once the variant is created. `prepaid` — You call [renew](/docs/concepts/subscriptions) after you charge the customer. QuotaStack waits for that call before it moves the period forward. `postpaid` — QuotaStack moves the period forward on its own. QuotaStack then reports usage back to you, to invoice. |
| `renewal_due_days` | integer | required | Days before the period ends that QuotaStack warns you to renew. [Prepaid](/docs/concepts/subscriptions) only. |
| `grace_period_days` | integer | required | How many days late a [prepaid](/docs/concepts/subscriptions) plan can go before it ends. |
| `allow_usage_while_overdue` | boolean | required | Whether a customer can spend credit while their bill is late. |
| `metadata` | object | required | Your own key-value tags, unchanged since the last write. |
| `created_at` | string (date-time) | required |  |
| `updated_at` | string (date-time) | required |  |

### See also

- [Plan-variant entitlements](/docs/concepts/plan-variant-entitlements)

## Attach a credit grant to a plan variant

`POST /v1/plans/{id}/variants/{vid}/grants`

- **Idempotency-Key:** Required. Returns 422 without it.
- **Environment:** Sandbox and Live
- **Auth:** Tenant API key
- **Writes:** One new grant schedule on this variant. QuotaStack grants no credit yet; the scheduler does that later, per [subscription](/docs/concepts/subscriptions). The reply’s `metadata` is always `null` — this request has no `metadata` field to set it with.
- **Fires:** No webhooks.

This endpoint is where a recurring grant schedule lives. Creating one grants no credit right away. The scheduler grants credit at each `grant_interval`, when a [subscription](/docs/concepts/subscriptions) first starts or renews. That same renewal fires [subscription.renewed](/docs/api/events/subscription.renewed). There is no way to update or delete a grant afterward. Make a new [plan variant](/docs/concepts/plan-variant-entitlements) to change one. A missing variant returns `404`. A bad `credits`, `grant_interval`, or `grant_type` returns `422`. openapi.yaml declares only `201` here.

### 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 | The plan’s UUID. If you leave it out: QuotaStack rejects the call. This path segment is required. |
| `vid` | string (uuid) | required | The [plan variant](/docs/concepts/plan-variant-entitlements)’s 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 this grant issues each time it fires. Unit: millicredits, where 1 credit is 1000 millicredits. If you leave it out: QuotaStack rejects the call. This field is required, and must be more than zero. Range: 1–∞. |
| `grant_interval` | string | required | When this grant fires. Use one of the five keywords below, or an ISO 8601 duration like `PT5H` for a custom cadence that runs on its own clock, apart from billing. QuotaStack accepts nothing shorter than 5 minutes. See [subscriptions](/docs/concepts/subscriptions) for the full duration rules. If you leave it out: QuotaStack rejects the call. This field is required. |
| `expires_after_seconds` | integer | optional | How long each [credit block](/docs/concepts/credits) from this grant lasts, counted from when it fires. Unit: seconds. If you leave it out: Left out, the block never expires. |
| `accumulation_cap` | integer (int64) | optional | The highest balance this grant is allowed to push the account to. Unit: millicredits, where 1 credit is 1000 millicredits. If you leave it out: Left out, there is no cap. The grant always adds its full amount. |
| `rollover_percentage` | integer | optional | How much unused credit from last cycle carries into this grant, from 0 to 100 percent. A [prepaid](/docs/concepts/subscriptions) renewal is what applies this. Unit: percent. If you leave it out: Left out, or 0, nothing carries forward. Range: 0–100. |
| `max_rollover_cycles` | integer | optional | How many cycles in a row credit can roll over. After that, QuotaStack resets the count to zero. If you leave it out: Left out, rollover has no cycle limit. |
| `grant_type` | string | required | How often this grant fires overall. Pairing `recurring` with `grant_interval: on_activation` is invalid. Use `one_time` for a grant that fires only at activation. If you leave it out: QuotaStack rejects the call. This field is required. `recurring` — Fires every time `grant_interval` comes around, for as long as the [subscription](/docs/concepts/subscriptions) stays active. `one_time` — Fires exactly once, on `grant_interval`’s next turn, then never again. `trial` — Fires once, meant for a [subscription](/docs/concepts/subscriptions)’s trial period. |
| `source` | string | optional | Why this grant exists. QuotaStack stores it on every [ledger](/docs/concepts/credits) entry the grant writes. If you leave it out: By default, it is `plan_grant`. `plan_grant` — The default. Use this for a grant tied to the variant’s normal schedule. `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. |

### Request

```json
{
  "credits": 10000,
  "grant_interval": "monthly",
  "grant_type": "recurring",
  "source": "promotional",
  "rollover_percentage": 50,
  "accumulation_cap": 15000,
  "max_rollover_cycles": 3
}
```

### Response 201

```json
{
  "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a61",
  "plan_variant_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a12",
  "credits": 10000,
  "grant_interval": "monthly",
  "expires_after_seconds": null,
  "accumulation_cap": 15000,
  "rollover_percentage": 50,
  "max_rollover_cycles": 3,
  "grant_type": "recurring",
  "source": "promotional",
  "metadata": null,
  "created_at": "2026-07-20T09:10:00Z",
  "updated_at": "2026-07-20T09:10:00Z"
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string (uuid) | required |  |
| `plan_variant_id` | string (uuid) | required |  |
| `tenant_id` | string (uuid) | required |  |
| `credits` | integer (int64) | required | How many credits this grant issues each time it fires. Unit: millicredits, where 1 credit is 1000 millicredits. If you leave it out: QuotaStack rejects the call. This field is required, and must be more than zero. |
| `grant_interval` | string | required | When this grant fires. Use one of the five keywords below, or an ISO 8601 duration like `PT5H` for a custom cadence that runs on its own clock, apart from billing. QuotaStack accepts nothing shorter than 5 minutes. See [subscriptions](/docs/concepts/subscriptions) for the full duration rules. If you leave it out: QuotaStack rejects the call. This field is required. |
| `expires_after_seconds` | integer | optional | How long each [credit block](/docs/concepts/credits) from this grant lasts, counted from when it fires. Unit: seconds. If you leave it out: Left out, the block never expires. |
| `accumulation_cap` | integer (int64) | optional | The highest balance this grant is allowed to push the account to. Unit: millicredits, where 1 credit is 1000 millicredits. If you leave it out: Left out, there is no cap. The grant always adds its full amount. |
| `rollover_percentage` | integer | optional | How much unused credit from last cycle carries into this grant, from 0 to 100 percent. A [prepaid](/docs/concepts/subscriptions) renewal is what applies this. Unit: percent. If you leave it out: Left out, or 0, nothing carries forward. Range: 0–100. |
| `max_rollover_cycles` | integer | optional | How many cycles in a row credit can roll over. After that, QuotaStack resets the count to zero. If you leave it out: Left out, rollover has no cycle limit. |
| `grant_type` | string | required | How often this grant fires overall. Pairing `recurring` with `grant_interval: on_activation` is invalid. Use `one_time` for a grant that fires only at activation. If you leave it out: QuotaStack rejects the call. This field is required. `recurring` — Fires every time `grant_interval` comes around, for as long as the [subscription](/docs/concepts/subscriptions) stays active. `one_time` — Fires exactly once, on `grant_interval`’s next turn, then never again. `trial` — Fires once, meant for a [subscription](/docs/concepts/subscriptions)’s trial period. |
| `source` | string | required | Why this grant exists. If you leave it out: By default, it is `plan_grant`. `plan_grant` — The default. Use this for a grant tied to the variant’s normal schedule. `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. |
| `metadata` | object | required | QuotaStack always returns `null` here. There is no way to attach metadata to a credit grant through this API. |
| `created_at` | string (date-time) | required |  |
| `updated_at` | string (date-time) | required |  |

### See also

- [Subscriptions](/docs/concepts/subscriptions)
- [subscription.renewed](/docs/api/events/subscription.renewed)

## Retrieve a plan variant

`GET /v1/plan-variants/{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 endpoint reads `/v1/plan-variants/{id}` directly, a different path root from every other Plans call. This one needs no plan `id` at all. Use it when you already have a variant’s `id` from somewhere else, a [subscription](/docs/concepts/subscriptions) say, and only need that one variant. The reply never includes its credit grants — call `listPlans` with `include_variants=true` for those.

### Path parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string (uuid) | required | The [plan variant](/docs/concepts/plan-variant-entitlements)’s UUID. If you leave it out: QuotaStack rejects the call. This path segment is required. |

### Response 200

```json
{
  "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a12",
  "plan_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a11",
  "billing_cycle": "monthly",
  "trial_days": 0,
  "status": "active",
  "billing_mode": "prepaid",
  "renewal_due_days": 3,
  "grace_period_days": 3,
  "allow_usage_while_overdue": true,
  "metadata": {},
  "created_at": "2026-07-20T09:05:00Z",
  "updated_at": "2026-07-20T09:05:00Z"
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string (uuid) | required |  |
| `plan_id` | string (uuid) | required |  |
| `tenant_id` | string (uuid) | required |  |
| `billing_cycle` | string | required | How often this variant renews. Fixed forever once the variant is created. `daily` — Renews once a day. `weekly` — Renews once a week. `monthly` — Renews on the same day each month. `yearly` — Renews on the same day each year. `one_time` — Never renews. One purchase, and nothing repeats after it. |
| `trial_days` | integer | required | How many days a new [subscription](/docs/concepts/subscriptions) on this variant spends in `trialing` before its first charge. Range: 0–∞. |
| `status` | string | required | This variant is `active`, or it is `retired`. `active` — The normal state. `retired` — A lifecycle flag only. This variant keeps its [subscriptions](/docs/concepts/subscriptions), grants, and [entitlements](/docs/concepts/entitlements) exactly as they were. |
| `billing_mode` | string | required | Who advances the billing period. Fixed forever once the variant is created. `prepaid` — You call [renew](/docs/concepts/subscriptions) after you charge the customer. QuotaStack waits for that call before it moves the period forward. `postpaid` — QuotaStack moves the period forward on its own. QuotaStack then reports usage back to you, to invoice. |
| `renewal_due_days` | integer | required | Days before the period ends that QuotaStack warns you to renew. [Prepaid](/docs/concepts/subscriptions) only. |
| `grace_period_days` | integer | required | How many days late a [prepaid](/docs/concepts/subscriptions) plan can go before it ends. |
| `allow_usage_while_overdue` | boolean | required | Whether a customer can spend credit while their bill is late. |
| `metadata` | object | required | Your own key-value tags, unchanged since the last write. |
| `created_at` | string (date-time) | required |  |
| `updated_at` | string (date-time) | required |  |

### Errors

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

### See also

- [Plan-variant entitlements](/docs/concepts/plan-variant-entitlements)

## List variant entitlements

`GET /v1/plans/{id}/variants/{vid}/entitlements`

- **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 [entitlement](/docs/concepts/entitlements) tied to one variant. There is no paging; every row comes back in one call. A missing variant returns `404`. openapi.yaml declares only `200` here.

### Path parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string (uuid) | required | The plan’s UUID. If you leave it out: QuotaStack rejects the call. This path segment is required. |
| `vid` | string (uuid) | required | The [plan variant](/docs/concepts/plan-variant-entitlements)’s UUID. If you leave it out: QuotaStack rejects the call. This path segment is required. |

### Response 200

```json
{
  "data": [
    {
      "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a62",
      "plan_variant_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a12",
      "billable_metric_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a63",
      "billable_metric_key": "sso",
      "value": {
        "enabled": true
      },
      "created_at": "2026-07-20T09:15:00Z",
      "updated_at": "2026-07-20T09:15:00Z"
    }
  ]
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `data` | array | required | One row per attached metric. |
| `data.billable_metric_key` | string | optional | The [billable metric](/docs/concepts/metering)’s own `key`, the same one you used to attach it. QuotaStack fills this in on its own; you cannot set it. |
| `data.value` | object | required | The stored value, shaped by the metric’s `type`. `boolean` reads `{"enabled": ...}`. `gauge` reads `{"cap": ...}`. `static` reads any object. `metered` usually reads `{}`. |

### See also

- [Plan-variant entitlements](/docs/concepts/plan-variant-entitlements)

## Attach a variant entitlement

`POST /v1/plans/{id}/variants/{vid}/entitlements`

- **Idempotency-Key:** Not required for this operation.
- **Environment:** Sandbox and Live
- **Auth:** Tenant API key
- **Writes:** One new [entitlement](/docs/concepts/entitlements) attachment on this variant.
- **Fires:** No webhooks.

An [entitlement](/docs/concepts/entitlements) attachment grants a feature or a cap that credits do not price: a flag, a cap, or a config blob. Use a [metering rule](/docs/concepts/metering) instead for a feature that should cost credits per use. This call needs an `Idempotency-Key` header, like every write. openapi.yaml skips it here, but QuotaStack still checks.

### Path parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string (uuid) | required | The plan’s UUID. If you leave it out: QuotaStack rejects the call. This path segment is required. |
| `vid` | string (uuid) | required | The [plan variant](/docs/concepts/plan-variant-entitlements)’s UUID. If you leave it out: QuotaStack rejects the call. This path segment is required. |

### Body parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `billable_metric_key` | string | required | Which [billable metric](/docs/concepts/metering) this grants, by its `key`. If you leave it out: QuotaStack rejects the call. This field is required — an empty or unknown key returns `404`, not `422`. |
| `value` | object | required | The value this attachment grants, shaped by the metric’s `type`. `boolean` takes `{"enabled": <true\|false>}`. `gauge` takes `{"cap": <non-negative integer>}`. `static` takes any object, often `{"config": ...}`. `metered` ignores this field. If you leave it out: For a `metered` or `static` metric, you can leave `value` out. QuotaStack then stores `{}` as the value. For `boolean` or `gauge`, a bad or missing `value` returns `500`, not the documented `422`. |

### Request

```json
{
  "billable_metric_key": "sso",
  "value": {
    "enabled": true
  }
}
```

### Response 201

```json
{
  "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a62",
  "plan_variant_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a12",
  "billable_metric_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a63",
  "billable_metric_key": "sso",
  "value": {
    "enabled": true
  },
  "created_at": "2026-07-20T09:15:00Z",
  "updated_at": "2026-07-20T09:15:00Z"
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string (uuid) | required |  |
| `tenant_id` | string (uuid) | required |  |
| `plan_variant_id` | string (uuid) | required |  |
| `billable_metric_id` | string (uuid) | required |  |
| `billable_metric_key` | string | optional | The [billable metric](/docs/concepts/metering)’s own `key`, the same one you used to attach it. QuotaStack fills this in on its own; you cannot set it. |
| `value` | object | required | The stored value, shaped by the metric’s `type`. `boolean` reads `{"enabled": ...}`. `gauge` reads `{"cap": ...}`. `static` reads any object. `metered` usually reads `{}`. |
| `created_at` | string (date-time) | required |  |
| `updated_at` | string (date-time) | required |  |

### Errors

- `404` — No [plan variant](/docs/concepts/plan-variant-entitlements) here has this `id`, or no [billable metric](/docs/concepts/metering) has this `billable_metric_key`. See [not-found](/docs/api/errors/not-found).
- `409` — This metric is already attached to this variant. Use `updateVariantEntitlement` to change its value. See [conflict](/docs/api/errors/conflict).
- `422` — For a `boolean` metric, `value` must carry `enabled` as a real boolean. For a `gauge` metric, `value` must carry a non-negative whole-number `cap`. See `value`’s own prose below — this status does not always fire the way this row implies. See [validation-error](/docs/api/errors/validation-error).

### See also

- [Plan-variant entitlements](/docs/concepts/plan-variant-entitlements)
- [Entitlements](/docs/concepts/entitlements)
- [Metering](/docs/concepts/metering)

## Update a variant entitlement

`PATCH /v1/plans/{id}/variants/{vid}/entitlements/{billable_metric_key}`

- **Idempotency-Key:** Not required for this operation.
- **Environment:** Sandbox and Live
- **Auth:** Tenant API key
- **Writes:** Replaces the attachment’s `value`. See `value`’s own prose below for what happens when you leave it out.
- **Fires:** No webhooks.

This endpoint swaps an attached metric’s `value` for a new one — no merge. This call needs an `Idempotency-Key` header, like every write. openapi.yaml skips it here, but QuotaStack still checks. You cannot change the metric itself; detach and reattach instead.

### Path parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string (uuid) | required | The plan’s UUID. If you leave it out: QuotaStack rejects the call. This path segment is required. |
| `vid` | string (uuid) | required | The [plan variant](/docs/concepts/plan-variant-entitlements)’s UUID. If you leave it out: QuotaStack rejects the call. This path segment is required. |
| `billable_metric_key` | string | required | The [billable metric](/docs/concepts/metering)’s own `key`, the same one you used to attach it. If you leave it out: QuotaStack rejects the call. This path segment is required. |

### Body parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `value` | object | required | The value this attachment grants, shaped by the metric’s `type`. `boolean` takes `{"enabled": <true\|false>}`. `gauge` takes `{"cap": <non-negative integer>}`. `static` takes any object, often `{"config": ...}`. `metered` ignores this field. If you leave it out: For a `metered` or `static` metric, you can leave `value` out. QuotaStack then quietly replaces the stored value with `{}`. For `boolean` or `gauge`, a bad or missing `value` returns `500`, not the documented `422`. |

### Request

```json
{
  "value": {
    "enabled": false
  }
}
```

### Response 200

```json
{
  "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a62",
  "plan_variant_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a12",
  "billable_metric_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a63",
  "billable_metric_key": "sso",
  "value": {
    "enabled": false
  },
  "created_at": "2026-07-20T09:15:00Z",
  "updated_at": "2026-07-20T09:15:00Z"
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string (uuid) | required |  |
| `tenant_id` | string (uuid) | required |  |
| `plan_variant_id` | string (uuid) | required |  |
| `billable_metric_id` | string (uuid) | required |  |
| `billable_metric_key` | string | optional | The [billable metric](/docs/concepts/metering)’s own `key`, the same one you used to attach it. QuotaStack fills this in on its own; you cannot set it. |
| `value` | object | required | The stored value, shaped by the metric’s `type`. `boolean` reads `{"enabled": ...}`. `gauge` reads `{"cap": ...}`. `static` reads any object. `metered` usually reads `{}`. |
| `created_at` | string (date-time) | required |  |
| `updated_at` | string (date-time) | required |  |

### Errors

- `404` — No [plan variant](/docs/concepts/plan-variant-entitlements) here has this `id`, or no metric is attached under this `billable_metric_key`. See [not-found](/docs/api/errors/not-found).
- `422` — For a `boolean` metric, `value` must carry `enabled` as a real boolean. For a `gauge` metric, `value` must carry a non-negative whole-number `cap`. See `value`’s own prose below — this status does not always fire the way this row implies. See [validation-error](/docs/api/errors/validation-error).

### See also

- [Plan-variant entitlements](/docs/concepts/plan-variant-entitlements)
- [Entitlements](/docs/concepts/entitlements)

## Detach a variant entitlement

`DELETE /v1/plans/{id}/variants/{vid}/entitlements/{billable_metric_key}`

- **Idempotency-Key:** Not used. This is a read.
- **Environment:** Sandbox and Live
- **Auth:** Tenant API key
- **Writes:** Soft-deletes the attachment. The metric stays. Every other variant that has it stays too.
- **Fires:** No webhooks.

This endpoint is a soft delete. QuotaStack marks the attachment deleted and nothing else. Subscribers on this variant fall back to the metric’s own `default_value` right after.

### Path parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string (uuid) | required | The plan’s UUID. If you leave it out: QuotaStack rejects the call. This path segment is required. |
| `vid` | string (uuid) | required | The [plan variant](/docs/concepts/plan-variant-entitlements)’s UUID. If you leave it out: QuotaStack rejects the call. This path segment is required. |
| `billable_metric_key` | string | required | The [billable metric](/docs/concepts/metering)’s own `key`, the same one you used to attach it. If you leave it out: QuotaStack rejects the call. This path segment is required. |

### Errors

- `404` — No [plan variant](/docs/concepts/plan-variant-entitlements) here has this `id`, or no metric is attached under this `billable_metric_key`. See [not-found](/docs/api/errors/not-found).

### See also

- [Plan-variant entitlements](/docs/concepts/plan-variant-entitlements)
- [Entitlements](/docs/concepts/entitlements)
