Subscriptions
Optional recurring billing state management with prepaid and postpaid modes, credit grants on renewal, rollover, and contract lifecycle.
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.
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
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:
- QuotaStack tells you something needs to happen (via webhook).
- You charge the customer in your payment system.
- 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.
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.
with usage_summary: total consumed, balances, per-metric breakdown
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
| 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.
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
{
"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.
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:
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/usageis 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.
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).
# 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:
{
"customer_id": "019d8a20-4ff5-7be0-81da-e1454b3d6f64",
"plan_variant_id": "pv_monthly_pro"
}
Constraints and notes
billing_modeis 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 nocredit_grantsis a pure usage-based setup — the cycle advances automatically, usage accumulates, and thesubscription.renewedwebhook delivers ausage_summaryfor you to invoice against. No credits are pre-granted.
Common Mistakes
The mistakes developers typically make with this concept — and what to do instead.
subscription.renewal_overdue in prepaid mode