SaaS Subscription: Recurring Plans with Monthly Credit Grants
How to model a classic B2B SaaS with tiered subscription plans, monthly credit grants, rollover, overage policies, and plan upgrades.
Inspired by: Linear, Notion, Vercel, most B2B SaaS products
Think of this as the classic B2B subscription: tiered monthly plans that grant a fresh credit budget each cycle, with rollover for unused credits and topup packs for mid-cycle bursts. Same shape as Linear, Notion, or your average dev tool.
SaaS Subscription
Pattern: recurring subscription with monthly credit grants.
The problem
You run a B2B SaaS product. Customers subscribe to a plan (Starter, Pro, Enterprise). Each plan grants a monthly credit allowance. Customers consume credits through usage. When credits run out, behavior depends on your overage policy — block further usage, or allow it and invoice the overage later.
You need:
- Tiered plans with different monthly credit amounts.
- Automatic credit grants on each billing cycle.
- Optional rollover of unused credits to the next cycle.
- Overage handling: hard block vs. soft limit.
- Plan upgrades and downgrades.
- Topup packs for customers who need more credits mid-cycle.
Credit structure
| Block type | Priority | Expiry | Source |
|---|---|---|---|
| Monthly plan grant | 10 | End of billing cycle | Credit grant template on plan variant |
| Rollover credits | 5 | End of next billing cycle | Rolled from prior cycle |
| Topup pack | 5 | 90 days | Topup grant after payment |
Burn-down order: Monthly grant (priority 10) burns first. Within priority 10, soonest-expiring blocks go first. Rollover credits and topup packs (priority 5) burn next. This ensures current-cycle credits are consumed before carried-forward or purchased extras.
Plan catalog
Set up plans, variants, and credit grant templates.
Plans
POST /v1/plans
Idempotency-Key: plan:starter
{ "name": "Starter", "description": "For small teams" }
POST /v1/plans
Idempotency-Key: plan:pro
{ "name": "Pro", "description": "For growing teams" }
POST /v1/plans
Idempotency-Key: plan:enterprise
{ "name": "Enterprise", "description": "Custom for large organizations" }
Plan variants (monthly cadence)
POST /v1/plans/{starter_id}/variants
Idempotency-Key: variant:starter-monthly
{
"name": "Starter Monthly",
"billing_cycle": "monthly",
"billing_mode": "prepaid",
"renewal_due_days": 3,
"grace_period_days": 3,
"allow_usage_while_overdue": true
}
POST /v1/plans/{pro_id}/variants
Idempotency-Key: variant:pro-monthly
{
"name": "Pro Monthly",
"billing_cycle": "monthly",
"billing_mode": "prepaid",
"renewal_due_days": 3,
"grace_period_days": 3,
"allow_usage_while_overdue": true
}
POST /v1/plans/{enterprise_id}/variants
Idempotency-Key: variant:enterprise-monthly
{
"name": "Enterprise Monthly",
"billing_cycle": "monthly",
"billing_mode": "prepaid",
"renewal_due_days": 5,
"grace_period_days": 7,
"allow_usage_while_overdue": true
}
Credit grant templates
Attach grant rules to each variant. These fire automatically on subscription activation and renewal.
POST /v1/plans/{starter_id}/variants/{starter_monthly_id}/credit-grants
Idempotency-Key: grant:starter-monthly
{
"credits": 10000000,
"grant_interval": "billing_cycle",
"grant_type": "recurring",
"source": "plan_grant",
"expires_after_seconds": 2592000,
"rollover_percentage": 0,
"accumulation_cap": null
}
10,000,000mc = 10,000 credits per cycle, expiring after 30 days (2,592,000 seconds). No rollover.
POST /v1/plans/{pro_id}/variants/{pro_monthly_id}/credit-grants
Idempotency-Key: grant:pro-monthly
{
"credits": 50000000,
"grant_interval": "billing_cycle",
"grant_type": "recurring",
"source": "plan_grant",
"expires_after_seconds": 2592000,
"rollover_percentage": 50,
"max_rollover_cycles": 3,
"accumulation_cap": 75000000
}
50,000 credits per cycle. 50% of unused credits roll over, for up to 3 consecutive cycles, capped at 75,000 total. This rewards consistent usage without unbounded accumulation.
POST /v1/plans/{enterprise_id}/variants/{enterprise_monthly_id}/credit-grants
Idempotency-Key: grant:enterprise-monthly
{
"credits": 200000000,
"grant_interval": "billing_cycle",
"grant_type": "recurring",
"source": "plan_grant",
"expires_after_seconds": 2592000,
"rollover_percentage": 100,
"max_rollover_cycles": null,
"accumulation_cap": null
}
200,000 credits per cycle. Full rollover, no cap, no cycle limit. Enterprise customers keep everything they paid for.
Summary
| Plan | Credits/month | Rollover | Cap |
|---|---|---|---|
| Starter | 10,000 | None | — |
| Pro | 50,000 | 50%, max 3 cycles | 75,000 |
| Enterprise | 200,000 | 100%, unlimited | None |
Subscription lifecycle
Creating a subscription
After the customer signs up and pays via your payment provider:
POST /v1/subscriptions
Idempotency-Key: sub:<your_subscription_id>
{
"customer_id": "cus_...",
"plan_variant_id": "pvr_starter_monthly",
"external_subscription_id": "stripe_sub_abc",
"metadata": {
"stripe_price_id": "price_...",
"monthly_amount_usd": "29"
}
}
QuotaStack immediately:
- Creates the subscription with status
active. - Sets
current_period_startto now andcurrent_period_endto now + 1 month. - Fires the credit grant template: creates a 10,000-credit block expiring at period end.
- Fires
subscription.createdwebhook.
Renewal flow (prepaid, tenant-driven)
QuotaStack does not charge the customer. Your app does. Here is the timeline:
Day 27 (3 days before period end): QuotaStack fires subscription.renewal_due:
{
"event": "subscription.renewal_due",
"subscription_id": "sub_...",
"customer_id": "cus_...",
"current_period_end": "2026-05-13T08:00:00Z",
"renewal_due_days": 3,
"billing_mode": "prepaid"
}
Your webhook handler charges the customer via Stripe/Razorpay/etc.
On successful payment: Call renew:
POST /v1/subscriptions/sub_.../renew
Idempotency-Key: renew:sub_...:2
{}
QuotaStack:
- Advances
current_period_startandcurrent_period_endby one month. - Evaluates the credit grant template. For Pro with rollover: computes unused balance from prior cycle blocks, applies 50% rollover, creates new block with
grant.credits + rollover_amountclamped toaccumulation_cap. - Fires
subscription.renewedwebhook.
If payment fails: Do not call renew. Let the period end.
Day 30 (period end): QuotaStack transitions the subscription to overdue and fires subscription.renewal_overdue:
{
"event": "subscription.renewal_overdue",
"subscription_id": "sub_...",
"customer_id": "cus_...",
"period_end": "2026-05-13T08:00:00Z",
"grace_ends_at": "2026-05-16T08:00:00Z",
"grace_period_days": 3
}
During grace, the customer can still use remaining credits (because allow_usage_while_overdue: true). Retry the charge. If it succeeds, call renew — the subscription returns to active.
Day 33 (grace exhausted): QuotaStack transitions to expired and fires subscription.expired:
{
"event": "subscription.expired",
"subscription_id": "sub_...",
"customer_id": "cus_...",
"expired_at": "2026-05-16T08:00:00Z"
}
The subscription is done. Existing credit blocks continue until their own expiry. If you want the customer on a free plan, explicitly create a new subscription — there is no automatic fallback.
Rollover mechanics
Rollover is configured on the credit grant template, not on the subscription. When a subscription renews, QuotaStack evaluates each grant rule:
Prior cycle: 50,000 credits granted, 35,000 consumed, 15,000 remaining.
Rollover: 50% of 15,000 = 7,500 credits.
New cycle grant: 50,000 + 7,500 = 57,500 credits (under the 75,000 cap).
If the customer barely used their credits:
Prior cycle: 50,000 credits granted, 5,000 consumed, 45,000 remaining.
Rollover: 50% of 45,000 = 22,500 credits.
New cycle: 50,000 + 22,500 = 72,500 credits (under the 75,000 cap).
If rolled-over balance is already high:
Prior cycle: 72,500 credits granted, 10,000 consumed, 62,500 remaining.
Rollover: 50% of 62,500 = 31,250.
New cycle: 50,000 + 31,250 = 81,250 -- exceeds cap.
Clamped to 75,000 credits.
The max_rollover_cycles limit resets the rollover counter when reached. After 3 consecutive rollovers, the cycle resets: old blocks expire, a fresh base grant is issued.
Overage policy
What happens when a customer runs out of credits mid-cycle?
Hard block (default)
Set overage_policy: null on the customer (inherits tenant default of block).
When balance hits 0, the entitlement check returns allowed: false. Your app shows “You’ve used your monthly credits. Upgrade your plan or buy a topup pack.”
Soft limit (allow overage)
Set overage_policy: "allow" on the customer:
PATCH /v1/customers/cus_...
Idempotency-Key: update:<uuid>
{
"overage_policy": "allow"
}
Usage continues past 0. The balance goes negative. At cycle end, the subscription.renewed webhook includes usage summary data. Your billing system reads the overage, computes the fiat cost, and invoices accordingly.
Notify + allow
Set overage_policy: "notify". Same as allow, but QuotaStack also fires credit.overage_threshold_exceeded when the balance crosses a configured threshold. Use this to send the customer an alert before the invoice surprise.
Topup packs for mid-cycle boosts
Customers who hit their limit can buy additional credits without changing plans:
POST /v1/topup/grant
Idempotency-Key: topup:<payment_id>
{
"customer_id": "cus_...",
"credits": 5000000,
"expires_at": "2026-07-13T00:00:00Z",
"metadata": {
"source": "topup_pack",
"priority": 5,
"payment_id": "stripe_pi_..."
}
}
5,000 credits with 90-day expiry and priority 5. These burn after the monthly grant (priority 10) but before any rollover credits at the same priority level (oldest-expiring first within the same priority).
Upgrade and downgrade
Upgrade (immediate)
Customer upgrades from Starter (10,000/mo) to Pro (50,000/mo). You want them to get Pro credits immediately.
- Cancel the old subscription at period end:
POST /v1/subscriptions/{starter_sub_id}/cancel
Idempotency-Key: cancel:<uuid>
{
"cancel_immediately": false,
"reason": "upgrade to Pro"
}
- Create the new subscription:
POST /v1/subscriptions
Idempotency-Key: sub:<new_sub_id>
{
"customer_id": "cus_...",
"plan_variant_id": "pvr_pro_monthly",
"external_subscription_id": "stripe_sub_new",
"metadata": { "upgraded_from": "starter" }
}
The new subscription activates immediately and grants 50,000 Pro credits. The old Starter credits remain until their block-level expiry (end of the original period). The customer temporarily has both — credits stack.
At the old period end, the Starter subscription transitions to canceled and the Starter credits expire. The Pro subscription continues on its own cycle.
Downgrade (queued)
Customer downgrades from Pro to Starter. You want them to keep Pro until the current period ends.
-
Do nothing in QuotaStack yet. In your app, record the intent: “downgrade to Starter at period end.”
-
When
subscription.renewal_duefires for the Pro subscription, do not renew it. Let it expire. -
When
subscription.expiredfires, create the Starter subscription:
POST /v1/subscriptions
Idempotency-Key: sub:downgrade-<customer_id>-starter
{
"customer_id": "cus_...",
"plan_variant_id": "pvr_starter_monthly"
}
This approach keeps your downgrade logic in your app (where the business rules live) and uses QuotaStack purely for credit accounting and lifecycle events.
Example: full billing cycle
Here is a complete month for a Pro customer:
Day 1: Subscription created. 50,000 credits granted (priority 10, 30-day expiry).
Customer starts using the product.
Day 15: Customer has used 30,000 credits. 20,000 remaining.
Customer buys a 5,000-credit topup (priority 5, 90-day expiry).
Total balance: 25,000.
Day 27: subscription.renewal_due webhook fires.
Your app charges Stripe. Payment succeeds.
POST /v1/subscriptions/{id}/renew.
Day 27: QuotaStack advances the period.
Rollover: 50% of remaining Pro credits.
Remaining at renewal: say 18,000 (used 2,000 more since day 15).
Rollover: 9,000 credits.
New grant: 50,000 + 9,000 = 59,000 credits (under 75,000 cap).
Prior cycle Pro block expires at old period_end.
New Pro block created: 59,000 credits, expires end of new period.
Topup pack (5,000 credits) still active -- separate block, separate expiry.
Day 30: Old period ends. Old Pro block expires.
Any remaining balance in the old block is zeroed.
credit.expired webhook fires for the old block.
Day 31+: Customer uses new 59,000 Pro credits + any remaining topup.
Tips
-
Prepaid means tenant-driven renewal. QuotaStack fires the webhook, but you drive the charge and the renew call. If you forget to call renew, the subscription enters overdue and eventually expires. This is by design — QuotaStack does not touch your payment provider.
-
Rollover is per-grant-rule, not per-subscription. If a plan variant has two grant rules (e.g., “API credits” and “storage credits”), each rolls over independently with its own percentage and cap.
-
Credit blocks survive subscription cancellation. Canceling a subscription does not void the credits already granted. The blocks remain until their own expiry. This is important for upgrades — the old plan’s remaining credits coexist with the new plan’s grant.
-
allow_usage_while_overdueis your grace-period UX. Set totrue(default) for consumer-friendly behavior: during a payment retry window, the user keeps working. Set tofalsefor strict enforcement: the moment the subscription is overdue, entitlement checks fail regardless of balance. -
One customer, many subscriptions. A customer can have a base subscription plus add-on subscriptions simultaneously. Each subscription independently grants credits and has its own renewal cycle. Credits from all sources stack in a single balance.
See also: Subscriptions, Credit Grants on Renewal, Rollover, Overage Policy.