---
title: Subscriptions
description: Optional recurring billing state management with prepaid and postpaid modes, credit grants on renewal, rollover, and contract lifecycle.
order: 6
---

# Subscriptions

Subscriptions in QuotaStack are **optional**. Many tenants use a pure wallet + topup model: customers buy credit packs, credits land in their wallet, usage debits from the wallet. No subscription needed.

Use subscriptions when you need:

- Recurring billing cycles (monthly, yearly, etc.)
- Automatic credit replenishment each period
- Contract periods with defined start and end dates
- Trial periods before activation
- Rollover of unused credits between cycles

> **Mental Model:** Subscriptions are **state machines for recurring billing**. QuotaStack tracks the cycle and credits; you handle the payment. Prepaid: you drive renewal. Postpaid: QuotaStack auto-advances and sends you a usage summary.

## Quick Take

- Subscriptions are **optional** — many apps use pure wallet + topup instead
- Two modes: **prepaid** (you charge and renew) vs **postpaid** (auto-renews with usage summary)
- Credit grants on renewal with configurable **rollover** and accumulation caps
- Contract support for fixed-term commitments with auto-renewal or expiry

## Diagram

Subscription lifecycle timeline with five milestones: Create → Grant credits → Renew → Grant + rollover → Cancel / expire.

```mermaid
flowchart LR
    A[Create] --> B[Grant credits]
    B --> C[Renew]
    C --> D[Grant + rollover]
    D --> E[Cancel / expire]
```

## QuotaStack manages state, not payment

This is the core principle. QuotaStack tracks subscription status, billing periods, credit grants, and contract timelines. Your application handles payment collection. The flow is always:

1. QuotaStack tells you something needs to happen (via webhook).
2. You charge the customer in your payment system.
3. You tell QuotaStack the outcome (via API call).

QuotaStack never talks to a payment processor directly.

## Billing modes

Each plan variant has a `billing_mode`: either `prepaid` (default) or `postpaid`.

### Prepaid

The tenant drives renewal. QuotaStack fires a `subscription.renewal_due` webhook before the period ends, giving you time to collect payment.

<div style="display:flex;flex-direction:column;align-items:center;gap:0;margin:20px 0;">
<div style="background:#f3f4f6;color:#374151;padding:8px 20px;border-radius:8px;font-size:13px;font-weight:500;">Period nearing end</div>
<div style="color:#9CA3AF;font-size:16px;padding:4px 0;">&darr;</div>
<div style="background:#ccfbf1;color:#115e59;padding:8px 20px;border-radius:8px;font-size:13px;font-weight:600;">QuotaStack fires subscription.renewal_due webhook</div>
<div style="color:#9CA3AF;font-size:16px;padding:4px 0;">&darr;</div>
<div style="background:#dbeafe;color:#1e40af;padding:8px 20px;border-radius:8px;font-size:13px;font-weight:600;">Tenant charges customer via payment system</div>
<div style="color:#9CA3AF;font-size:16px;padding:4px 0;">&darr;</div>
<div style="background:#dbeafe;color:#1e40af;padding:8px 20px;border-radius:8px;font-size:13px;font-weight:600;font-family:var(--font-mono,monospace);">POST /v1/subscriptions/{id}/renew</div>
<div style="color:#9CA3AF;font-size:16px;padding:4px 0;">&darr;</div>
<div style="background:#dcfce7;color:#166534;padding:8px 20px;border-radius:8px;font-size:13px;font-weight:600;">QuotaStack advances period + grants credits</div>
</div>

Timing is controlled by two fields on the plan variant:

| Field | Default | Description |
|-------|---------|-------------|
| `renewal_due_days` | 3 | Days before period end to fire `subscription.renewal_due` |
| `grace_period_days` | 3 | Days after period end before the subscription expires |
| `allow_usage_while_overdue` | true | Whether the customer can still use credits during the grace period |

If the tenant does not call the renew endpoint before the grace period expires, the subscription transitions: `active` -> `overdue` -> `expired`.

### Postpaid

QuotaStack auto-advances the billing cycle. At period end, the cycle rolls forward automatically and a `subscription.renewed` webhook fires with a `usage_summary` payload containing credits consumed during the prior period. The tenant uses this to generate an invoice.

<div style="display:flex;flex-direction:column;align-items:center;gap:0;margin:20px 0;">
<div style="background:#f3f4f6;color:#374151;padding:8px 20px;border-radius:8px;font-size:13px;font-weight:500;">Period ends</div>
<div style="color:#9CA3AF;font-size:16px;padding:4px 0;">&darr;</div>
<div style="background:#ccfbf1;color:#115e59;padding:8px 20px;border-radius:8px;font-size:13px;font-weight:600;">QuotaStack auto-advances to next period</div>
<div style="color:#9CA3AF;font-size:16px;padding:4px 0;">&darr;</div>
<div style="background:#ccfbf1;color:#115e59;padding:8px 20px;border-radius:8px;font-size:13px;font-weight:600;text-align:center;max-width:340px;">QuotaStack fires subscription.renewed webhook<br><span style="font-size:11px;font-weight:400;opacity:0.8;">with usage_summary: total consumed, balances, per-metric breakdown</span></div>
<div style="color:#9CA3AF;font-size:16px;padding:4px 0;">&darr;</div>
<div style="background:#dbeafe;color:#1e40af;padding:8px 20px;border-radius:8px;font-size:13px;font-weight:600;">Tenant generates invoice from usage_summary</div>
</div>

Postpaid subscriptions still grant credits each cycle (the plan's credit grants apply). The difference is that the cycle advances without waiting for tenant confirmation.

## Status lifecycle

<svg viewBox="0 0 500 310" fill="none" xmlns="http://www.w3.org/2000/svg" style="width:100%;max-width:500px;height:auto;margin:20px auto;display:block;">
  <!-- trialing -->
  <rect x="10" y="10" width="80" height="32" rx="8" fill="#f3e8ff"/>
  <text x="50" y="31" text-anchor="middle" font-size="11" font-weight="600" fill="#6b21a8" font-family="Inter,system-ui,sans-serif">trialing</text>
  <line x1="90" y1="26" x2="120" y2="26" stroke="#9CA3AF" stroke-width="1.5" marker-end="url(#sl-arrow)"/>

  <!-- active -->
  <rect x="120" y="10" width="70" height="32" rx="8" fill="#dcfce7"/>
  <text x="155" y="31" text-anchor="middle" font-size="11" font-weight="600" fill="#166534" font-family="Inter,system-ui,sans-serif">active</text>

  <!-- active -> cancelling -->
  <line x1="190" y1="26" x2="220" y2="26" stroke="#9CA3AF" stroke-width="1.5" marker-end="url(#sl-arrow)"/>
  <rect x="220" y="10" width="90" height="32" rx="8" fill="#fef3c7"/>
  <text x="265" y="31" text-anchor="middle" font-size="11" font-weight="600" fill="#92400e" font-family="Inter,system-ui,sans-serif">cancelling</text>

  <!-- cancelling -> expired -->
  <line x1="310" y1="26" x2="340" y2="26" stroke="#9CA3AF" stroke-width="1.5" marker-end="url(#sl-arrow)"/>
  <rect x="340" y="10" width="70" height="32" rx="8" fill="#fee2e2"/>
  <text x="375" y="31" text-anchor="middle" font-size="11" font-weight="600" fill="#991b1b" font-family="Inter,system-ui,sans-serif">expired</text>

  <!-- cancelling -> canceled -->
  <line x1="265" y1="42" x2="265" y2="62" stroke="#9CA3AF" stroke-width="1.5" marker-end="url(#sl-arrow)"/>
  <rect x="225" y="66" width="80" height="32" rx="8" fill="#fee2e2"/>
  <text x="265" y="87" text-anchor="middle" font-size="11" font-weight="600" fill="#991b1b" font-family="Inter,system-ui,sans-serif">canceled</text>

  <!-- active -> overdue -->
  <line x1="155" y1="42" x2="155" y2="122" stroke="#9CA3AF" stroke-width="1.5" marker-end="url(#sl-arrow)"/>
  <rect x="120" y="126" width="70" height="32" rx="8" fill="#fef3c7"/>
  <text x="155" y="147" text-anchor="middle" font-size="11" font-weight="600" fill="#92400e" font-family="Inter,system-ui,sans-serif">overdue</text>

  <!-- overdue -> expired -->
  <line x1="190" y1="142" x2="340" y2="142" stroke="#9CA3AF" stroke-width="1.5" marker-end="url(#sl-arrow)"/>
  <rect x="340" y="126" width="70" height="32" rx="8" fill="#fee2e2"/>
  <text x="375" y="147" text-anchor="middle" font-size="11" font-weight="600" fill="#991b1b" font-family="Inter,system-ui,sans-serif">expired</text>

  <!-- overdue -> canceled -->
  <line x1="155" y1="158" x2="155" y2="178" stroke="#9CA3AF" stroke-width="1.5" marker-end="url(#sl-arrow)"/>
  <rect x="115" y="182" width="80" height="32" rx="8" fill="#fee2e2"/>
  <text x="155" y="203" text-anchor="middle" font-size="11" font-weight="600" fill="#991b1b" font-family="Inter,system-ui,sans-serif">canceled</text>

  <!-- active -> paused -->
  <line x1="140" y1="42" x2="50" y2="142" stroke="#9CA3AF" stroke-width="1.5" marker-end="url(#sl-arrow)"/>
  <rect x="10" y="146" width="70" height="32" rx="8" fill="#dbeafe"/>
  <text x="45" y="167" text-anchor="middle" font-size="11" font-weight="600" fill="#1e40af" font-family="Inter,system-ui,sans-serif">paused</text>

  <!-- paused -> active (curved back) -->
  <path d="M10,162 C-20,162 -20,26 50,26" stroke="#059669" stroke-width="1.5" fill="none" stroke-dasharray="4,3" marker-end="url(#sl-arrow-green)"/>
  <text x="-2" y="90" font-size="9" fill="#059669" font-family="Inter,system-ui,sans-serif">resume</text>

  <!-- active -> contract_ended -->
  <line x1="170" y1="42" x2="270" y2="248" stroke="#9CA3AF" stroke-width="1.5" marker-end="url(#sl-arrow)"/>
  <rect x="220" y="252" width="120" height="32" rx="8" fill="#fef3c7"/>
  <text x="280" y="273" text-anchor="middle" font-size="10" font-weight="600" fill="#92400e" font-family="Inter,system-ui,sans-serif">contract_ended</text>

  <!-- contract_ended -> active (re-up) -->
  <line x1="340" y1="268" x2="380" y2="268" stroke="#059669" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#sl-arrow-green)"/>
  <text x="408" y="272" font-size="9" fill="#059669" font-family="Inter,system-ui,sans-serif">re-up</text>

  <defs>
    <marker id="sl-arrow" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6" fill="#9CA3AF"/></marker>
    <marker id="sl-arrow-green" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6" fill="#059669"/></marker>
  </defs>
</svg>

| Status | Meaning |
|--------|---------|
| `trialing` | Trial period active. Transitions to `active` automatically when `trial_ends_at` passes. To convert early, call `POST /v1/subscriptions/{id}/renew` — this activates the subscription and issues the first billing-cycle credit grant. Transitions to `expired` if the trial ends without conversion. |
| `active` | Normal operating state. Credits are granted on renewal. |
| `cancelling` | Cancel requested with `cancel_at_period_end: true`. Remains active until the current period ends, then transitions to `expired`. |
| `overdue` | Prepaid only. Period ended but tenant has not called renew. Grace period is ticking. |
| `paused` | Temporarily suspended. Can resume to `active`. |
| `canceled` | Terminated immediately via `cancel_immediately: true`. Terminal state. |
| `expired` | Grace period elapsed without renewal, or cancelling subscription reached period end. Terminal state. |
| `contract_ended` | Contract end date reached. Can transition back to `active` if the contract is renewed. |

## Credit grants on renewal

Each plan variant has one or more `credit_grants` (defined in the `plan_credit_grants` table). These specify what credits are issued each billing cycle.

A credit grant has:

| Field | Description |
|-------|-------------|
| `credits` | Millicredits to grant (1 credit = 1,000 mc) |
| `grant_interval` | When to grant. Keyword (`on_activation`, `daily`, `weekly`, `monthly`, `billing_cycle`) or ISO 8601 duration (e.g. `PT5H`). See below. |
| `grant_type` | `one_time`, `recurring`, or `trial` |
| `expires_after_seconds` | Optional. Seconds until this credit block expires. Null = never. |
| `source` | Credit source label (e.g. `plan_grant`, `trial`) |

Grants with `grant_interval: on_activation` or `grant_type: one_time` fire only when the subscription is first created. Recurring grants fire on their configured interval.

### Interval form: keyword or ISO 8601

`grant_interval` accepts two forms:

**Keywords** — use these for standard cadences. `on_activation | daily | weekly | monthly | billing_cycle`. The `billing_cycle` keyword ties the grant to the subscription's billing cycle end.

**ISO 8601 durations** — use these for custom cadences. The grant cadence runs independently of the subscription's billing cycle, so you can have monthly billing with a 5-hour quota reset (Claude Pro–style).

| Form | Example | Use when |
|---|---|---|
| `"PT5H"` | Every 5 hours | Claude Pro–style resetting quotas |
| `"PT30M"` | Every 30 minutes | High-frequency burst windows |
| `"P3D"` | Every 3 days | Sub-weekly cadences |
| `"P1DT12H"` | Every 1.5 days | Non-standard cadences |

Supported letters: `D` (days), `H` (hours), `M` (minutes), `S` (seconds). Year and month durations aren't supported as ISO 8601 — their length is variable, so use the `monthly` or `yearly` keywords instead.

**Minimum interval:** 5 minutes (`PT5M`). Shorter values return `422`.

**Timing anchor.** The first grant fires when the subscription is created. Subsequent grants fire on anniversary-based timing — at `subscription.created_at + N × interval`, not relative to the previous fire. Delayed or missed grants don't shift future schedules forward.

**Recovery after downtime.** If QuotaStack is unavailable for several cadence intervals, only the **current** window's credits are issued on recovery. Earlier windows are not backfilled.

For a worked Claude Pro–style example (subscription + `PT5H` resetting quota), see the [Consumer AI Subscription use case](/docs/use-cases/consumer-ai-subscription).

## Rollover

By default, unused credits from the previous cycle can be expired and replaced with a fresh grant. Rollover changes this: a percentage of unused credits carries forward into the new cycle.

| Field | Description |
|-------|-------------|
| `rollover_percentage` | 0-100. Percentage of unused credits from the prior cycle to carry forward. 0 or null = no rollover. |
| `max_rollover_cycles` | Maximum number of consecutive cycles credits can roll over. Null = unlimited. |
| `accumulation_cap` | Maximum balance (in millicredits) the grant can push the account to. Null = unlimited. |

Example: A plan grants 10,000 mc per month with `rollover_percentage: 50` and `accumulation_cap: 15000`.

- Month 1: Customer uses 6,000 mc. 4,000 mc unused.
- Month 2: Rollover = 50% of 4,000 = 2,000 mc. New grant = 10,000 + 2,000 = 12,000 mc, capped so `balance_after_grant <= 15,000 mc`.
- If the customer had 14,000 mc remaining at renewal, the grant is clamped to 1,000 mc so the post-grant balance sits at the cap (15,000 mc).
- If the customer had 15,000 mc already, the grant is 0 — the cap clamps the grant amount, never reduces existing balance.

When `max_rollover_cycles` is reached, the rollover counter resets and only the base grant amount is issued.

## Contracts

Subscriptions can have a contract period defined by `contract_start` and `contract_end`. These are independent of billing cycles -- a yearly contract might have monthly billing.

| Field | Description |
|-------|-------------|
| `contract_start` | Set automatically to the subscription start date. |
| `contract_end` | Optional. When the contract expires. |
| `contract_ending_soon_days` | Days before `contract_end` to fire the `subscription.contract_ending_soon` webhook. Default: 30. |

When `contract_end` is reached, the subscription transitions to `contract_ended`. To renew the contract, use:

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

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

## Pause and resume

Pause suspends a subscription without canceling it. Paused subscriptions do not receive credit grants on scheduled renewals and do not advance billing periods, but the customer's existing credits remain spendable.

```bash
curl -X POST https://api.quotastack.io/v1/subscriptions/{id}/pause \
  -H "X-API-Key: qs_live_..." \
  -H "Idempotency-Key: pause:{subscriptionId}" \
  -H "Content-Type: application/json" \
  -d '{}'
```

To reinstate the subscription:

```bash
curl -X POST https://api.quotastack.io/v1/subscriptions/{id}/resume \
  -H "X-API-Key: qs_live_..." \
  -H "Idempotency-Key: resume:{subscriptionId}" \
  -H "Content-Type: application/json" \
  -d '{}'
```

Pause and resume fire `subscription.paused` and `subscription.resumed` webhooks respectively.

## After contract_end

When `contract_end` is reached, the subscription transitions to `contract_ended`. This is a **historical marker** — it records that the billing relationship ended but does not freeze the credit account:

- Remaining credits stay usable until they expire naturally.
- `POST /v1/usage` is still accepted — consumption debits remaining credit blocks.
- Entitlement checks continue to return live results based on balance + overage policy.

If you need to fully cut off access at `contract_ended`, cancel the subscription (`cancel_immediately: true`) and void remaining credit blocks via `POST /v1/customers/{id}/credits/adjust` with a negative delta.

## Cancellation

Two cancel modes, controlled by the `cancel_immediately` field on the cancel request:

**End-of-period** (`cancel_immediately: false`):
The subscription enters `cancelling` status. The customer retains access until the current period ends. At period end, the subscription transitions to `expired`.

```bash
curl -X POST https://api.quotastack.io/v1/subscriptions/{id}/cancel \
  -H "X-API-Key: qs_live_..." \
  -H "Idempotency-Key: cancel:{subscriptionId}" \
  -H "Content-Type: application/json" \
  -d '{"cancel_immediately": false, "reason": "Customer requested downgrade"}'
```

**Immediate** (`cancel_immediately: true`):
The subscription transitions directly to `canceled`. Terminal. No further credit grants.

## Webhooks

Subscriptions emit these webhook events:

| Event | When |
|-------|------|
| `subscription.created` | New subscription created |
| `subscription.renewal_due` | Prepaid: period end approaching, tenant should charge and call renew |
| `subscription.renewed` | Period advanced and credits granted (prepaid after renew call, postpaid auto) |
| `subscription.renewal_overdue` | Prepaid: period ended without renewal, grace period started |
| `subscription.expired` | Subscription expired (grace period elapsed or cancelling period ended) |
| `subscription.canceled` | Subscription canceled immediately |
| `subscription.upgraded` | Moved to a higher-tier plan variant |
| `subscription.downgraded` | Moved to a lower-tier plan variant |
| `subscription.paused` | Subscription paused |
| `subscription.resumed` | Subscription resumed |
| `subscription.contract_ending_soon` | Contract end date approaching |
| `subscription.contract_ended` | Contract end date reached |

For postpaid subscriptions, the `subscription.renewed` webhook includes a `usage_summary` object with total credits consumed, balance snapshots, and per-metric breakdowns for the prior period.

## Example: prepaid monthly subscription

`POST /v1/subscriptions` accepts either `external_customer_id` (your tenant ID) or `customer_id` (the QuotaStack UUID) in the body — exactly one required, same convention as the other body endpoints (see [Customer identification](/docs/concepts/customer-identification)).

```bash
# 1. Create a subscription using your tenant's identifier.
curl -X POST https://api.quotastack.io/v1/subscriptions \
  -H "X-API-Key: qs_live_..." \
  -H "Idempotency-Key: sub-create:{externalCustomerId}:{planVariantId}" \
  -H "Content-Type: application/json" \
  -d '{
    "external_customer_id": "user_abc123",
    "plan_variant_id": "pv_monthly_pro",
    "contract_end": "2027-04-01T00:00:00Z"
  }'

# 2. When you receive "subscription.renewal_due" webhook,
#    charge the customer, then:
curl -X POST https://api.quotastack.io/v1/subscriptions/{id}/renew \
  -H "X-API-Key: qs_live_..." \
  -H "Idempotency-Key: renew:{paymentId}" \
  -H "Content-Type: application/json" \
  -d '{
    "external_payment_id": "pay_xyz789"
  }'
```

If you already have the QuotaStack UUID in hand (e.g., from a prior response), use `customer_id` instead:

```json
{
  "customer_id": "019d8a20-4ff5-7be0-81da-e1454b3d6f64",
  "plan_variant_id": "pv_monthly_pro"
}
```

## Constraints and notes

- **`billing_mode` is immutable** after plan variant creation. To switch a customer from prepaid to postpaid (or vice versa), create a new plan variant with the desired mode and move the subscription over.
- **Postpaid with zero credit grants is valid.** A plan variant with `billing_mode: "postpaid"` and no `credit_grants` is a pure usage-based setup — the cycle advances automatically, usage accumulates, and the `subscription.renewed` webhook delivers a `usage_summary` for you to invoice against. No credits are pre-granted.

## Common Mistakes

**✗ Don't use subscriptions if you just need topups**

Subscriptions add state (periods, renewals, grace, contracts). If customers simply buy credits when they need them, skip subscriptions entirely.

**✗ Don't ignore `subscription.renewal_overdue` in prepaid mode**

This webhook means the grace period started. Not handling it leaves customers in limbo and eventually silently expires their subscription.

**✗ Don't assume postpaid auto-collects payment**

Postpaid sends you a usage summary after each period. You still have to invoice the customer through your payment processor — QuotaStack never touches money.
