Team-owned Stripe subscriptions in 3 endpoints
Teams shipped on getSnap.dev v1.10 as a way for a whole agency to share
one Business plan without buying five Pro plans. The problem: the team's
plan was a snapshot of the creator's Stripe subscription at team-creation
time. If Alice created a team on Pro, then later cancelled her personal
subscription, the team's plan drifted silently. Stripe events only ever
touched Alice's api_keys row; the team row was frozen in time.
This post is the case study of shipping v1.13 — giving each team its own Stripe subscription decoupled from any individual member's billing. Three endpoints, one metadata field on the subscription, and zero database migration required to move existing teams over. Total code delta: +1054 lines, 11 files, 8 new tests.
The design constraint
We deliberately did not want per-seat billing today. Every SaaS blog post insists you must charge $X per user per month the moment you have teams. In practice, per-seat billing means:
- Team owners have to defend an ever-growing bill to their finance team.
- You need admin flows for adding + removing seats mid-cycle.
- You need prorated invoices, seat-count webhooks, and a policy for "we downgraded from 12 seats to 8 — do we refund the 4 or credit them next month?"
- You need a separate product + price ID in Stripe, decoupled from your personal-plan prices.
None of that is desirable when the core value prop is the whole team shares one shared quota. So we chose the smallest possible shape: teams pay the same tier prices as personal subs ($9 Starter / $29 Pro / $79 Business), and one team gets one subscription. If your team grows from 3 people to 10 people, your invoice is unchanged — you're just spreading the same 25k monthly captures across 10 users instead of 3.
Three new endpoints
The teams API grew from 8 operations to 11. The three new ones are:
| Endpoint | Who | What it does |
|---|---|---|
POST /v1/teams/:id/checkout |
Owner only | Creates the team's Stripe customer (idempotent - reuses existing) and launches Stripe Checkout with metadata.team_id stamped on the resulting subscription. Returns a checkout URL; if the team already has a sub, returns the billing portal URL instead. New team subs get a 7-day trial with missing_payment_method: cancel. |
POST /v1/teams/:id/portal |
Owner only | Stripe billing portal URL for the team's subscription. Where owners change plans, update payment methods, download invoices, or cancel. Returns 400 if the team has no Stripe customer yet. |
GET /v1/teams/:id/billing |
Any team member | Read-only. Returns plan, monthly limit, rate limit, subscription status (active / trialing / past_due / canceled / etc.), current period end, cancel-at-period-end flag. Powers the dashboard UI without a Stripe round-trip on every page load. |
The one metadata field
Stripe subscriptions carry an arbitrary metadata bag. We
stamp team_id on it at Checkout Session creation time:
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: "subscription",
line_items: [{ price: PLAN_PRICES[plan], quantity: 1 }],
subscription_data: {
trial_period_days: 7,
trial_settings: {
end_behavior: { missing_payment_method: "cancel" },
},
metadata: {
team_id: teamIdParam, // <-- routes webhook events to team
owner_user_id: apiKey.user_id,
plan: parsed.data.plan,
},
},
success_url: "https://getsnap.dev/docs/teams.html?upgraded=true&team_id=" + teamIdParam,
cancel_url: "https://getsnap.dev/docs/teams.html?team_id=" + teamIdParam,
});
Then the Stripe webhook handler checks subscription.metadata.team_id
on every event. If set, the update lands on the teams table; if absent,
it falls through to the existing personal api_keys path
untouched. This preserves the pre-v1.13 behavior for every existing
customer:
case "customer.subscription.updated": {
const subscription = event.data.object;
const teamId = teamIdFromMeta(subscription);
if (teamId) {
// Team-owned subscription. Update the team row + punch Redis
// for every attached api_key. No user-facing upgrade email
// today - the /billing endpoint and dashboard UI surface plan
// changes on demand.
updateTeamPlan(teamId, plan, subscriptionId, customerId, status,
currentPeriodEnd, cancelAtPeriodEnd);
break;
}
// Original personal-sub path runs unchanged.
updateApiKeyPlan(customerId, plan, subscriptionId);
...
}
Everything else about the webhook — the stripe_events(event_id UNIQUE)
idempotency table, the transactional dedup INSERT, the post-commit side
effects for emails — is untouched. The routing is a single
early-return based on one metadata key.
Zero migration required
The teams table gained three columns via idempotent
ALTER TABLE migrations:
ALTER TABLE teams ADD COLUMN subscription_status TEXT;
ALTER TABLE teams ADD COLUMN current_period_end TEXT;
ALTER TABLE teams ADD COLUMN cancel_at_period_end INTEGER DEFAULT 0;
All three default to NULL or 0 for teams that
pre-date v1.13. Those teams stay on their creation-time snapshot — the
plan they inherited from the creator is what they get, unchanged — until
the owner runs the /checkout flow to attach a proper
team-owned subscription. At that point the webhook fires and populates
the three new columns.
No backfill script, no data migration, no downtime. New teams start with the new fields empty; existing teams continue to work.
Live end-to-end test
Before shipping we wanted to see the actual Stripe webhook route through our new code. Local unit tests mock the Stripe SDK; that's necessary but not sufficient. So we ran this against live prod:
- Created a test team via
POST /v1/teams. - Ran
POST /v1/teams/:id/checkoutwhich minted a real Stripe customer (cus_VIPN8PrKHOinr5) with our metadata bag set. - Skipped the interactive checkout page and created a subscription
directly via Stripe API with
payment_behavior=default_incomplete(no real charge) andmetadata[team_id]=team_ASXQtkYsMfVQqzJm. - Stripe fired
customer.subscription.createdat our webhook. - Verified the team row:
plan=starter,monthly_limit=5000,rate_limit_per_min=30,subscription_status=incomplete,stripe_subscription_id=sub_1UHoZ7QrFYlhiOq7ie5dvKY1. - Cancelled the sub via Stripe API —
customer.subscription.updatedfired — team status transitioned toincomplete_expired. - Dissolved the test team via
DELETE /v1/teams/:id. The dissolve handler noticed the active sub and calledstripe.subscriptions.cancel()as fire-and-forget before dropping the DB row.
End-to-end lifecycle verified in about two minutes. The whole flow
works via metadata.team_id on the subscription; if we
were doing per-seat later, we'd add a quantity field to
the Checkout Session and read a customer.subscription.updated
for seat-count changes. The routing logic wouldn't need to change.
What we didn't ship
A few adjacent concerns we deliberately deferred:
-
Referral credit on team invoices. The referral flow
matches by
stripe_customer_idon theapi_keysrow. A team's customer is fresh — it doesn't match any api_key row. Soinvoice.paidon a team invoice no-ops for referrals. Users who care about earning referrer credit should start with a personal subscription first, then create a team afterwards. -
Team billing transfer on ownership change. If Alice
runs
/checkoutand later transfers ownership to Bob, the Stripe subscription stays on Alice's customer. To move billing over, Bob runs/checkouthimself, which creates a new subscription on his customer. Alice's old sub isn't automatically cancelled — she can do that via her personal billing portal. This is documented in the teams doc, edge case row 8. - Per-seat pricing. Not planned. If we ever add higher tiers (Enterprise, Team Business+), we might add per-seat multipliers there. The current flat-tier model works for the "shared quota" story that teams are actually solving.
The Discord alert that told us this was working
In the same release we shipped runtime Discord alerts for API 5xx, Stripe signature failures, and cron script failures (see the v1.13.0 changelog). The first thing that happened after deploying team billing was — nothing. No alerts fired for three days. Then a test API call with a deliberately bad Stripe signature triggered:
:shield: Stripe webhook signature verification failed
IP:78.56.45.227
Signature:t=fake,v1=bogus
Error:No signatures found matching the expected signature for payload...
Which is exactly what we wanted. Muted for 1 hour by a Redis dedup key.
A second test call within the window was silent (verified via
redis-cli GET alert:stripe_sig_fail). Once the key expired,
the next real signature failure would fire again. Dogfooding continues.
Try it yourself
Create a free API key, spin up a team, run
POST /v1/teams/:id/checkout against the Starter plan
(7-day trial, no card charged if you don't add one). Then use
getsnap teams billing <team_id> from the CLI to
watch the subscription state transition.
Related reading
- A 503 that could not happen — debugging a framework-level bug in the same codebase
- Billing and payment flow — what a subscription change does to your account