quotastack Docs
Docs / Use cases / SaaS Subscription: Recurring Plans with Monthly Credit Grants

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.

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 typePriorityExpirySource
Monthly plan grant0End of billing cycleCredit grant template on plan variant
Rollover credits0End of next billing cycleRolled from prior cycle
Topup pack090 daysTopup grant after payment

Burn-down order: all three use the same priority, so soonest-expiring blocks go first. Current-cycle credits are consumed before longer-lived rollover credits or topup packs; no-expiry wallet credits would be last.

Billable metrics

Before building plans, define what customers actually consume. A B2B SaaS typically has two shapes of usage: high-volume commodity actions (API calls) and lower-volume expensive actions (AI features). Plus non-credit limits like seat caps.

What to meter

Metric keyTypePricingWhat it tracks
api_callmeteredflat, 100mc per callEvery API request
ai_generationmeteredtiered graduatedAI-powered reports, summaries, analyses
max_seatsgaugenon-credit (cap)Team member limit per plan

Create each metric with POST /v1/billable-metrics, then attach a metering rule to the credit-based ones. The max_seats gauge doesn’t need a metering rule — it’s enforced by entitlement checks, not credit debits.

Pricing the AI generation metric

API calls are simple — flat 100mc per call. AI generations are more interesting because you want to reward heavy users with volume discounts. Use graduated tiered pricing:

POST /v1/metering-rules
Idempotency-Key: rule:ai_generation

{
  "billable_metric_key": "ai_generation",
  "cost_type": "tiered",
  "tier_config": {
    "mode": "graduated",
    "tiers": [
      { "up_to": 100, "unit_cost": 5000, "flat_cost": 0 },
      { "up_to": 500, "unit_cost": 3000, "flat_cost": 0 },
      { "up_to": null, "unit_cost": 1000, "flat_cost": 0 }
    ]
  }
}

The cost accumulates within each billing cycle. A customer who runs 250 generations in a month pays:

TierRangeCost per generationSubtotal
1First 1005,000mc (5 credits)500,000mc
2Next 1503,000mc (3 credits)450,000mc
Total250 generations950,000mc (950 credits)

Seat caps by plan

Attach the max_seats gauge to each plan variant with a different cap. Customers inherit the cap from their active subscription:

PlanSeat capAPI calls (at 100mc)AI generations (at tier 1 rate)
Starter (10,000 credits)5~100,000~2,000
Pro (50,000 credits)25~500,000~10,000
Enterprise (200,000 credits)500~2,000,000~40,000

Credit-based metrics (api_call, ai_generation) don’t need explicit plan attachment — they’re governed by the credit grant amount and the metering rule pricing. Only non-credit metrics like max_seats need to be attached to each variant.

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

PlanCredits/monthRolloverCap
Starter10,000None
Pro50,00050%, max 3 cycles75,000
Enterprise200,000100%, unlimitedNone

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:

  1. Creates the subscription with status active.
  2. Sets current_period_start to now and current_period_end to now + 1 month.
  3. Fires the credit grant template: creates a 10,000-credit block expiring at period end.
  4. Fires subscription.created webhook.

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:

  1. Advances current_period_start and current_period_end by one month.
  2. 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_amount clamped to accumulation_cap.
  3. Fires subscription.renewed webhook.

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/topups/grant
Idempotency-Key: topup:<payment_id>

{
  "customer_id": "cus_...",
  "credits": 5000000,
  "expires_at": "2026-07-13T00:00:00Z",
  "metadata": {
    "source": "topup_pack",
    "priority": 0,
    "payment_id": "stripe_pi_..."
  }
}

5,000 credits with 90-day expiry and priority 0. These burn after the monthly grant because the monthly grant expires sooner.

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.

  1. 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"
}
  1. 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.

  1. Do nothing in QuotaStack yet. In your app, record the intent: “downgrade to Starter at period end.”

  2. When subscription.renewal_due fires for the Pro subscription, do not renew it. Let it expire.

  3. When subscription.expired fires, 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 0, 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 0, 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.

Recording usage

Every API call and AI generation fires a usage event via POST /v1/usage. Pass the metric key, unit count, and a deterministic idempotency key derived from the request ID:

POST /v1/usage
Idempotency-Key: usage:gen-<generation_id>

{
  "external_customer_id": "cus_acme",
  "billable_metric_key": "ai_generation",
  "units": 1,
  "idempotency_key": "gen-gen_xyz789"
}

Usage events return 202 Accepted and are processed asynchronously — the credit debit lands within seconds. For tiered metrics like ai_generation, the cost accumulates within the billing cycle: the 101st generation in a month costs 3,000mc instead of 5,000mc.

For seat-based limits, check the gauge entitlement before allowing the action:

GET /v1/customer-by-external-id/cus_acme/entitlements/max_seats

Returns { "allowed": true, "value": { "cap": 25 }, "current": 18 } — 18 of 25 seats used, the invite can proceed. If current equals cap, block the invite and prompt an upgrade.

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_overdue is your grace-period UX. Set to true (default) for consumer-friendly behavior: during a payment retry window, the user keeps working. Set to false for 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.