
Modeling Billing So the Code Matches the Pricing Page, Not the Other Way Around
A base-fee-plus-seat-overage pricing model needs two Stripe subscription items, not one; webhooks confirm state changes but never decide them; and a single four-state subscription machine replaces a separate `isActive` flag that could quietly disagree with it
Part 3 of 7 — InvocaCare Architecture series
Pricing pages look simple. "$79/month, up to 200 calls, includes your first seats, $X per extra seat after that." One sentence, easy to read, easy to sell. Then you sit down to model it in Stripe and discover that "simple pricing" and "simple billing code" are not the same claim at all — and every shortcut you take on the second one eventually shows up as a support ticket, a double charge, or an argument about what a customer actually owes.
Here's how InvocaCare's billing model turned out, and which shortcuts I took on purpose versus which ones I ruled out after actually trying them.
Decision: two subscription items, not one
The obvious way to model "base fee plus per-seat overage" in Stripe is a single subscription item, quantity equal to seat count, price scaled accordingly. I tried it. It can't actually express the pricing: a flat base fee that doesn't move with seat count, plus an overage that only kicks in past an included-seat threshold, isn't a single linear quantity-times-price relationship. You either lose the base fee or lose the free included seats.
So every InvocaCare subscription carries exactly two Stripe subscription items:
| Item | Role | Quantity |
|---|---|---|
| Base | flat monthly tier fee | always 1 |
| Seat | per-additional-seat overage | max(0, seats − includedSeats) |
Each item is tagged with its own metadata (role: 'base' / role: 'seat'), so the code that reconstructs a subscription record from Stripe's API response can identify each item reliably regardless of array order — a small detail, but the kind of thing that turns into a confusing bug six months later if you skip it.
I also looked at using Stripe Checkout Sessions for tier changes — upgrade/downgrade via a hosted checkout flow — and ruled it out for the same reason: it creates a window where there's no active subscription, and it can't update seat quantities on an existing one. Tier and seat changes happen through direct Stripe API calls instead, kept in sync with our own records synchronously.
Decision: webhooks confirm, they don't decide
The tempting pattern is to let Stripe webhooks drive your billing state — a tier changes, Stripe fires an event, your handler reacts. I didn't build it that way. Outbound calls from InvocaCare's billing service update Stripe and DynamoDB synchronously, in the same operation that initiated the change. Webhooks exist to confirm that the action actually completed and to correct any divergence — they never initiate a tier change themselves.