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.
Kontier blueprint
| Concern | Kontier primitive |
|---|---|
| Per-user quantity tracking | A quantity_source: DECLARED, quantity_adjustable: true product; the seat count is the quantity value on the subscription's line |
| Annual prepay discount | Two plans, one with price_cadence_unit: month, one with year, each with its own seat price |
| Mid-cycle seat changes | Cut the live revision, state the new quantity on the successor phase, publish |
| Trial conversion | A first phase with a free price and a 14-day duration, followed by the paid phase; no separate trial status |
| Switching annual / monthly | A cut at the end of the current period, with the successor phase's line drawn from the annual plan |
| Expansion revenue tracking | subscription.revision_published webhook + analytics |
Build it
IDs in the examples are placeholders: use the id each create call returns.
1. Define the seat product
Code
quantity_source: DECLARED tells Kontier the billed quantity is stated on the subscription (not counted by a meter), and quantity_adjustable: true marks this as a seat/license-style product where the buyer picks the quantity. A product is created in a working draft; it is published together with the plans in step 3.
2. Create a monthly and an annual plan, each with its seat price
Cadence is a property of the plan: price_cadence_unit says which billing period the plan's prices are authored for (week, month or year; month when omitted). A price covers one period of its plan's cadence and is never rescaled, so the monthly and the annual rate live on two plans.
Code
Attach the seat product to both plans, then give each plan its own price for it. plan_version: 0 on a plan that has never been published stages the price on the plan's working draft; it ships with the first publish.
Code
(Repeat the POST /v1/plans/{id}/products call for the annual plan.)
3. Publish the catalog
Nothing above is sellable until it is published. Publishing is a change: POST /v1/changes with the operation catalog.publish_version. Publish the product first, then each plan:
Code
Use "subject_kind": "product" with the product's id for the product. Each plan's first publish is its version 1, which is what a subscription line names in origin.version.
4. Start a subscription with a 14-day trial
A subscription is created empty, then its deal is written as a revision made of phases, then it is started.
Code
Code
Code
A trial is a phase, not a status. The first phase states a free price, so it bills nothing; when its 14 days run out the paid phase takes over. The paid phase's line inherits the quantity of 5 from the trial phase, and "price": { "kind": "reset" } drops the free price back to the catalog's $15, so the first paid invoice is 5 × $15 = $75. then: "open" on the last phase means the subscription runs on (it auto-renews). The subscription's status is ACTIVE throughout; the statuses are DRAFT, ACTIVE, PAUSED and CANCELLED.
5. Mid-cycle seat increase (proration handled)
On day 12 of a 30-day period (27 November 2026), the customer adds 3 seats. A change to a live deal is three calls: cut the live revision at an instant, write the whole draft with the new seat count, publish.
Code
Omitting at cuts at the server's now; a cut may not be in the past. In the draft the cut answers, the paid phase now ends at the cut instant (here 2026-11-27T10:42:00Z, read from phases[1].ends.at) with then: "stop". Write the whole draft: the trial and the paid phase as they are, with then: "next" on the paid phase, and a new phase with 8 seats that continues it:
Code
Code
The cut instant becomes a boundary the invoice pipeline prorates across: the 3 extra seats are billed for the 18 remaining days only, and the difference for the period already billed in advance is settled on the next invoice. Publishing emits subscription.revision_published.
A phase that has started cannot change, except its end and then. A refused draft answers 422 SUBSCRIPTION_DRAFT_INVALID with every problem in problems. To see the amounts before you write, send the same body to POST /v1/subscriptions/{id}/revisions/draft/preview; nothing is saved. To throw the draft away instead of publishing it, DELETE /v1/subscriptions/{id}/revisions/draft. See Editing a draft.
6. Switch to annual at the next renewal
The customer wants the annual rate from the next period on. Read the end of the current period (live.current_period.end from GET /v1/subscriptions/{id}?include=live), cut there, and write the draft with a last phase whose line is drawn from the annual plan:
Code
The draft keeps every phase from step 5, ends the 8-seat phase at the cut, and adds the annual phase. The annual phase says "continues": false: it is a new plan, not the rest of the monthly phase. (Left out, continues is true for the phase right after a cut.) Then publish the draft as in 5c. A later phase may bill on a different cadence than an earlier one: the monthly period ends at the cut and the annual calendar steps from there. One phase cannot mix plans of different cadences; that is refused with SUBSCRIPTION_CADENCE_MISMATCH.
To schedule a seat change without drafting it yourself, author a change: POST /v1/changes with the operation subscription.transform (kind: set_value, attribute: quantity, from the instant it applies). See Scheduling.
Variations
- Volume discount over 50 seats. Give the seat price a tier ladder instead of a flat amount:
"tiers": [{ "up_to": "50", "unit_amount": "15.00" }, { "unit_amount": "12.00" }]. UnderSTAIRCASEeach seat above 50 is billed at $12; underVOLUMEthe whole quantity is billed at the rate of the tier it lands in. - Free tier with hard cap. Give the plan's product membership a
max_quantity(POST /v1/plans/{id}/products); a quantity above it cannot be sold from the plan. - Custom enterprise rate. State an agreed price on that customer's line instead of touching the catalog:
"price": { "kind": "stated", "price": { "kind": "agreed", "amount": "12.50" } }. The agreed value carries forward to every later phase until something else is stated. - Multi-currency catalog. Add EUR, GBP and CAD prices for the same product and plan; a subscription bills in its own
currency, fixed when it is created.
What you don't have to build
- Proration math on quantity changes (a cut is a billing boundary; the pipeline prorates across it)
- Trial countdown and conversion (the free phase ends on its own and the paid phase opens)
- Annual-vs-monthly switching at renewal (a cut at the period end with the annual plan on the next phase)
- Expansion-revenue analytics (
GET /v1/analytics/metricslists what analytics serves,GET /v1/analytics/seriesreturns it over time) - Change notifications (
subscription.revision_publishedon every published revision,subscription.activatedwhen the deal starts)
Next steps
- Subscriptions - revisions, phases, lines and their values
- Pricing Models - VOLUME vs STAIRCASE for seat tiering
- Scheduling - changes that run at a later instant
- Analytics - MRR, ARR, expansion revenue