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:

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:

EndpointWhoWhat 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:

  1. Created a test team via POST /v1/teams.
  2. Ran POST /v1/teams/:id/checkout which minted a real Stripe customer (cus_VIPN8PrKHOinr5) with our metadata bag set.
  3. Skipped the interactive checkout page and created a subscription directly via Stripe API with payment_behavior=default_incomplete (no real charge) and metadata[team_id]=team_ASXQtkYsMfVQqzJm.
  4. Stripe fired customer.subscription.created at our webhook.
  5. Verified the team row: plan=starter, monthly_limit=5000, rate_limit_per_min=30, subscription_status=incomplete, stripe_subscription_id=sub_1UHoZ7QrFYlhiOq7ie5dvKY1.
  6. Cancelled the sub via Stripe API — customer.subscription.updated fired — team status transitioned to incomplete_expired.
  7. Dissolved the test team via DELETE /v1/teams/:id. The dissolve handler noticed the active sub and called stripe.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:

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.

Get a free API key   Read the teams doc

Related reading