SaaS Per-Seat Subscription
The dominant pricing pattern in modern B2B SaaS: customers pay per active user per month or year, quantity changes mid-cycle as teams grow, and an annual prepay carries a meaningful discount over the equivalent monthly rate. Looks simple from the outside; the complexity hides in mid-cycle proration, trial-to-paid conversion, and accurate expansion-revenue tracking.
Real-world examples. Slack, Linear, Notion, Figma, Loom, GitHub, Cursor, ChatGPT Team. Common shape: $10-30 per seat per month, annual rates 15-20% lower, free trial 14-30 days, mid-cycle seat additions billed prorated, mid-cycle removals create credit toward the next invoice.
The shape of the problem
Per-seat pricing looks like a flat number on the page. The hard parts:
- Mid-cycle quantity changes - when a customer adds 3 seats on day 12 of a 30-day period, you owe them 18 days of charges at the new total, not a full month at the new total. Removing seats produces credit (some companies refund, others bank it).
- Trial conversion - the moment the trial ends, the first full-period invoice has to generate automatically with the seat count as of that moment.
- Annual prepay vs monthly - same product, two prices, customer can switch between them at renewal. Switching mid-term is also legitimate (and creates a credit/charge).
- Expansion revenue tracking - finance wants to know what fraction of MRR growth came from new customers vs existing customer expansion vs churn recovery. The seat-change events are the source of truth.
- Hard caps and soft caps - some pricing schemes cap seats at a tier boundary; some warn but allow overage.
Kontorion blueprint
| Concern | Kontorion primitive |
|---|---|
| Per-user quantity tracking | Subscription item quantity on a quantity_source: DECLARED, quantity_adjustable: true product |
| Annual prepay discount | Monthly and annual cadence carried by separate plan versions; the subscription's billing_interval_unit picks month vs year |
| Mid-cycle seat changes | POST /subscriptions/{id}/change-quantity with automatic proration |
| Trial conversion | A trial phase (phase_kind: trial) that auto-transitions; subscription status stays ACTIVE once started |
| Switching annual / monthly | Scheduled change (change_type: version_bump) at next renewal |
| Expansion revenue tracking | subscription.quantity_changed webhook + /v1/analytics/mrr |
Build it
1. Define the seat product
Code
quantity_source: DECLARED tells Kontorion the billed quantity comes from the subscription item (not a meter), and quantity_adjustable: true marks this as a seat/license-style product where the buyer picks the quantity. Together they make the subscription item's quantity field drive the line quantity at billing time.
2. Attach two prices to the plan - monthly and annual
Code
Cadence is not a property of the price — a price carries only the amount (list_price / tiers) and currency. The billing cadence lives on the subscription via billing_interval_unit (month / year). Model the monthly and annual rates as separate plan versions so the subscription can switch between them; the resolved per-period amount is read from the active version's price at billing time.
3. Start a subscription with a 14-day trial
Code
A trial is modeled as a phase, not a subscription status. The first phase has phase_kind: trial, which does not generate invoices, and auto_transition: true so it flips to the standard phase after 14 days. The subscription's status is ACTIVE for the whole time (status is only DRAFT/ACTIVE/CANCELLED/EXPIRED — there is no TRIALING state). No invoice generates during the trial phase; when it ends and the standard phase begins, the first full-period invoice posts at 5 × $15 = $75.
4. Mid-cycle seat increase (proration handled)
On day 12 of a 30-day period, customer adds 3 seats:
Code
Proration is automatic - the engine emits credit and charge atoms based on the subscription's settings.default_proration_mode (pro_rata / pay_in_full / do_not_charge). The next invoice (or the current draft, depending on billing timing) carries:
- A credit line: 5 seats × $15 × (12/30) for the days already paid at the old quantity
- A charge line: 8 seats × $15 × (18/30) for the remaining days at the new quantity
Net effect: customer is charged for the prorated delta, not double-charged.
5. Switch to annual at the next renewal
Customer wants to lock in the annual rate. Don't apply the change immediately - schedule it for the next billing period boundary so the current monthly cycle finishes cleanly. The annual cadence is carried by a different plan version, so the scheduled change is a version_bump that moves the subscription onto that version at the boundary:
Code
version_bump is the only subscription change_type the scheduler dispatches. At release it routes through the subscription's version-change path with immediate_prorate, landing it on the target plan version (the one whose cadence is annual).
scheduled_change.upcoming fires at the configured lead time before the change so you can email the customer the heads-up.
Variations
- Volume discount over 50 seats. Replace the flat tier with a STAIRCASE walk:
[{up_to: 50, unit_amount: "15.00"}, {up_to: null, unit_amount: "12.00"}]. Pricing automatically applies the lower rate to seats above 50. - Free tier with hard cap. Set
unit_amount: "0"for the first 3 seats; downstream listeners onsubscription.quantity_changedcan alert or block when the customer crosses a threshold. - Custom enterprise rate. Apply a customer price override on the seat product for that one customer. The override beats the catalog price; the rest of the plan stays standard.
- Multi-currency catalog. Add EUR, GBP, and CAD prices with the same plan; each customer is billed in their
preferred_currency. FX is only used for cross-currency reporting, not customer-facing charges.
What you don't have to build
- Proration math on quantity changes (handled by the subscription's
settings.default_proration_mode) - Trial countdown and conversion (the
trialphase auto-transitions tostandardwhen itsduration_valueelapses) - Annual-vs-monthly switching at renewal (a scheduled
version_bumpmoves the subscription onto the plan version with the new cadence) - Expansion-revenue analytics (
/v1/analytics/mrrreturns current vs. previous and a 12-month time series) - Trial-conversion notifications (
subscription.activatedwebhook) - "Manage seats" customer portal (use the same
POST /change-quantityendpoint server-side)
Next steps
- Subscriptions - lifecycle states, proration semantics
- Pricing Models - VOLUME vs STAIRCASE for seat tiering
- Scheduled Changes - annual / monthly switching
- Analytics - MRR, ARR, expansion revenue, cohort retention