---
title: "Subscriptions"
description: "A customer on one plan, over time — the recurring cycle that grants credits and can renew, pause, or end."
---

# Subscriptions

A customer on a plan, over time.

A [subscription](/docs/concepts/subscriptions) puts one customer on one plan,
over time. The subscription tracks the billing period, and it grants credits
again at renewal.

[Subscriptions](/docs/concepts/subscriptions) are optional. A customer can buy
credits with a one-time [topup](/docs/concepts/topups-and-wallets) instead,
with no subscription at all.

## Which call do I want?

- You are putting a customer on a plan for the first time → `POST /v1/subscriptions`
  `createSubscription` grants the [plan variant](/docs/concepts/plan-variant-entitlements)'s credits in the same call. There is no separate grant step.
- The customer is moving to a more expensive variant → `POST /v1/subscriptions/{id}/upgrade`
- The customer is moving to a cheaper variant → `POST /v1/subscriptions/{id}/downgrade`
  Upgrade grants prorated credit right away. Downgrade grants nothing until the next renewal. The two are not mirror images.
- The customer is stopping, but may come back → `POST /v1/subscriptions/{id}/pause`
  Existing credit stays spendable. The billing period stops moving too. Sending `resume_at` does nothing. Nothing brings it back on its own — call `resumeSubscription`.
- The customer is stopping for good → `POST /v1/subscriptions/{id}/cancel`
  Pause freezes the period and keeps the account open. Cancel ends it, right away or at period end, and never comes back on its own.
- A paused subscription is coming back → `POST /v1/subscriptions/{id}/resume`
  Restarts the billing period from right now. Whatever time was left when it paused is gone.
- You are advancing a manual prepaid [subscription](/docs/concepts/subscriptions) yourself → `POST /v1/subscriptions/{id}/renew`
  Use this call when old credit can roll over. Postpaid plans move on their own. A payment connector moves after a paid charge.
- You only need to push out a contract's end date → `POST /v1/subscriptions/{id}/extend`
  This call touches `contract_end` alone. Nothing here moves the billing period or grants credit — use `renewSubscription` for that.

10 of 10 operations documented in full.

## List subscriptions

`GET /v1/subscriptions`

- **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 [subscription](/docs/concepts/subscriptions) for your tenant, newest first. Filter by `customer_id`, `status`, or both — both are optional. An unrecognized `customer_id` returns an empty list, not `404`. An unrecognized `status` is different. QuotaStack does not check it first. The query then fails at the database with `500`, since `status` is a fixed set of values there, not free text.

### Query parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `customer_id` | string | optional | Only [subscriptions](/docs/concepts/subscriptions) for this customer. Takes your own `external_id` or QuotaStack's own `id`. An unrecognized value returns an empty list, not `404`. If you leave it out: Every [subscription](/docs/concepts/subscriptions) comes back, with no filter. |
| `status` | string | optional | Only [subscriptions](/docs/concepts/subscriptions) in this state. QuotaStack does not check it against the eight real values. An unknown value returns `500`. If you leave it out: Every status comes back, with no filter. `trialing` — A trial. QuotaStack moves this to `active` on its own once the trial ends. `active` — The normal state. Credit grants fire on schedule, and usage debits the balance. `cancelling` — Ending at period end, from `cancelSubscription`. Access continues until then. `canceled` — Ended right away, from `cancelSubscription` with `cancel_immediately: true`. Terminal. `expired` — A grace period ran out, or a `cancelling` period ended. Terminal. `paused` — Suspended, from `pauseSubscription`. No credit grants fire, and the billing period does not move. `overdue` — The period ended and nobody called `renewSubscription` yet. Prepaid only. `contract_ended` — The contract's end date passed. `extendSubscription` can bring this back to `active`. |
| `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-4f6c8e1b3a21",
      "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
      "plan_variant_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a12",
      "environment": "live",
      "status": "active",
      "origin": "api",
      "started_at": "2026-07-01T09:00:00Z",
      "trial_ends_at": null,
      "current_period_start": "2026-07-01T09:00:00Z",
      "current_period_end": "2026-08-01T09:00:00Z",
      "billing_anchor": 1,
      "cancel_at_period_end": false,
      "scheduled_variant_id": null,
      "external_subscription_id": null,
      "paused_at": null,
      "resumed_at": null,
      "expired_at": null,
      "contract_start": "2026-07-01T09:00:00Z",
      "contract_end": null,
      "contract_ending_soon_days": 0,
      "metadata": {},
      "created_at": "2026-07-01T09:00:00Z",
      "updated_at": "2026-07-01T09:00:00Z"
    }
  ],
  "pagination": {
    "has_more": false,
    "next_cursor": null
  }
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `data` | array | required |  |
| `data.status` | string | required | This [subscription](/docs/concepts/subscriptions)'s current state in its lifecycle. `trialing` — A trial. QuotaStack moves this to `active` on its own once the trial ends. `active` — The normal state. Credit grants fire on schedule, and usage debits the balance. `cancelling` — Ending at period end, from `cancelSubscription`. Access continues until then. `canceled` — Ended right away, from `cancelSubscription` with `cancel_immediately: true`. Terminal. `expired` — A grace period ran out, or a `cancelling` period ended. Terminal. `paused` — Suspended, from `pauseSubscription`. No credit grants fire, and the billing period does not move. `overdue` — The period ended and nobody called `renewSubscription` yet. Prepaid only. `contract_ended` — The contract's end date passed. `extendSubscription` can bring this back to `active`. |
| `data.origin` | string | optional | How this [subscription](/docs/concepts/subscriptions) came to exist. `api` — Created through these ten endpoints — the ordinary case. `import` — Brought in through a [legacy cutover](/docs/cookbook/import-existing-customers), not `createSubscription`. |
| `data.trial_ends_at` | string (date-time) | optional | When the trial ends. `null` for a [subscription](/docs/concepts/subscriptions) that never had one. |
| `data.current_period_start` | string (date-time) | optional | The start of the billing period this [subscription](/docs/concepts/subscriptions) is in right now. |
| `data.current_period_end` | string (date-time) | optional | The end of the billing period this [subscription](/docs/concepts/subscriptions) is in right now. |
| `data.billing_anchor` | integer | optional | The day of the month a monthly period renews on, 1 through 31. |
| `data.cancel_at_period_end` | boolean | required | Whether this [subscription](/docs/concepts/subscriptions) stops at the end of its current period instead of right away. |
| `data.scheduled_variant_id` | string (uuid) | optional | The [plan variant](/docs/concepts/plan-variant-entitlements) a pending `downgradeSubscription` will switch to at the next renewal. `null` with no downgrade pending. An `upgradeSubscription` call clears this, canceling the pending downgrade outright. |
| `data.external_subscription_id` | string | optional | Your own identifier for this [subscription](/docs/concepts/subscriptions). `renewSubscription`'s `external_payment_id` writes here too — the two calls share this one column, not two separate fields. |
| `data.paused_at` | string (date-time) | optional | When `pauseSubscription` last ran. `null` if this [subscription](/docs/concepts/subscriptions) has never paused. |
| `data.resumed_at` | string (date-time) | optional | When `resumeSubscription` last ran. `null` if this [subscription](/docs/concepts/subscriptions) has never resumed. |
| `data.expired_at` | string (date-time) | optional | When this [subscription](/docs/concepts/subscriptions) reached `expired`. `null` until it does. |
| `data.contract_start` | string (date-time) | optional | When the contract began. QuotaStack sets this once, at creation. |
| `data.contract_end` | string (date-time) | optional | When the contract ends. `null` for an open-ended [subscription](/docs/concepts/subscriptions). |
| `data.contract_ending_soon_days` | integer | required | Days before `contract_end` that QuotaStack fires `subscription.contract_ending_soon`. A [subscription](/docs/concepts/subscriptions) created with no value here reads `0`, not `30` — the scheduler still treats `0` as `30` when it checks the reminder. |
| `data.metadata` | object | required | Your own key-value tags, as they stand after the last write that touched them. |
| `pagination` | object | required | Cursor-based pagination envelope. Returned on every list endpoint. No `total` — cursor-only. |

### See also

- [Subscriptions](/docs/concepts/subscriptions)

## Create a subscription

`POST /v1/subscriptions`

- **Idempotency-Key:** Required. Returns 422 without it.
- **Environment:** Sandbox and Live
- **Auth:** Tenant API key
- **Writes:** One new [subscription](/docs/concepts/subscriptions). QuotaStack also grants the [plan variant](/docs/concepts/plan-variant-entitlements)'s credits in the same call, best-effort. The grant never applies an `accumulation_cap` or `rollover_percentage`, even if the variant's grant defines one — only a renewal does.
- **Fires:** [`subscription.created`](/docs/api/events/subscription.created), [`credit.granted`](/docs/api/events/credit.granted)

This call puts one customer on one [plan variant](/docs/concepts/plan-variant-entitlements), starting now. QuotaStack grants that variant's credits in the same call. A failed grant does not fail the [subscription](/docs/concepts/subscriptions) itself. A trial variant starts the subscription as `trialing`. Every other variant starts it `active`. Send `customer_id` or `external_customer_id`, never both. QuotaStack rejects the call with neither too. `external_customer_id` looks up, or creates, a customer by that value alone. `customer_id` is the lenient form: it also takes your own external ID, and a UUID from a different tenant returns `400`. The reply never says how many credits the call granted — check the customer's balance or [ledger](/docs/concepts/credits) history for that. Skip this call for a customer already mid-plan somewhere else — it starts the period now and grants the full activation credits again. Use `POST /v1/imports/customers` for that case instead.

### 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. 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. |
| `plan_variant_id` | string (uuid) | required | Which [plan variant](/docs/concepts/plan-variant-entitlements) to subscribe the customer to. If you leave it out: QuotaStack rejects the call. This field is required. |
| `billing_anchor` | integer | optional | The day of the month, 1 through 31, a monthly period should renew on. If you leave it out: QuotaStack uses today's day of the month. An out-of-range value falls back to the same default. |
| `external_subscription_id` | string | optional | Your own identifier for this [subscription](/docs/concepts/subscriptions). QuotaStack stores it unchanged. If you leave it out: Left `null`. |
| `contract_end` | string (date-time) | optional | When the contract ends. If you leave it out: The [subscription](/docs/concepts/subscriptions) has no contract end, and never reaches `contract_ended` on its own. |
| `contract_ending_soon_days` | integer | optional | Days before `contract_end` that QuotaStack should fire `subscription.contract_ending_soon`. If you leave it out: QuotaStack stores `0`, not `30`. The scheduler treats `0` the same as `30` when it checks the reminder, so the [webhook](/docs/concepts/webhooks) still fires on time. |
| `metadata` | object | optional | Your own key-value tags, stored as the [subscription](/docs/concepts/subscriptions)'s starting value. If you leave it out: An empty object is stored instead. |

### Request

```json
{
  "external_customer_id": "user_42",
  "plan_variant_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a12",
  "contract_end": "2027-07-01T00:00:00Z"
}
```

### Response 201

```json
{
  "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a21",
  "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
  "plan_variant_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a12",
  "environment": "live",
  "status": "active",
  "origin": "api",
  "started_at": "2026-07-01T09:00:00Z",
  "trial_ends_at": null,
  "current_period_start": "2026-07-01T09:00:00Z",
  "current_period_end": "2026-08-01T09:00:00Z",
  "billing_anchor": 1,
  "cancel_at_period_end": false,
  "scheduled_variant_id": null,
  "external_subscription_id": null,
  "paused_at": null,
  "resumed_at": null,
  "expired_at": null,
  "contract_start": "2026-07-01T09:00:00Z",
  "contract_end": null,
  "contract_ending_soon_days": 0,
  "metadata": {},
  "created_at": "2026-07-01T09:00:00Z",
  "updated_at": "2026-07-01T09:00:00Z"
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string (uuid) | required |  |
| `tenant_id` | string (uuid) | required |  |
| `customer_id` | string (uuid) | required |  |
| `plan_variant_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` |
| `status` | string | required | This [subscription](/docs/concepts/subscriptions)'s current state in its lifecycle. `trialing` — A trial. QuotaStack moves this to `active` on its own once the trial ends. `active` — The normal state. Credit grants fire on schedule, and usage debits the balance. `cancelling` — Ending at period end, from `cancelSubscription`. Access continues until then. `canceled` — Ended right away, from `cancelSubscription` with `cancel_immediately: true`. Terminal. `expired` — A grace period ran out, or a `cancelling` period ended. Terminal. `paused` — Suspended, from `pauseSubscription`. No credit grants fire, and the billing period does not move. `overdue` — The period ended and nobody called `renewSubscription` yet. Prepaid only. `contract_ended` — The contract's end date passed. `extendSubscription` can bring this back to `active`. |
| `origin` | string | optional | How this [subscription](/docs/concepts/subscriptions) came to exist. `api` — Created through these ten endpoints — the ordinary case. `import` — Brought in through a [legacy cutover](/docs/cookbook/import-existing-customers), not `createSubscription`. |
| `started_at` | string (date-time) | required |  |
| `trial_ends_at` | string (date-time) | optional | When the trial ends. `null` for a [subscription](/docs/concepts/subscriptions) that never had one. |
| `current_period_start` | string (date-time) | optional | The start of the billing period this [subscription](/docs/concepts/subscriptions) is in right now. |
| `current_period_end` | string (date-time) | optional | The end of the billing period this [subscription](/docs/concepts/subscriptions) is in right now. |
| `billing_anchor` | integer | optional | The day of the month a monthly period renews on, 1 through 31. |
| `cancel_at_period_end` | boolean | required | Whether this [subscription](/docs/concepts/subscriptions) stops at the end of its current period instead of right away. |
| `scheduled_variant_id` | string (uuid) | optional | The [plan variant](/docs/concepts/plan-variant-entitlements) a pending `downgradeSubscription` will switch to at the next renewal. `null` with no downgrade pending. An `upgradeSubscription` call clears this, canceling the pending downgrade outright. |
| `external_subscription_id` | string | optional | Your own identifier for this [subscription](/docs/concepts/subscriptions). `renewSubscription`'s `external_payment_id` writes here too — the two calls share this one column, not two separate fields. |
| `paused_at` | string (date-time) | optional | When `pauseSubscription` last ran. `null` if this [subscription](/docs/concepts/subscriptions) has never paused. |
| `resumed_at` | string (date-time) | optional | When `resumeSubscription` last ran. `null` if this [subscription](/docs/concepts/subscriptions) has never resumed. |
| `expired_at` | string (date-time) | optional | When this [subscription](/docs/concepts/subscriptions) reached `expired`. `null` until it does. |
| `contract_start` | string (date-time) | optional | When the contract began. QuotaStack sets this once, at creation. |
| `contract_end` | string (date-time) | optional | When the contract ends. `null` for an open-ended [subscription](/docs/concepts/subscriptions). |
| `contract_ending_soon_days` | integer | required | Days before `contract_end` that QuotaStack fires `subscription.contract_ending_soon`. A [subscription](/docs/concepts/subscriptions) created with no value here reads `0`, not `30` — the scheduler still treats `0` as `30` when it checks the reminder. |
| `metadata` | object | required | Your own key-value tags, as they stand after the last write that touched them. |
| `created_at` | string (date-time) | required |  |
| `updated_at` | string (date-time) | required |  |

### 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).
- `401` — Missing or invalid authentication.
- `404` — No [plan variant](/docs/concepts/plan-variant-entitlements) here has this `plan_variant_id`. See [not-found](/docs/api/errors/not-found).
- `422` — A missing `plan_variant_id` returns this, under that name. A `plan_variant_id` for a retired variant returns this too, under `variant-not-active` instead. See [validation-error](/docs/api/errors/validation-error).
- `429` — Rate limit exceeded.
- `500` — Unexpected server error.

### See also

- [Subscriptions](/docs/concepts/subscriptions)
- [Plan-variant entitlements](/docs/concepts/plan-variant-entitlements)
- [Customer identification](/docs/concepts/customer-identification)
- [Importing existing customers](/docs/cookbook/import-existing-customers)

## Retrieve a subscription

`GET /v1/subscriptions/{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 [subscription](/docs/concepts/subscriptions) by its own `id`. The lookup is environment-scoped. A sandbox key resolves only a sandbox subscription. A live key resolves only a live one. Add `include_plan=true` to also embed the [plan variant](/docs/concepts/plan-variant-entitlements) and its credit grants. openapi.yaml does not declare that parameter, though QuotaStack accepts it.

### Path parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string (uuid) | required | The [subscription](/docs/concepts/subscriptions)'s own UUID. Not a customer `id`. If you leave it out: QuotaStack rejects the call. This path segment is required. |

### Response 200

```json
{
  "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a21",
  "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
  "plan_variant_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a12",
  "environment": "live",
  "status": "active",
  "origin": "api",
  "started_at": "2026-07-01T09:00:00Z",
  "trial_ends_at": null,
  "current_period_start": "2026-07-01T09:00:00Z",
  "current_period_end": "2026-08-01T09:00:00Z",
  "billing_anchor": 1,
  "cancel_at_period_end": false,
  "scheduled_variant_id": null,
  "external_subscription_id": null,
  "paused_at": null,
  "resumed_at": null,
  "expired_at": null,
  "contract_start": "2026-07-01T09:00:00Z",
  "contract_end": null,
  "contract_ending_soon_days": 0,
  "metadata": {},
  "created_at": "2026-07-01T09:00:00Z",
  "updated_at": "2026-07-01T09:00:00Z"
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string (uuid) | required |  |
| `tenant_id` | string (uuid) | required |  |
| `customer_id` | string (uuid) | required |  |
| `plan_variant_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` |
| `status` | string | required | This [subscription](/docs/concepts/subscriptions)'s current state in its lifecycle. `trialing` — A trial. QuotaStack moves this to `active` on its own once the trial ends. `active` — The normal state. Credit grants fire on schedule, and usage debits the balance. `cancelling` — Ending at period end, from `cancelSubscription`. Access continues until then. `canceled` — Ended right away, from `cancelSubscription` with `cancel_immediately: true`. Terminal. `expired` — A grace period ran out, or a `cancelling` period ended. Terminal. `paused` — Suspended, from `pauseSubscription`. No credit grants fire, and the billing period does not move. `overdue` — The period ended and nobody called `renewSubscription` yet. Prepaid only. `contract_ended` — The contract's end date passed. `extendSubscription` can bring this back to `active`. |
| `origin` | string | optional | How this [subscription](/docs/concepts/subscriptions) came to exist. `api` — Created through these ten endpoints — the ordinary case. `import` — Brought in through a [legacy cutover](/docs/cookbook/import-existing-customers), not `createSubscription`. |
| `started_at` | string (date-time) | required |  |
| `trial_ends_at` | string (date-time) | optional | When the trial ends. `null` for a [subscription](/docs/concepts/subscriptions) that never had one. |
| `current_period_start` | string (date-time) | optional | The start of the billing period this [subscription](/docs/concepts/subscriptions) is in right now. |
| `current_period_end` | string (date-time) | optional | The end of the billing period this [subscription](/docs/concepts/subscriptions) is in right now. |
| `billing_anchor` | integer | optional | The day of the month a monthly period renews on, 1 through 31. |
| `cancel_at_period_end` | boolean | required | Whether this [subscription](/docs/concepts/subscriptions) stops at the end of its current period instead of right away. |
| `scheduled_variant_id` | string (uuid) | optional | The [plan variant](/docs/concepts/plan-variant-entitlements) a pending `downgradeSubscription` will switch to at the next renewal. `null` with no downgrade pending. An `upgradeSubscription` call clears this, canceling the pending downgrade outright. |
| `external_subscription_id` | string | optional | Your own identifier for this [subscription](/docs/concepts/subscriptions). `renewSubscription`'s `external_payment_id` writes here too — the two calls share this one column, not two separate fields. |
| `paused_at` | string (date-time) | optional | When `pauseSubscription` last ran. `null` if this [subscription](/docs/concepts/subscriptions) has never paused. |
| `resumed_at` | string (date-time) | optional | When `resumeSubscription` last ran. `null` if this [subscription](/docs/concepts/subscriptions) has never resumed. |
| `expired_at` | string (date-time) | optional | When this [subscription](/docs/concepts/subscriptions) reached `expired`. `null` until it does. |
| `contract_start` | string (date-time) | optional | When the contract began. QuotaStack sets this once, at creation. |
| `contract_end` | string (date-time) | optional | When the contract ends. `null` for an open-ended [subscription](/docs/concepts/subscriptions). |
| `contract_ending_soon_days` | integer | required | Days before `contract_end` that QuotaStack fires `subscription.contract_ending_soon`. A [subscription](/docs/concepts/subscriptions) created with no value here reads `0`, not `30` — the scheduler still treats `0` as `30` when it checks the reminder. |
| `metadata` | object | required | Your own key-value tags, as they stand after the last write that touched them. |
| `created_at` | string (date-time) | required |  |
| `updated_at` | string (date-time) | required |  |

### Errors

- `404` — No [subscription](/docs/concepts/subscriptions) here has this `id`. Passing a customer's own `id` instead also returns this — this path only takes a subscription's own `id`. So does a sandbox key naming a live subscription, or a live key naming a sandbox one. See [not-found](/docs/api/errors/not-found).

### See also

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

## Upgrade a subscription

`POST /v1/subscriptions/{id}/upgrade`

- **Idempotency-Key:** Required. Returns 422 without it.
- **Environment:** Sandbox and Live
- **Auth:** Tenant API key
- **Writes:** Changes `plan_variant_id` right away and clears `scheduled_variant_id`. Grants prorated credit for the rest of this period. No `accumulation_cap` or rollover applies.
- **Fires:** [`subscription.upgraded`](/docs/api/events/subscription.upgraded), [`credit.granted`](/docs/api/events/credit.granted)

This call switches `plan_variant_id` right away. The [subscription](/docs/concepts/subscriptions) keeps its current status. This call also clears any downgrade scheduled by `downgradeSubscription`, canceling that schedule outright, not merging with it. QuotaStack grants prorated credit for the rest of the current period, best-effort, in the same call. The amount ignores any `accumulation_cap` or `rollover_percentage` the new variant's grant defines; only a renewal honors those. Only an `active` or `trialing` subscription can upgrade. Any other status returns `409`, a status openapi.yaml does not declare here. This reply is not the full `Subscription` object the field table below describes. The real reply adds `credits_granted`, in [millicredits](/docs/concepts/credits), and drops `origin`, `paused_at`, `resumed_at`, `expired_at`, `contract_start`, `contract_end`, and `contract_ending_soon_days`.

### 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 [subscription](/docs/concepts/subscriptions)'s own UUID. Not a customer `id`. If you leave it out: QuotaStack rejects the call. This path segment is required. |

### Body parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `plan_variant_id` | string (uuid) | required | The [plan variant](/docs/concepts/plan-variant-entitlements) to switch to, right away. If you leave it out: QuotaStack rejects the call. This field is required. |
| `metadata` | object | optional | QuotaStack accepts this field and stores nothing from it. Upgrade never reads it. If you leave it out: No difference. This field has no effect either way. |

### Request

```json
{
  "plan_variant_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a12"
}
```

### Response 200

```json
{
  "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a21",
  "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
  "plan_variant_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a12",
  "environment": "live",
  "status": "active",
  "started_at": "2026-06-01T09:00:00Z",
  "trial_ends_at": null,
  "current_period_start": "2026-07-01T00:00:00Z",
  "current_period_end": "2026-08-01T00:00:00Z",
  "billing_anchor": 1,
  "cancel_at_period_end": false,
  "scheduled_variant_id": null,
  "external_subscription_id": null,
  "credits_granted": 6774,
  "metadata": {},
  "created_at": "2026-06-01T09:00:00Z",
  "updated_at": "2026-07-15T14:22:00Z"
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string (uuid) | required |  |
| `tenant_id` | string (uuid) | required |  |
| `customer_id` | string (uuid) | required |  |
| `plan_variant_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` |
| `status` | string | required | This [subscription](/docs/concepts/subscriptions)'s current state in its lifecycle. `trialing` — A trial. QuotaStack moves this to `active` on its own once the trial ends. `active` — The normal state. Credit grants fire on schedule, and usage debits the balance. `cancelling` — Ending at period end, from `cancelSubscription`. Access continues until then. `canceled` — Ended right away, from `cancelSubscription` with `cancel_immediately: true`. Terminal. `expired` — A grace period ran out, or a `cancelling` period ended. Terminal. `paused` — Suspended, from `pauseSubscription`. No credit grants fire, and the billing period does not move. `overdue` — The period ended and nobody called `renewSubscription` yet. Prepaid only. `contract_ended` — The contract's end date passed. `extendSubscription` can bring this back to `active`. |
| `origin` | string | optional | How this [subscription](/docs/concepts/subscriptions) came to exist. `api` — Created through these ten endpoints — the ordinary case. `import` — Brought in through a [legacy cutover](/docs/cookbook/import-existing-customers), not `createSubscription`. |
| `started_at` | string (date-time) | required |  |
| `trial_ends_at` | string (date-time) | optional | When the trial ends. `null` for a [subscription](/docs/concepts/subscriptions) that never had one. |
| `current_period_start` | string (date-time) | optional | The start of the billing period this [subscription](/docs/concepts/subscriptions) is in right now. |
| `current_period_end` | string (date-time) | optional | The end of the billing period this [subscription](/docs/concepts/subscriptions) is in right now. |
| `billing_anchor` | integer | optional | The day of the month a monthly period renews on, 1 through 31. |
| `cancel_at_period_end` | boolean | required | Whether this [subscription](/docs/concepts/subscriptions) stops at the end of its current period instead of right away. |
| `scheduled_variant_id` | string (uuid) | optional | The [plan variant](/docs/concepts/plan-variant-entitlements) a pending `downgradeSubscription` will switch to at the next renewal. `null` with no downgrade pending. An `upgradeSubscription` call clears this, canceling the pending downgrade outright. |
| `external_subscription_id` | string | optional | Your own identifier for this [subscription](/docs/concepts/subscriptions). `renewSubscription`'s `external_payment_id` writes here too — the two calls share this one column, not two separate fields. |
| `paused_at` | string (date-time) | optional | When `pauseSubscription` last ran. `null` if this [subscription](/docs/concepts/subscriptions) has never paused. |
| `resumed_at` | string (date-time) | optional | When `resumeSubscription` last ran. `null` if this [subscription](/docs/concepts/subscriptions) has never resumed. |
| `expired_at` | string (date-time) | optional | When this [subscription](/docs/concepts/subscriptions) reached `expired`. `null` until it does. |
| `contract_start` | string (date-time) | optional | When the contract began. QuotaStack sets this once, at creation. |
| `contract_end` | string (date-time) | optional | When the contract ends. `null` for an open-ended [subscription](/docs/concepts/subscriptions). |
| `contract_ending_soon_days` | integer | required | Days before `contract_end` that QuotaStack fires `subscription.contract_ending_soon`. A [subscription](/docs/concepts/subscriptions) created with no value here reads `0`, not `30` — the scheduler still treats `0` as `30` when it checks the reminder. |
| `metadata` | object | required | Your own key-value tags, as they stand after the last write that touched them. |
| `created_at` | string (date-time) | required |  |
| `updated_at` | string (date-time) | required |  |

### Errors

- `400` — QuotaStack could not parse the request body as JSON. See [bad-request](/docs/api/errors/bad-request).
- `404` — No [subscription](/docs/concepts/subscriptions) here has this `id`, or no [plan variant](/docs/concepts/plan-variant-entitlements) has this `plan_variant_id`. See [not-found](/docs/api/errors/not-found).
- `422` — A missing `plan_variant_id` returns this, under that name. A retired target variant returns this too, under `variant-not-active` instead. See [validation-error](/docs/api/errors/validation-error).

### See also

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

## Downgrade a subscription

`POST /v1/subscriptions/{id}/downgrade`

- **Idempotency-Key:** Required. Returns 422 without it.
- **Environment:** Sandbox and Live
- **Auth:** Tenant API key
- **Writes:** Sets `scheduled_variant_id`. `plan_variant_id` and the account balance stay as they were until the next renewal applies the switch.
- **Fires:** [`subscription.downgraded`](/docs/api/events/subscription.downgraded)

This call schedules a variant switch for the end of the current period. `plan_variant_id` on the [subscription](/docs/concepts/subscriptions) does not change now — only `scheduled_variant_id` does. QuotaStack grants no credit here. The new variant's credits wait for the renewal that applies the switch. Only an `active` or `trialing` subscription can downgrade. Any other status returns `409`, also undeclared 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 [subscription](/docs/concepts/subscriptions)'s own UUID. Not a customer `id`. If you leave it out: QuotaStack rejects the call. This path segment is required. |

### Body parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `plan_variant_id` | string (uuid) | required | The [plan variant](/docs/concepts/plan-variant-entitlements) to switch to at the next renewal. If you leave it out: QuotaStack rejects the call. This field is required. |
| `metadata` | object | optional | QuotaStack accepts this field and stores nothing from it. Downgrade never reads it. If you leave it out: No difference. This field has no effect either way. |

### Request

```json
{
  "plan_variant_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a12"
}
```

### Response 200

```json
{
  "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a21",
  "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
  "plan_variant_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a12",
  "environment": "live",
  "status": "active",
  "origin": "api",
  "started_at": "2026-07-01T09:00:00Z",
  "trial_ends_at": null,
  "current_period_start": "2026-07-01T09:00:00Z",
  "current_period_end": "2026-08-01T09:00:00Z",
  "billing_anchor": 1,
  "cancel_at_period_end": false,
  "scheduled_variant_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a12",
  "external_subscription_id": null,
  "paused_at": null,
  "resumed_at": null,
  "expired_at": null,
  "contract_start": "2026-07-01T09:00:00Z",
  "contract_end": null,
  "contract_ending_soon_days": 0,
  "metadata": {},
  "created_at": "2026-07-01T09:00:00Z",
  "updated_at": "2026-07-01T09:00:00Z"
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string (uuid) | required |  |
| `tenant_id` | string (uuid) | required |  |
| `customer_id` | string (uuid) | required |  |
| `plan_variant_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` |
| `status` | string | required | This [subscription](/docs/concepts/subscriptions)'s current state in its lifecycle. `trialing` — A trial. QuotaStack moves this to `active` on its own once the trial ends. `active` — The normal state. Credit grants fire on schedule, and usage debits the balance. `cancelling` — Ending at period end, from `cancelSubscription`. Access continues until then. `canceled` — Ended right away, from `cancelSubscription` with `cancel_immediately: true`. Terminal. `expired` — A grace period ran out, or a `cancelling` period ended. Terminal. `paused` — Suspended, from `pauseSubscription`. No credit grants fire, and the billing period does not move. `overdue` — The period ended and nobody called `renewSubscription` yet. Prepaid only. `contract_ended` — The contract's end date passed. `extendSubscription` can bring this back to `active`. |
| `origin` | string | optional | How this [subscription](/docs/concepts/subscriptions) came to exist. `api` — Created through these ten endpoints — the ordinary case. `import` — Brought in through a [legacy cutover](/docs/cookbook/import-existing-customers), not `createSubscription`. |
| `started_at` | string (date-time) | required |  |
| `trial_ends_at` | string (date-time) | optional | When the trial ends. `null` for a [subscription](/docs/concepts/subscriptions) that never had one. |
| `current_period_start` | string (date-time) | optional | The start of the billing period this [subscription](/docs/concepts/subscriptions) is in right now. |
| `current_period_end` | string (date-time) | optional | The end of the billing period this [subscription](/docs/concepts/subscriptions) is in right now. |
| `billing_anchor` | integer | optional | The day of the month a monthly period renews on, 1 through 31. |
| `cancel_at_period_end` | boolean | required | Whether this [subscription](/docs/concepts/subscriptions) stops at the end of its current period instead of right away. |
| `scheduled_variant_id` | string (uuid) | optional | The [plan variant](/docs/concepts/plan-variant-entitlements) a pending `downgradeSubscription` will switch to at the next renewal. `null` with no downgrade pending. An `upgradeSubscription` call clears this, canceling the pending downgrade outright. |
| `external_subscription_id` | string | optional | Your own identifier for this [subscription](/docs/concepts/subscriptions). `renewSubscription`'s `external_payment_id` writes here too — the two calls share this one column, not two separate fields. |
| `paused_at` | string (date-time) | optional | When `pauseSubscription` last ran. `null` if this [subscription](/docs/concepts/subscriptions) has never paused. |
| `resumed_at` | string (date-time) | optional | When `resumeSubscription` last ran. `null` if this [subscription](/docs/concepts/subscriptions) has never resumed. |
| `expired_at` | string (date-time) | optional | When this [subscription](/docs/concepts/subscriptions) reached `expired`. `null` until it does. |
| `contract_start` | string (date-time) | optional | When the contract began. QuotaStack sets this once, at creation. |
| `contract_end` | string (date-time) | optional | When the contract ends. `null` for an open-ended [subscription](/docs/concepts/subscriptions). |
| `contract_ending_soon_days` | integer | required | Days before `contract_end` that QuotaStack fires `subscription.contract_ending_soon`. A [subscription](/docs/concepts/subscriptions) created with no value here reads `0`, not `30` — the scheduler still treats `0` as `30` when it checks the reminder. |
| `metadata` | object | required | Your own key-value tags, as they stand after the last write that touched them. |
| `created_at` | string (date-time) | required |  |
| `updated_at` | string (date-time) | required |  |

### Errors

- `404` — No [subscription](/docs/concepts/subscriptions) here has this `id`, or no [plan variant](/docs/concepts/plan-variant-entitlements) has this `plan_variant_id`. See [not-found](/docs/api/errors/not-found).
- `422` — A missing `plan_variant_id` returns this, under that name. A retired target variant returns this too, under `variant-not-active` instead. See [validation-error](/docs/api/errors/validation-error).

### See also

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

## Cancel a subscription

`POST /v1/subscriptions/{id}/cancel`

- **Idempotency-Key:** Required. Returns 422 without it.
- **Environment:** Sandbox and Live
- **Auth:** Tenant API key
- **Writes:** Sets `status` to `canceled` (immediate) or `cancelling` (default) and merges `metadata`, adding `reason` under `cancel_reason` when sent. Grants and takes no credit.
- **Fires:** [`subscription.canceled`](/docs/api/events/subscription.canceled)

With `cancel_immediately: true`, this call ends the [subscription](/docs/concepts/subscriptions) right away. `status` becomes `canceled`, terminal, no further credit grants. Left at the default `false`, this call schedules the end instead. `status` becomes `cancelling`, `cancel_at_period_end` becomes `true`, and the customer keeps access until the period ends. Then `status` becomes `expired`. QuotaStack grants and takes back no credit either way — remaining balance is not refunded. A `reason` you send lands in `metadata.cancel_reason`, merged alongside any `metadata` you send. The two modes guard different statuses. The default path only works from `active`; every other status, including `trialing`, `paused`, and `overdue`, returns `409`, undeclared here. `cancel_immediately: true` is more lenient: it works from `trialing`, `active`, `cancelling`, `paused`, or `overdue`, and 409s only from `canceled`, `expired`, or `contract_ended`.

### 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 [subscription](/docs/concepts/subscriptions)'s own UUID. Not a customer `id`. If you leave it out: QuotaStack rejects the call. This path segment is required. |

### Body parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `cancel_immediately` | boolean | optional | `true` ends the [subscription](/docs/concepts/subscriptions) right away. `false` schedules the end for when the current period finishes. If you leave it out: By default, it is `false` — cancel at period end. |
| `reason` | string | optional | Your own words for why. QuotaStack stores this under `metadata.cancel_reason`. If you leave it out: Nothing gets written to `metadata.cancel_reason`. |
| `metadata` | object | optional | Your own key-value tags. QuotaStack merges this into the existing map, one key at a time. If you leave it out: Nothing changes. The tags stay as they are. |

### Request

```json
{
  "cancel_immediately": false,
  "reason": "Customer requested downgrade"
}
```

### Response 200

```json
{
  "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a21",
  "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
  "plan_variant_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a12",
  "environment": "live",
  "status": "cancelling",
  "origin": "api",
  "started_at": "2026-07-01T09:00:00Z",
  "trial_ends_at": null,
  "current_period_start": "2026-07-01T09:00:00Z",
  "current_period_end": "2026-08-01T09:00:00Z",
  "billing_anchor": 1,
  "cancel_at_period_end": true,
  "scheduled_variant_id": null,
  "external_subscription_id": null,
  "paused_at": null,
  "resumed_at": null,
  "expired_at": null,
  "contract_start": "2026-07-01T09:00:00Z",
  "contract_end": null,
  "contract_ending_soon_days": 0,
  "metadata": {
    "cancel_reason": "Customer requested downgrade"
  },
  "created_at": "2026-07-01T09:00:00Z",
  "updated_at": "2026-07-01T09:00:00Z"
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string (uuid) | required |  |
| `tenant_id` | string (uuid) | required |  |
| `customer_id` | string (uuid) | required |  |
| `plan_variant_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` |
| `status` | string | required | This [subscription](/docs/concepts/subscriptions)'s current state in its lifecycle. `trialing` — A trial. QuotaStack moves this to `active` on its own once the trial ends. `active` — The normal state. Credit grants fire on schedule, and usage debits the balance. `cancelling` — Ending at period end, from `cancelSubscription`. Access continues until then. `canceled` — Ended right away, from `cancelSubscription` with `cancel_immediately: true`. Terminal. `expired` — A grace period ran out, or a `cancelling` period ended. Terminal. `paused` — Suspended, from `pauseSubscription`. No credit grants fire, and the billing period does not move. `overdue` — The period ended and nobody called `renewSubscription` yet. Prepaid only. `contract_ended` — The contract's end date passed. `extendSubscription` can bring this back to `active`. |
| `origin` | string | optional | How this [subscription](/docs/concepts/subscriptions) came to exist. `api` — Created through these ten endpoints — the ordinary case. `import` — Brought in through a [legacy cutover](/docs/cookbook/import-existing-customers), not `createSubscription`. |
| `started_at` | string (date-time) | required |  |
| `trial_ends_at` | string (date-time) | optional | When the trial ends. `null` for a [subscription](/docs/concepts/subscriptions) that never had one. |
| `current_period_start` | string (date-time) | optional | The start of the billing period this [subscription](/docs/concepts/subscriptions) is in right now. |
| `current_period_end` | string (date-time) | optional | The end of the billing period this [subscription](/docs/concepts/subscriptions) is in right now. |
| `billing_anchor` | integer | optional | The day of the month a monthly period renews on, 1 through 31. |
| `cancel_at_period_end` | boolean | required | Whether this [subscription](/docs/concepts/subscriptions) stops at the end of its current period instead of right away. |
| `scheduled_variant_id` | string (uuid) | optional | The [plan variant](/docs/concepts/plan-variant-entitlements) a pending `downgradeSubscription` will switch to at the next renewal. `null` with no downgrade pending. An `upgradeSubscription` call clears this, canceling the pending downgrade outright. |
| `external_subscription_id` | string | optional | Your own identifier for this [subscription](/docs/concepts/subscriptions). `renewSubscription`'s `external_payment_id` writes here too — the two calls share this one column, not two separate fields. |
| `paused_at` | string (date-time) | optional | When `pauseSubscription` last ran. `null` if this [subscription](/docs/concepts/subscriptions) has never paused. |
| `resumed_at` | string (date-time) | optional | When `resumeSubscription` last ran. `null` if this [subscription](/docs/concepts/subscriptions) has never resumed. |
| `expired_at` | string (date-time) | optional | When this [subscription](/docs/concepts/subscriptions) reached `expired`. `null` until it does. |
| `contract_start` | string (date-time) | optional | When the contract began. QuotaStack sets this once, at creation. |
| `contract_end` | string (date-time) | optional | When the contract ends. `null` for an open-ended [subscription](/docs/concepts/subscriptions). |
| `contract_ending_soon_days` | integer | required | Days before `contract_end` that QuotaStack fires `subscription.contract_ending_soon`. A [subscription](/docs/concepts/subscriptions) created with no value here reads `0`, not `30` — the scheduler still treats `0` as `30` when it checks the reminder. |
| `metadata` | object | required | Your own key-value tags, as they stand after the last write that touched them. |
| `created_at` | string (date-time) | required |  |
| `updated_at` | string (date-time) | required |  |

### Default: ends at period end

The customer keeps access until the period ends. Then the [subscription](/docs/concepts/subscriptions) expires on its own.

```json
{
  "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a21",
  "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
  "plan_variant_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a12",
  "environment": "live",
  "status": "cancelling",
  "origin": "api",
  "started_at": "2026-07-01T09:00:00Z",
  "trial_ends_at": null,
  "current_period_start": "2026-07-01T09:00:00Z",
  "current_period_end": "2026-08-01T09:00:00Z",
  "billing_anchor": 1,
  "cancel_at_period_end": true,
  "scheduled_variant_id": null,
  "external_subscription_id": null,
  "paused_at": null,
  "resumed_at": null,
  "expired_at": null,
  "contract_start": "2026-07-01T09:00:00Z",
  "contract_end": null,
  "contract_ending_soon_days": 0,
  "metadata": {
    "cancel_reason": "Customer requested downgrade"
  },
  "created_at": "2026-07-01T09:00:00Z",
  "updated_at": "2026-07-01T09:00:00Z"
}
```

### `cancel_immediately: true`

The [subscription](/docs/concepts/subscriptions) ends right away. Terminal — nothing here changes again.

```json
{
  "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a21",
  "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
  "plan_variant_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a12",
  "environment": "live",
  "status": "canceled",
  "origin": "api",
  "started_at": "2026-07-01T09:00:00Z",
  "trial_ends_at": null,
  "current_period_start": "2026-07-01T09:00:00Z",
  "current_period_end": "2026-08-01T09:00:00Z",
  "billing_anchor": 1,
  "cancel_at_period_end": false,
  "scheduled_variant_id": null,
  "external_subscription_id": null,
  "paused_at": null,
  "resumed_at": null,
  "expired_at": null,
  "contract_start": "2026-07-01T09:00:00Z",
  "contract_end": null,
  "contract_ending_soon_days": 0,
  "metadata": {},
  "created_at": "2026-07-01T09:00:00Z",
  "updated_at": "2026-07-01T09:00:00Z"
}
```

### Errors

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

### See also

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

## Pause a subscription

`POST /v1/subscriptions/{id}/pause`

- **Idempotency-Key:** Required. Returns 422 without it.
- **Environment:** Sandbox and Live
- **Auth:** Tenant API key
- **Writes:** Sets `status` to `paused` and records `paused_at`. `resume_at` and `metadata` are accepted and discarded.
- **Fires:** [`subscription.paused`](/docs/api/events/subscription.paused)

This call suspends a [subscription](/docs/concepts/subscriptions) without ending it. QuotaStack fires no credit grants and does not move the billing period while paused. Existing credit stays spendable. Only an `active` subscription can pause — not `trialing`, not `overdue`. An unknown `id` returns `404`. Pausing from any other status returns `409`. openapi.yaml declares neither status for this call. `resume_at` and `metadata` on the request have no effect. QuotaStack accepts both and stores neither. Nothing auto-resumes a paused subscription — call `resumeSubscription` when you want it back.

### 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 [subscription](/docs/concepts/subscriptions)'s own UUID. Not a customer `id`. If you leave it out: QuotaStack rejects the call. This path segment is required. |

### Body parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `resume_at` | string (date-time) | optional | QuotaStack accepts this field and stores nothing from it. Nothing auto-resumes a paused [subscription](/docs/concepts/subscriptions). If you leave it out: No difference. This field has no effect either way — call `resumeSubscription` yourself, whenever that should be. |
| `metadata` | object | optional | QuotaStack accepts this field and stores nothing from it. Pause never reads it. If you leave it out: No difference. This field has no effect either way. |

### Request

```json
{
  "resume_at": "2026-08-01T00:00:00Z"
}
```

### Response 200

```json
{
  "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a21",
  "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
  "plan_variant_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a12",
  "environment": "live",
  "status": "paused",
  "origin": "api",
  "started_at": "2026-07-01T09:00:00Z",
  "trial_ends_at": null,
  "current_period_start": "2026-07-01T09:00:00Z",
  "current_period_end": "2026-08-01T09:00:00Z",
  "billing_anchor": 1,
  "cancel_at_period_end": false,
  "scheduled_variant_id": null,
  "external_subscription_id": null,
  "paused_at": "2026-07-20T11:00:00Z",
  "resumed_at": null,
  "expired_at": null,
  "contract_start": "2026-07-01T09:00:00Z",
  "contract_end": null,
  "contract_ending_soon_days": 0,
  "metadata": {},
  "created_at": "2026-07-01T09:00:00Z",
  "updated_at": "2026-07-01T09:00:00Z"
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string (uuid) | required |  |
| `tenant_id` | string (uuid) | required |  |
| `customer_id` | string (uuid) | required |  |
| `plan_variant_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` |
| `status` | string | required | This [subscription](/docs/concepts/subscriptions)'s current state in its lifecycle. `trialing` — A trial. QuotaStack moves this to `active` on its own once the trial ends. `active` — The normal state. Credit grants fire on schedule, and usage debits the balance. `cancelling` — Ending at period end, from `cancelSubscription`. Access continues until then. `canceled` — Ended right away, from `cancelSubscription` with `cancel_immediately: true`. Terminal. `expired` — A grace period ran out, or a `cancelling` period ended. Terminal. `paused` — Suspended, from `pauseSubscription`. No credit grants fire, and the billing period does not move. `overdue` — The period ended and nobody called `renewSubscription` yet. Prepaid only. `contract_ended` — The contract's end date passed. `extendSubscription` can bring this back to `active`. |
| `origin` | string | optional | How this [subscription](/docs/concepts/subscriptions) came to exist. `api` — Created through these ten endpoints — the ordinary case. `import` — Brought in through a [legacy cutover](/docs/cookbook/import-existing-customers), not `createSubscription`. |
| `started_at` | string (date-time) | required |  |
| `trial_ends_at` | string (date-time) | optional | When the trial ends. `null` for a [subscription](/docs/concepts/subscriptions) that never had one. |
| `current_period_start` | string (date-time) | optional | The start of the billing period this [subscription](/docs/concepts/subscriptions) is in right now. |
| `current_period_end` | string (date-time) | optional | The end of the billing period this [subscription](/docs/concepts/subscriptions) is in right now. |
| `billing_anchor` | integer | optional | The day of the month a monthly period renews on, 1 through 31. |
| `cancel_at_period_end` | boolean | required | Whether this [subscription](/docs/concepts/subscriptions) stops at the end of its current period instead of right away. |
| `scheduled_variant_id` | string (uuid) | optional | The [plan variant](/docs/concepts/plan-variant-entitlements) a pending `downgradeSubscription` will switch to at the next renewal. `null` with no downgrade pending. An `upgradeSubscription` call clears this, canceling the pending downgrade outright. |
| `external_subscription_id` | string | optional | Your own identifier for this [subscription](/docs/concepts/subscriptions). `renewSubscription`'s `external_payment_id` writes here too — the two calls share this one column, not two separate fields. |
| `paused_at` | string (date-time) | optional | When `pauseSubscription` last ran. `null` if this [subscription](/docs/concepts/subscriptions) has never paused. |
| `resumed_at` | string (date-time) | optional | When `resumeSubscription` last ran. `null` if this [subscription](/docs/concepts/subscriptions) has never resumed. |
| `expired_at` | string (date-time) | optional | When this [subscription](/docs/concepts/subscriptions) reached `expired`. `null` until it does. |
| `contract_start` | string (date-time) | optional | When the contract began. QuotaStack sets this once, at creation. |
| `contract_end` | string (date-time) | optional | When the contract ends. `null` for an open-ended [subscription](/docs/concepts/subscriptions). |
| `contract_ending_soon_days` | integer | required | Days before `contract_end` that QuotaStack fires `subscription.contract_ending_soon`. A [subscription](/docs/concepts/subscriptions) created with no value here reads `0`, not `30` — the scheduler still treats `0` as `30` when it checks the reminder. |
| `metadata` | object | required | Your own key-value tags, as they stand after the last write that touched them. |
| `created_at` | string (date-time) | required |  |
| `updated_at` | string (date-time) | required |  |

### See also

- [Subscriptions](/docs/concepts/subscriptions)

## Resume a subscription

`POST /v1/subscriptions/{id}/resume`

- **Idempotency-Key:** Required. Returns 422 without it.
- **Environment:** Sandbox and Live
- **Auth:** Tenant API key
- **Writes:** Sets `status` to `active`, records `resumed_at`, and resets `current_period_start`/`current_period_end` to a fresh period starting now. Merges `metadata`. Grants credit for the new period, best-effort, with no rollover or `accumulation_cap` applied.
- **Fires:** [`subscription.resumed`](/docs/api/events/subscription.resumed), [`credit.granted`](/docs/api/events/credit.granted)

This call is for a `paused` [subscription](/docs/concepts/subscriptions). QuotaStack's status guard also allows a `trialing`, `overdue`, or `contract_ended` subscription to resume, though none of those is the case this call is built for. Resuming a `contract_ended` subscription reactivates it too — the same outcome `extendSubscription` produces. Resuming restarts the billing period from right now. Whatever time was left when it paused is gone. QuotaStack also grants credit for the new period, best-effort, the same amount `createSubscription` grants on activation: no rollover, no `accumulation_cap`. A grant with `grant_interval: on_activation` or `grant_type: one_time` does not fire again on resume. An unknown `id` returns `404`. Resuming from a status that cannot reach `active` returns `409`. Neither status is declared for this 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}`. |

### Path parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string (uuid) | required | The [subscription](/docs/concepts/subscriptions)'s own UUID. Not a customer `id`. If you leave it out: QuotaStack rejects the call. This path segment is required. |

### Body parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `metadata` | object | optional | Your own key-value tags. QuotaStack merges this into the existing map, one key at a time. If you leave it out: Nothing changes. The tags stay as they are. |

### Request

```json
{
  "metadata": {
    "resumed_via": "support-ticket-482"
  }
}
```

### Response 200

```json
{
  "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a21",
  "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
  "plan_variant_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a12",
  "environment": "live",
  "status": "active",
  "origin": "api",
  "started_at": "2026-07-01T09:00:00Z",
  "trial_ends_at": null,
  "current_period_start": "2026-07-22T08:00:00Z",
  "current_period_end": "2026-08-22T08:00:00Z",
  "billing_anchor": 1,
  "cancel_at_period_end": false,
  "scheduled_variant_id": null,
  "external_subscription_id": null,
  "paused_at": null,
  "resumed_at": "2026-07-22T08:00:00Z",
  "expired_at": null,
  "contract_start": "2026-07-01T09:00:00Z",
  "contract_end": null,
  "contract_ending_soon_days": 0,
  "metadata": {
    "resumed_via": "support-ticket-482"
  },
  "created_at": "2026-07-01T09:00:00Z",
  "updated_at": "2026-07-01T09:00:00Z"
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string (uuid) | required |  |
| `tenant_id` | string (uuid) | required |  |
| `customer_id` | string (uuid) | required |  |
| `plan_variant_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` |
| `status` | string | required | This [subscription](/docs/concepts/subscriptions)'s current state in its lifecycle. `trialing` — A trial. QuotaStack moves this to `active` on its own once the trial ends. `active` — The normal state. Credit grants fire on schedule, and usage debits the balance. `cancelling` — Ending at period end, from `cancelSubscription`. Access continues until then. `canceled` — Ended right away, from `cancelSubscription` with `cancel_immediately: true`. Terminal. `expired` — A grace period ran out, or a `cancelling` period ended. Terminal. `paused` — Suspended, from `pauseSubscription`. No credit grants fire, and the billing period does not move. `overdue` — The period ended and nobody called `renewSubscription` yet. Prepaid only. `contract_ended` — The contract's end date passed. `extendSubscription` can bring this back to `active`. |
| `origin` | string | optional | How this [subscription](/docs/concepts/subscriptions) came to exist. `api` — Created through these ten endpoints — the ordinary case. `import` — Brought in through a [legacy cutover](/docs/cookbook/import-existing-customers), not `createSubscription`. |
| `started_at` | string (date-time) | required |  |
| `trial_ends_at` | string (date-time) | optional | When the trial ends. `null` for a [subscription](/docs/concepts/subscriptions) that never had one. |
| `current_period_start` | string (date-time) | optional | The start of the billing period this [subscription](/docs/concepts/subscriptions) is in right now. |
| `current_period_end` | string (date-time) | optional | The end of the billing period this [subscription](/docs/concepts/subscriptions) is in right now. |
| `billing_anchor` | integer | optional | The day of the month a monthly period renews on, 1 through 31. |
| `cancel_at_period_end` | boolean | required | Whether this [subscription](/docs/concepts/subscriptions) stops at the end of its current period instead of right away. |
| `scheduled_variant_id` | string (uuid) | optional | The [plan variant](/docs/concepts/plan-variant-entitlements) a pending `downgradeSubscription` will switch to at the next renewal. `null` with no downgrade pending. An `upgradeSubscription` call clears this, canceling the pending downgrade outright. |
| `external_subscription_id` | string | optional | Your own identifier for this [subscription](/docs/concepts/subscriptions). `renewSubscription`'s `external_payment_id` writes here too — the two calls share this one column, not two separate fields. |
| `paused_at` | string (date-time) | optional | When `pauseSubscription` last ran. `null` if this [subscription](/docs/concepts/subscriptions) has never paused. |
| `resumed_at` | string (date-time) | optional | When `resumeSubscription` last ran. `null` if this [subscription](/docs/concepts/subscriptions) has never resumed. |
| `expired_at` | string (date-time) | optional | When this [subscription](/docs/concepts/subscriptions) reached `expired`. `null` until it does. |
| `contract_start` | string (date-time) | optional | When the contract began. QuotaStack sets this once, at creation. |
| `contract_end` | string (date-time) | optional | When the contract ends. `null` for an open-ended [subscription](/docs/concepts/subscriptions). |
| `contract_ending_soon_days` | integer | required | Days before `contract_end` that QuotaStack fires `subscription.contract_ending_soon`. A [subscription](/docs/concepts/subscriptions) created with no value here reads `0`, not `30` — the scheduler still treats `0` as `30` when it checks the reminder. |
| `metadata` | object | required | Your own key-value tags, as they stand after the last write that touched them. |
| `created_at` | string (date-time) | required |  |
| `updated_at` | string (date-time) | required |  |

### See also

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

## Renew a subscription

`POST /v1/subscriptions/{id}/renew`

- **Idempotency-Key:** Required. Returns 422 without it.
- **Environment:** Sandbox and Live
- **Auth:** Tenant API key
- **Writes:** Advances `current_period_start`/`current_period_end` and grants that cycle's credit, applying `rollover_percentage`, `max_rollover_cycles`, and `accumulation_cap`. Merges `metadata`. `external_payment_id` overwrites `external_subscription_id`.
- **Fires:** [`subscription.renewed`](/docs/api/events/subscription.renewed), [`credit.granted`](/docs/api/events/credit.granted)

This call is the credit-grant path for a manual prepaid [subscription](/docs/concepts/subscriptions). Renewing moves the billing period forward and grants that cycle's credit. A connector-managed subscription advances only after a verified provider payment. This public call returns `409` for one. This call is the only one of the ten that honors `rollover_percentage`, `max_rollover_cycles`, and `accumulation_cap`. A postpaid variant moves its own period forward; you never call this for one. `external_payment_id`, when sent, overwrites `external_subscription_id` directly — it is not a field of its own. QuotaStack merges `metadata`. A failed grant aborts the whole call: nothing moves, and QuotaStack saves nothing. A retry is safe. If two renewal attempts race for the same [subscription](/docs/concepts/subscriptions), the loser gets `200` back with `credits_granted: 0`. Treat that as done, not as a failure. A race with `upgradeSubscription`, `downgradeSubscription`, `cancelSubscription`, `pauseSubscription`, or `resumeSubscription` on the same subscription is a different case. This kind of race can fail the call with `500` instead, a status the spec never lists here. Any credit already granted stays granted, and a later retry will not grant it twice. An unknown `id` returns `404`. Renewing from a status that cannot renew, one already set to cancel, or one managed by a payment connector returns `409`. Neither is declared. This reply is not the full `Subscription` object either: the real reply adds `credits_granted`, in [millicredits](/docs/concepts/credits), and drops `origin`, `started_at`, `trial_ends_at`, `paused_at`, `resumed_at`, `expired_at`, `contract_start`, `contract_end`, and `contract_ending_soon_days`. The `subscription.renewed` [webhook](/docs/concepts/webhooks) this call fires carries a different payload than this reply. That payload carries usage and balance totals for the period that ended, not the `Subscription` fields 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 [subscription](/docs/concepts/subscriptions)'s own UUID. Not a customer `id`. If you leave it out: QuotaStack rejects the call. This path segment is required. |

### Body parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `external_payment_id` | string | optional | Your own id for the payment behind this renewal. QuotaStack writes it into `external_subscription_id` — the same field `createSubscription` sets, not a field of its own. If you leave it out: `external_subscription_id` does not change. |
| `metadata` | object | optional | Your own key-value tags. QuotaStack merges this into the existing map, one key at a time. If you leave it out: Nothing changes. The tags stay as they are. |

### Request

```json
{
  "external_payment_id": "pay_xyz789"
}
```

### Response 200

```json
{
  "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a21",
  "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
  "plan_variant_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a12",
  "environment": "live",
  "status": "active",
  "current_period_start": "2026-08-01T00:00:00Z",
  "current_period_end": "2026-09-01T00:00:00Z",
  "billing_anchor": 1,
  "cancel_at_period_end": false,
  "scheduled_variant_id": null,
  "external_subscription_id": "pay_xyz789",
  "credits_granted": 10000,
  "metadata": {},
  "created_at": "2026-06-01T09:00:00Z",
  "updated_at": "2026-08-01T00:03:00Z"
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string (uuid) | required |  |
| `tenant_id` | string (uuid) | required |  |
| `customer_id` | string (uuid) | required |  |
| `plan_variant_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` |
| `status` | string | required | This [subscription](/docs/concepts/subscriptions)'s current state in its lifecycle. `trialing` — A trial. QuotaStack moves this to `active` on its own once the trial ends. `active` — The normal state. Credit grants fire on schedule, and usage debits the balance. `cancelling` — Ending at period end, from `cancelSubscription`. Access continues until then. `canceled` — Ended right away, from `cancelSubscription` with `cancel_immediately: true`. Terminal. `expired` — A grace period ran out, or a `cancelling` period ended. Terminal. `paused` — Suspended, from `pauseSubscription`. No credit grants fire, and the billing period does not move. `overdue` — The period ended and nobody called `renewSubscription` yet. Prepaid only. `contract_ended` — The contract's end date passed. `extendSubscription` can bring this back to `active`. |
| `origin` | string | optional | How this [subscription](/docs/concepts/subscriptions) came to exist. `api` — Created through these ten endpoints — the ordinary case. `import` — Brought in through a [legacy cutover](/docs/cookbook/import-existing-customers), not `createSubscription`. |
| `started_at` | string (date-time) | required |  |
| `trial_ends_at` | string (date-time) | optional | When the trial ends. `null` for a [subscription](/docs/concepts/subscriptions) that never had one. |
| `current_period_start` | string (date-time) | optional | The start of the billing period this [subscription](/docs/concepts/subscriptions) is in right now. |
| `current_period_end` | string (date-time) | optional | The end of the billing period this [subscription](/docs/concepts/subscriptions) is in right now. |
| `billing_anchor` | integer | optional | The day of the month a monthly period renews on, 1 through 31. |
| `cancel_at_period_end` | boolean | required | Whether this [subscription](/docs/concepts/subscriptions) stops at the end of its current period instead of right away. |
| `scheduled_variant_id` | string (uuid) | optional | The [plan variant](/docs/concepts/plan-variant-entitlements) a pending `downgradeSubscription` will switch to at the next renewal. `null` with no downgrade pending. An `upgradeSubscription` call clears this, canceling the pending downgrade outright. |
| `external_subscription_id` | string | optional | Your own identifier for this [subscription](/docs/concepts/subscriptions). `renewSubscription`'s `external_payment_id` writes here too — the two calls share this one column, not two separate fields. |
| `paused_at` | string (date-time) | optional | When `pauseSubscription` last ran. `null` if this [subscription](/docs/concepts/subscriptions) has never paused. |
| `resumed_at` | string (date-time) | optional | When `resumeSubscription` last ran. `null` if this [subscription](/docs/concepts/subscriptions) has never resumed. |
| `expired_at` | string (date-time) | optional | When this [subscription](/docs/concepts/subscriptions) reached `expired`. `null` until it does. |
| `contract_start` | string (date-time) | optional | When the contract began. QuotaStack sets this once, at creation. |
| `contract_end` | string (date-time) | optional | When the contract ends. `null` for an open-ended [subscription](/docs/concepts/subscriptions). |
| `contract_ending_soon_days` | integer | required | Days before `contract_end` that QuotaStack fires `subscription.contract_ending_soon`. A [subscription](/docs/concepts/subscriptions) created with no value here reads `0`, not `30` — the scheduler still treats `0` as `30` when it checks the reminder. |
| `metadata` | object | required | Your own key-value tags, as they stand after the last write that touched them. |
| `created_at` | string (date-time) | required |  |
| `updated_at` | string (date-time) | required |  |

### The normal case

The period moved forward. This cycle's credit was granted too.

```json
{
  "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a21",
  "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
  "plan_variant_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a12",
  "environment": "live",
  "status": "active",
  "current_period_start": "2026-08-01T00:00:00Z",
  "current_period_end": "2026-09-01T00:00:00Z",
  "billing_anchor": 1,
  "cancel_at_period_end": false,
  "scheduled_variant_id": null,
  "external_subscription_id": "pay_xyz789",
  "credits_granted": 10000,
  "metadata": {},
  "created_at": "2026-06-01T09:00:00Z",
  "updated_at": "2026-08-01T00:03:00Z"
}
```

### Someone else renewed this cycle first

The scheduler, or another call, already advanced this period. QuotaStack grants nothing and reports the [subscription](/docs/concepts/subscriptions) as it now stands. Treat this as success, not as an error.

```json
{
  "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a21",
  "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
  "plan_variant_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a12",
  "environment": "live",
  "status": "active",
  "current_period_start": "2026-08-01T00:00:00Z",
  "current_period_end": "2026-09-01T00:00:00Z",
  "billing_anchor": 1,
  "cancel_at_period_end": false,
  "scheduled_variant_id": null,
  "external_subscription_id": "pay_xyz789",
  "credits_granted": 0,
  "metadata": {},
  "created_at": "2026-06-01T09:00:00Z",
  "updated_at": "2026-08-01T00:02:47Z"
}
```

### See also

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

## Extend a contract

`POST /v1/subscriptions/{id}/extend`

- **Idempotency-Key:** Required. Returns 422 without it.
- **Environment:** Sandbox and Live
- **Auth:** Tenant API key
- **Writes:** Replaces `contract_end` and merges `metadata`. Reactivates a `contract_ended` [subscription](/docs/concepts/subscriptions) to `active`; every other status is unaffected. Grants no credit and fires no event.
- **Fires:** No webhooks.

This call changes `contract_end` alone. The billing period stays put, and no credit changes hands — use `renewSubscription` for that. This call works on a [subscription](/docs/concepts/subscriptions) in any status. Only a `contract_ended` subscription gets reactivated, back to `active`. Every other status stays as it is. Omitting `contract_end` does not return an error. QuotaStack decodes the missing field as `0001-01-01T00:00:00Z` and stores that as the new contract end. Always send a real date. `reason` has no effect — unlike `cancelSubscription`'s `reason`, QuotaStack accepts this one and stores it nowhere. An unknown `id` returns `404`, undeclared 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 [subscription](/docs/concepts/subscriptions)'s own UUID. Not a customer `id`. If you leave it out: QuotaStack rejects the call. This path segment is required. |

### Body parameters

| Field | Type | Required | Meaning |
|---|---|---|---|
| `contract_end` | string (date-time) | required | The contract's new end date. If you leave it out: QuotaStack does not reject a missing value. QuotaStack stores the zero date `0001-01-01T00:00:00Z`. Always send a real one. |
| `reason` | string | optional | QuotaStack accepts this field and stores nothing from it, unlike `cancelSubscription`'s `reason`. If you leave it out: No difference. This field has no effect either way. |
| `metadata` | object | optional | Your own key-value tags. QuotaStack merges this into the existing map, one key at a time. If you leave it out: Nothing changes. The tags stay as they are. |

### Request

```json
{
  "contract_end": "2027-07-01T00:00:00Z",
  "reason": "Annual renewal"
}
```

### Response 200

```json
{
  "id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a21",
  "customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
  "plan_variant_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a12",
  "environment": "live",
  "status": "active",
  "origin": "api",
  "started_at": "2026-07-01T09:00:00Z",
  "trial_ends_at": null,
  "current_period_start": "2026-07-01T09:00:00Z",
  "current_period_end": "2026-08-01T09:00:00Z",
  "billing_anchor": 1,
  "cancel_at_period_end": false,
  "scheduled_variant_id": null,
  "external_subscription_id": null,
  "paused_at": null,
  "resumed_at": null,
  "expired_at": null,
  "contract_start": "2026-07-01T09:00:00Z",
  "contract_end": "2027-07-01T00:00:00Z",
  "contract_ending_soon_days": 0,
  "metadata": {},
  "created_at": "2026-07-01T09:00:00Z",
  "updated_at": "2026-07-01T09:00:00Z"
}
```

### Response fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string (uuid) | required |  |
| `tenant_id` | string (uuid) | required |  |
| `customer_id` | string (uuid) | required |  |
| `plan_variant_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` |
| `status` | string | required | This [subscription](/docs/concepts/subscriptions)'s current state in its lifecycle. `trialing` — A trial. QuotaStack moves this to `active` on its own once the trial ends. `active` — The normal state. Credit grants fire on schedule, and usage debits the balance. `cancelling` — Ending at period end, from `cancelSubscription`. Access continues until then. `canceled` — Ended right away, from `cancelSubscription` with `cancel_immediately: true`. Terminal. `expired` — A grace period ran out, or a `cancelling` period ended. Terminal. `paused` — Suspended, from `pauseSubscription`. No credit grants fire, and the billing period does not move. `overdue` — The period ended and nobody called `renewSubscription` yet. Prepaid only. `contract_ended` — The contract's end date passed. `extendSubscription` can bring this back to `active`. |
| `origin` | string | optional | How this [subscription](/docs/concepts/subscriptions) came to exist. `api` — Created through these ten endpoints — the ordinary case. `import` — Brought in through a [legacy cutover](/docs/cookbook/import-existing-customers), not `createSubscription`. |
| `started_at` | string (date-time) | required |  |
| `trial_ends_at` | string (date-time) | optional | When the trial ends. `null` for a [subscription](/docs/concepts/subscriptions) that never had one. |
| `current_period_start` | string (date-time) | optional | The start of the billing period this [subscription](/docs/concepts/subscriptions) is in right now. |
| `current_period_end` | string (date-time) | optional | The end of the billing period this [subscription](/docs/concepts/subscriptions) is in right now. |
| `billing_anchor` | integer | optional | The day of the month a monthly period renews on, 1 through 31. |
| `cancel_at_period_end` | boolean | required | Whether this [subscription](/docs/concepts/subscriptions) stops at the end of its current period instead of right away. |
| `scheduled_variant_id` | string (uuid) | optional | The [plan variant](/docs/concepts/plan-variant-entitlements) a pending `downgradeSubscription` will switch to at the next renewal. `null` with no downgrade pending. An `upgradeSubscription` call clears this, canceling the pending downgrade outright. |
| `external_subscription_id` | string | optional | Your own identifier for this [subscription](/docs/concepts/subscriptions). `renewSubscription`'s `external_payment_id` writes here too — the two calls share this one column, not two separate fields. |
| `paused_at` | string (date-time) | optional | When `pauseSubscription` last ran. `null` if this [subscription](/docs/concepts/subscriptions) has never paused. |
| `resumed_at` | string (date-time) | optional | When `resumeSubscription` last ran. `null` if this [subscription](/docs/concepts/subscriptions) has never resumed. |
| `expired_at` | string (date-time) | optional | When this [subscription](/docs/concepts/subscriptions) reached `expired`. `null` until it does. |
| `contract_start` | string (date-time) | optional | When the contract began. QuotaStack sets this once, at creation. |
| `contract_end` | string (date-time) | optional | When the contract ends. `null` for an open-ended [subscription](/docs/concepts/subscriptions). |
| `contract_ending_soon_days` | integer | required | Days before `contract_end` that QuotaStack fires `subscription.contract_ending_soon`. A [subscription](/docs/concepts/subscriptions) created with no value here reads `0`, not `30` — the scheduler still treats `0` as `30` when it checks the reminder. |
| `metadata` | object | required | Your own key-value tags, as they stand after the last write that touched them. |
| `created_at` | string (date-time) | required |  |
| `updated_at` | string (date-time) | required |  |

### See also

- [Subscriptions](/docs/concepts/subscriptions)
