quotastack Docs
Docs / Concepts / Subscriptions

Subscriptions

Optional recurring billing state management with prepaid and postpaid modes, credit grants on renewal, rollover, and contract lifecycle.

Mental model — Subscriptions are state machines for recurring billing. QuotaStack tracks the cycle and credits. With manual prepaid billing you collect payment and call renew; with a connector the provider collects and QuotaStack advances only after its verified event. Postpaid stays tenant-invoiced.

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
STEP 1 Create STEP 2 Grant credits STEP 3 Renew STEP 4 Grant + rollover STEP 5 Cancel / expire

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.

Period nearing end
QuotaStack fires subscription.renewal_due webhook
Tenant charges customer via payment system
POST /v1/subscriptions/{id}/renew
QuotaStack advances period + grants credits

Timing is controlled by two fields on the plan variant:

FieldDefaultDescription
renewal_due_days3Days before period end to fire subscription.renewal_due
grace_period_days3Days after period end before the subscription expires
allow_usage_while_overduetrueWhether 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}/renew returns 409 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.

Period ends
QuotaStack auto-advances to next period
QuotaStack fires subscription.renewed webhook
with usage_summary: total consumed, balances, per-metric breakdown
Tenant generates invoice from usage_summary

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

trialing active cancelling expired canceled overdue expired canceled paused resume contract_ended re-up
StatusMeaning
trialingTrial 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.
activeNormal operating state. Credits are granted on renewal.
cancellingCancel requested with cancel_at_period_end: true. Remains active until the current period ends, then transitions to expired.
overduePrepaid only. Period ended but tenant has not called renew. Grace period is ticking.
pausedTemporarily suspended. Can resume to active.
canceledTerminated immediately via cancel_immediately: true. Terminal state.
expiredGrace period elapsed without renewal, or cancelling subscription reached period end. Terminal state.
contract_endedContract 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:

FieldDescription
creditsMillicredits to grant (1 credit = 1,000 mc)
grant_intervalWhen to grant. Keyword (on_activation, daily, weekly, monthly, billing_cycle) or ISO 8601 duration (e.g. PT5H). See below.
grant_typeone_time, recurring, or trial
expires_after_secondsOptional. Seconds until this credit block expires. Null = never.
sourceCredit 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).

FormExampleUse when
"PT5H"Every 5 hoursClaude Pro–style resetting quotas
"PT30M"Every 30 minutesHigh-frequency burst windows
"P3D"Every 3 daysSub-weekly cadences
"P1DT12H"Every 1.5 daysNon-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.

FieldDescription
rollover_percentage0-100. Percentage of unused credits from the prior cycle to carry forward. 0 or null = no rollover.
max_rollover_cyclesMaximum number of consecutive cycles credits can roll over. Null = unlimited.
accumulation_capMaximum 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.

FieldDescription
contract_startSet automatically to the subscription start date.
contract_endOptional. When the contract expires.
contract_ending_soon_daysDays 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/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.

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:

EventWhen
subscription.createdNew subscription created
subscription.renewal_duePrepaid: period end approaching, tenant should charge and call renew
subscription.renewedPeriod advanced and credits granted (prepaid after renew call, postpaid auto)
subscription.renewal_overduePrepaid: period ended without renewal, grace period started
subscription.expiredSubscription expired (grace period elapsed or cancelling period ended)
subscription.canceledSubscription canceled immediately
subscription.upgradedMoved to a higher-tier plan variant
subscription.downgradedMoved to a lower-tier plan variant
subscription.pausedSubscription paused
subscription.resumedSubscription resumed
subscription.contract_ending_soonContract end date approaching
subscription.contract_endedContract 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_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. Payment connectors support fixed prepaid variants in the MVP, so you still invoice postpaid customers through your own payment integration.