Subscriptions
Optional recurring billing state management with prepaid and postpaid modes, credit grants on renewal, rollover, and contract lifecycle.
Quick take
- Subscriptions are optional — many apps use pure wallet + topup instead
- Two catalog modes: prepaid vs postpaid; prepaid can be manually or connector managed
- Connector-managed renewals are provider initiated; the public manual renew endpoint is blocked for that subscription
- Credit grants on renewal with configurable rollover and accumulation caps
- Contract support for fixed-term commitments with auto-renewal or expiry
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; the selected provider moves money
QuotaStack tracks each subscription, paid term, credit grant, and contract date. It never stores card or bank details. You can collect funds in two ways:
- Manual flow: QuotaStack sends a webhook. Your app takes payment and calls the renew API after success.
- Payment connector: QuotaStack starts hosted checkout. The provider takes payment and may retry it. A signed event lets QuotaStack start or renew the plan.
In both flows, QuotaStack owns plan state, credits, metering, and entitlements. See Payment Connectors to learn who owns each step.
Billing modes
Each plan variant has a billing_mode: either prepaid (default) or postpaid.
Prepaid — manual integration
In the manual flow, 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.
Prepaid — payment connector
A connector plan uses the same grants and states. The payment provider sets the charge date. QuotaStack starts the next paid term only after it can prove payment.
For these subscriptions:
- the provider owns the mandate, SCA, retries, and failed-payment work;
- the QuotaStack job does not start a new paid term;
- public
POST /v1/subscriptions/{id}/renewreturns409 Conflict; - a scheduled local downgrade also returns
409 Conflict; - QuotaStack sends a cancel request to the bound provider;
- a signed provider event confirms the end state.
Do not combine manual renew calls with connector payment events.
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.
Rollover applies to prepaid renewals. POST /v1/subscriptions/{id}/renew is prepaid’s only grant path, and it now honours rollover_percentage and max_rollover_cycles. Before the 2026-07-25 release these settings were inert for prepaid plans — every cycle granted the flat base amount. If you have a prepaid plan configured with rollover, the granted amount per cycle will now differ from what you previously observed. See the changelog.
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 API-made plans, subscription.renewed fires each time the paid term moves on. This can come from the job, POST /renew, or a planned downgrade. The payload has prior_period, new_period, and a usage_summary for the old term. See subscription.renewed for all fields. A connector renewal emits the common payment.succeeded event instead.
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"
}
Migrating existing customers
Do not use POST /v1/subscriptions for customers who are already mid-plan somewhere else. It starts the period at now and fires the plan’s full activation grants — resetting their renewal date and handing them a second allotment of credit.
Use POST /v1/imports/customers instead. It takes your current_period_start / current_period_end, defaults to grant_policy: none so nothing is re-granted, and seeds only the balance that is actually left.
Every subscription carries origin: api for one created through the normal endpoints, import for one brought in through a cutover. Use it to tell migrated customers from natively-created ones when reconciling.
See Importing Existing Customers for the full recipe.
Retrieving a subscription
GET /v1/subscriptions/{id} takes a subscription UUID. Passing a customer identifier returns 404.
# Retrieve one subscription by its UUID
GET /v1/subscriptions/019d8a20-4ff5-7be0-81da-e1454b3d6f70
# Find a customer's subscriptions
GET /v1/subscriptions?customer_id=019d6258-07ba-7418-83be-58f5fde53e4e
The list endpoint also accepts status, cursor, and limit, and returns the standard { data, pagination } envelope.
The lookup is environment-scoped: a sandbox key (qs_test_...) resolves only sandbox subscriptions, and a live key only live ones. A sandbox key cannot read a live subscription by UUID.
Breaking change (2026-07-25). An undocumented customer-id lookup on this route used to work and now 404s. See the migration note.
Renewing a manual prepaid subscription
For a manual plan made by the API, renew grants the next set of credits. It also moves the paid term on. A connector plan uses proof from its provider and rejects this route with 409 Conflict.
Retries are safe
If the grant fails, the renewal returns 5xx with nothing persisted — the period does not advance, no ledger entries are written, and no webhook fires. Retrying the same renewal is safe: grants dedupe on their idempotency key, so the period advances exactly once no matter how many attempts it takes.
(Before the 2026-07-25 release a failure could leave the period advanced with no credits granted. If you built compensating logic to detect and repair half-applied renewals, you can remove it.)
ISO-8601 cadence grants are skipped
Grants configured with an ISO-8601 interval like PT5H fire on their own schedule only. Renew does not fire them — it previously double-fired them, granting once on the cadence and again on renewal. If your plan mixes a monthly cycle with a sub-cycle cadence grant, per-cycle totals will now be correct rather than inflated.
Racing renewals
If you call /renew while the scheduler is renewing the same subscription — or two of your own workers race — exactly one wins. The loser gets 200 with credits_granted: 0.
Treat that as “already renewed” and succeed. It is not an error and no second grant occurred. Do not retry on it, and do not surface it to the customer as a failed renewal.
const res = await renew(subscriptionId, paymentId);
if (res.credits_granted === 0) {
// Someone else already renewed this cycle. Nothing to do.
return { status: 'already_renewed' };
}
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
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. Payment connectors support fixed prepaid variants in the MVP, so you still invoice postpaid customers through your own payment integration.
Loading…