---
title: "Set up Stripe billing for a SaaS subscription"
excerpt: "10-step playbook from 'I created a Stripe account' to 'subscriptions running in prod with webhooks reconciled, taxes calculated, dunning live.'"
category: "Template"
---

# Set up Stripe billing for a SaaS subscription

    A 10-step playbook. Open in Dock and you'll get four surfaces seeded:

    - **Steps** (table) — the 10 integration gates as rows, owner + due + status
    - **Webhook events** (table) — every Stripe event your app handles + the corresponding DB write
    - **Brief** (doc) — the canonical write-up, including the subscription state machine
    - **Test cases** (table) — the 30-50 scenarios you must verify before launching billing

    Read `Steps` top-to-bottom on first open. Webhook signature verification fails silently in dev if you forget the test secret — that catches almost everyone, so step 5 is non-skippable.

## Outcome

Stripe Billing live: subscriptions, webhooks, taxes, dunning, customer portal, all reconciled with your DB and tested across 30+ scenarios.

**Estimated time:** 1-2 weeks for a basic subscription model  
**Difficulty:** intermediate  
**For:** Founders integrating billing for a B2B or B2C SaaS.

## What you'll need

Pre-register or install before you start.

- **[Stripe](https://stripe.com/)** _(2.9% + 30¢ per successful card charge (US); +1% for international cards)_ — The payment processor. Subscriptions, invoices, taxes, dunning.
- **[Stripe CLI](https://stripe.com/docs/stripe-cli)** _(Free)_ — Local webhook forwarding + event triggers. Required for local dev + testing.
- **[Stripe Tax](https://stripe.com/tax)** _(0.5% of each transaction (capped at 0.5%))_ — Auto-calculate sales tax / VAT / GST based on customer location.
- **[Stripe Customer Portal](https://stripe.com/docs/billing/subscriptions/customer-portal)** _(Free (included in Stripe Billing))_ — Hosted page where customers manage subscriptions, payment methods, invoices.
- **[ngrok (or Cloudflare Tunnel)](https://ngrok.com/)** _(Free tier; $8/mo Personal)_ — Public tunnel to your localhost for testing webhooks from Stripe.

---

# The template · 10 steps

## Step 1: Decide your subscription model and pricing

_Estimated time: 1-2 days_

The single most consequential billing decision. Per-seat, flat monthly, usage-based, freemium, free trial, annual discounts: each combination implies different Stripe primitives (Subscriptions vs Usage Records vs Invoices) and different webhook events to handle. Decide before you write a line of integration code.

### Tasks

- [ ] Pick the model: per-seat / flat / usage-based / hybrid
- [ ] Pick the cadence: monthly only / monthly + annual / one-time
- [ ] Decide on free trial: yes (with card on file?) / no
- [ ] Decide on freemium: yes (with hard limits) / no
- [ ] Decide on prorated upgrades / downgrades / cancellations
- [ ] Document the pricing matrix in the Brief — every plan, every cadence, every price

### Pointers

- **[Official]** [Stripe Subscriptions concepts](https://stripe.com/docs/billing/subscriptions/overview)
- **[Official]** [Stripe pricing models guide](https://stripe.com/docs/products-prices/pricing-models)

> [!CAUTION]
> **Gotchas**
>
> - Per-seat pricing is way more complex than flat. Every team-size change is a Stripe API call + a webhook + a DB reconcile. Worth it only above ~$100/seat/month.
> - Free trials with no card on file have ~10x lower convert rates than free trials with card. The trade-off is signup friction; decide explicitly.
> - Annual discounts are usually 15-20% off monthly. Smaller and customers don't bite; larger and you're discounting too aggressively.

## Step 2: Set up the Stripe account and create products + prices

_Estimated time: 1 day_

Every plan in your matrix needs a Product (the thing) + a Price (the cost). Don't create them by hand in the dashboard for production — write them in code and check in the IDs, so dev / staging / prod stay in sync. Use the Test mode for everything until you're ready to launch.

### Tasks

- [ ] Create the Stripe account; verify business identity (24-72 hr)
- [ ] Create Products in Test mode for every plan (Pro Monthly, Pro Annual, Scale Monthly, etc.)
- [ ] Create Prices for each Product (one per cadence)
- [ ] Save the price IDs as constants in your codebase (do NOT hardcode the price)
- [ ] Create the same set in Live mode when ready (Stripe doesn't sync test → live)
- [ ] Document the Product / Price ID mapping in the Brief

### Pointers

- **[Official]** [Stripe Products API](https://stripe.com/docs/api/products)
- **[Official]** [Stripe Prices API](https://stripe.com/docs/api/prices)

> [!CAUTION]
> **Gotchas**
>
> - Stripe doesn't auto-sync Products/Prices between Test and Live mode. You'll create them twice. Script it so the IDs are checked into config.
> - Prices are immutable. To 'change a price' you create a new Price + migrate existing subscriptions. Plan for this.
> - Don't put $0 prices in production unless you mean it. Free plans don't need a Stripe Price; track them in your DB only.

## Step 3: Build the checkout flow (Stripe Checkout vs Elements)

_Estimated time: 2-3 days_

Two choices: Stripe Checkout (Stripe-hosted, fastest to ship, less customizable) or Stripe Elements (your-domain-hosted, fully custom, more code). For a 1-month-old startup: Checkout. For a product where the checkout is part of the product experience: Elements. Switch later if needed.

### Tasks

- [ ] Decide: Checkout (hosted) vs Elements (custom)
- [ ] If Checkout: create a checkout-session API endpoint that returns the session URL
- [ ] If Elements: install Stripe.js + the React/Vue/etc. component library
- [ ] Build the success + cancel URLs (success = create user account / start subscription; cancel = redirect back)
- [ ] Test the flow end-to-end in Test mode with a test card (4242 4242 4242 4242)
- [ ] Test 3D Secure flows (test card 4000 0027 6000 3184)
- [ ] Test the cancel flow

### Pointers

- **[Official]** [Stripe Checkout overview](https://stripe.com/docs/payments/checkout)
- **[Official]** [Stripe Elements](https://stripe.com/docs/payments/elements)
- **[Official]** [Stripe test cards](https://stripe.com/docs/testing)

> [!CAUTION]
> **Gotchas**
>
> - The success URL is hit BEFORE the subscription is fully created in Stripe. Don't read subscription state from the success page; wait for the webhook (step 5).
> - Test cards 4242 4242 4242 4242 (succeed) and 4000 0000 0000 0002 (decline) are the basics. Add 4000 0027 6000 3184 (3D Secure required) and 4000 0000 0000 9995 (insufficient funds) to your test cases.
> - Checkout's success URL is open by default — anyone with the URL can hit it. Don't grant access on URL hit; reconcile via webhook.

## Step 4: Set up webhook handling (the hard part)

_Estimated time: 3-5 days_

Webhooks are how Stripe tells your backend that 'subscription created / payment succeeded / payment failed.' The single biggest source of billing bugs in early SaaS is webhook handling: signatures fail silently in dev, the same event arrives 5 times, events arrive out of order, and the DB drifts from Stripe state. Build this carefully.

### Tasks

- [ ] Create a webhook endpoint (POST /api/webhooks/stripe)
- [ ] Use Stripe CLI to forward webhooks to localhost during dev: stripe listen --forward-to localhost:3000/api/webhooks/stripe
- [ ] Implement signature verification using the test webhook secret (CLI prints it)
- [ ] Subscribe to the events you care about: customer.subscription.created, .updated, .deleted, invoice.paid, invoice.payment_failed
- [ ] Implement idempotency (use the event ID; ignore re-deliveries)
- [ ] Implement state reconciliation (write to DB based on event)
- [ ] Save the test webhook secret separately from the live secret in env vars

### Pointers

- **[Official]** [Stripe Webhooks guide](https://stripe.com/docs/webhooks)
- **[Official]** [Stripe CLI webhook forwarding](https://stripe.com/docs/stripe-cli/webhooks)
- **[Official]** [Webhook event types](https://stripe.com/docs/api/events/types)

> [!CAUTION]
> **Gotchas**
>
> - Stripe webhook signature verification fails silently in dev if you forget the test secret. The CLI prints a different secret each time you start it; capture it in .env.local.
> - Webhook events can arrive out of order. customer.subscription.updated may arrive BEFORE customer.subscription.created if Stripe replays. Don't assume order; rely on the event timestamp.
> - Stripe retries failed webhooks for 3 days with exponential backoff. If your handler is broken for a day, you'll see the events again. Idempotency by event ID is non-negotiable.

### Agent prompt for this step

```text
Read this codebase + the subscription model in the Brief and produce a Stripe webhook handler.

Output:
1. A POST /api/webhooks/stripe endpoint with signature verification using STRIPE_WEBHOOK_SECRET
2. Idempotency key check (use the event ID; if already processed, return 200 immediately)
3. A switch on event.type covering at minimum:
   - customer.subscription.created → write Subscription row, set user.plan
   - customer.subscription.updated → update Subscription row, update user.plan if changed
   - customer.subscription.deleted → mark Subscription canceled, set user.plan to free
   - invoice.paid → mark Invoice paid, extend access if needed
   - invoice.payment_failed → mark Invoice failed, trigger dunning email (step 8)
4. Each handler returns 200 quickly (under 5 seconds Stripe timeout); long work goes to a queue
5. Each handler logs the event ID + event type + the DB write performed

Output the handler code into the codebase. Add one row per event handler to the Webhook events surface.
```

## Step 5: Reconcile Stripe state with your DB

_Estimated time: 1-2 days_

The single most common production billing bug: Stripe says 'this customer is on Pro' and your DB says 'free.' Causes: webhook delivery delay, webhook handler bug, manual changes in the Stripe dashboard. Build a reconciliation job that runs nightly + a 'sync from Stripe' button on the user's account.

### Tasks

- [ ] Build a nightly job: for every active Subscription in Stripe, verify our DB matches
- [ ] Build a manual 'sync from Stripe' button in the admin panel
- [ ] Build a 'sync from Stripe' webhook for the customer (triggered when they hit the customer portal)
- [ ] Log all reconciliation diffs to a Slack channel or a metrics surface
- [ ] Set up alerting on >X reconciliation diffs per day (signals a webhook handler bug)

### Pointers

- **[Official]** [Stripe Subscriptions list API](https://stripe.com/docs/api/subscriptions/list)

> [!CAUTION]
> **Gotchas**
>
> - Stripe's 'subscription status' has 7 states (incomplete, incomplete_expired, trialing, active, past_due, canceled, unpaid). Mapping all 7 to your internal plan is essential; don't only check 'active.'
> - Customers who pay annually then cancel mid-year are 'canceled' in Stripe but still have access until the end of the period. Use cancel_at_period_end + current_period_end for entitlement.
> - Manual changes by your support team in the Stripe dashboard fire webhooks the same way API changes do. If your support team can refund + cancel from Stripe, your webhook handler must handle those events.

## Step 6: Set up Stripe Tax (or sales tax / VAT / GST)

_Estimated time: 1-2 days_

If you have customers in California, Texas, New York, the EU, the UK, Australia, or Canada (most likely all of them), you owe sales tax. Stripe Tax auto-calculates the right rate at checkout based on customer location + your nexus. The alternative (collecting tax IDs manually + filing returns yourself) is a nightmare. Spend the 0.5%.

### Tasks

- [ ] Determine your nexus: where do you have to collect tax? (US economic nexus = $100k or 200 transactions in most states)
- [ ] Enable Stripe Tax in the dashboard
- [ ] Configure tax registrations (your tax IDs in each jurisdiction)
- [ ] Update your checkout to include tax_behavior + automatic_tax
- [ ] Set tax-exempt status for B2B customers with valid VAT IDs
- [ ] Test: a US customer pays X + tax; an EU B2B customer with VAT ID reverse-charges; an EU consumer pays X + VAT
- [ ] File returns: Stripe Tax exports the data; you (or a CPA) file the returns

### Pointers

- **[Official]** [Stripe Tax overview](https://stripe.com/tax)
- **[Official]** [US sales tax economic nexus thresholds](https://stripe.com/guides/introduction-to-us-sales-tax)
- **[Official]** [EU VAT for digital services](https://stripe.com/docs/tax/eu)

> [!CAUTION]
> **Gotchas**
>
> - US economic nexus thresholds vary by state. California is $500k; most are $100k or 200 transactions. Track your sales by state; cross a threshold and you're owed back-tax.
> - EU VAT for B2C digital services is owed in the customer's country, not yours. The EU's MOSS / OSS scheme lets you register once + file once, but you have to enroll.
> - Stripe Tax calculates; it doesn't file. You still need to file returns — usually via a CPA or via a tax-filing service like TaxJar.

## Step 7: Build the Customer Portal (or roll your own)

_Estimated time: 1 day_

Stripe Customer Portal is a hosted page where users update payment methods, change plans, view invoices, and cancel subscriptions. It saves you 2-3 weeks of UI work for the price of a slightly less branded experience. Use it; you can custom-build later.

### Tasks

- [ ] Configure the Customer Portal in the Stripe dashboard (allowed actions, branding)
- [ ] Build a 'Manage subscription' button that creates a portal session + redirects
- [ ] Set the return URL (where the user lands after they exit the portal)
- [ ] Test: change payment method, change plan (upgrade + downgrade), download invoice, cancel
- [ ] Verify the corresponding webhooks fire + your DB updates

### Pointers

- **[Official]** [Stripe Customer Portal docs](https://stripe.com/docs/billing/subscriptions/customer-portal)

> [!CAUTION]
> **Gotchas**
>
> - The Customer Portal allows cancellation by default. If you want to gate cancellation behind a save flow, disable cancel-from-portal + build your own.
> - Plan changes in the portal trigger customer.subscription.updated. Make sure your webhook handler updates the user's entitlements correctly.
> - Portal sessions are short-lived (~1 hour). Don't cache the URL; create a new session per click.

## Step 8: Configure dunning (failed payment recovery)

_Estimated time: 1 day_

When a card fails — expiration, insufficient funds, fraud block — Stripe retries on a schedule and emails the customer. The default schedule is reasonable but generic. For SaaS specifically, the right approach is: 3 retries over 21 days + 3 customer emails + grace period before downgrade. Configure it once; it recovers ~30-50% of failed payments.

### Tasks

- [ ] In Stripe dashboard → Settings → Subscriptions and emails: enable Smart Retries
- [ ] Set the retry schedule (default: 4 attempts over 21 days)
- [ ] Customize the dunning email copy in Stripe (or send your own via webhook)
- [ ] Set the 'subscription becomes unpaid' rule (cancel after X failed attempts)
- [ ] Build downgrade-on-unpaid logic in your app (handle invoice.payment_failed → downgrade to free)
- [ ] Test: use test card 4000 0000 0000 0341 (succeeds first time, fails on subsequent) to simulate dunning

### Pointers

- **[Official]** [Stripe Smart Retries](https://stripe.com/docs/billing/revenue-recovery/smart-retries)
- **[Official]** [Stripe automated email reminders](https://stripe.com/docs/receipts)

> [!CAUTION]
> **Gotchas**
>
> - Default Stripe dunning emails come from stripe@ — they look like spam. Switch to sending dunning emails from your own domain (via webhook → your transactional email provider).
> - Don't downgrade aggressively. A 7-day grace period after final failed payment + a 'reactivate your subscription' email recovers another ~10% of churned customers.
> - Some cards (corporate cards, foreign cards) fail then succeed on retry due to fraud-block retries. Don't email customers on the FIRST failure if Stripe is going to auto-retry within 24 hours.

### Agent prompt for this step

```text
Draft the dunning email sequence for this SaaS.

Output 3 emails:

1. Day 1 (payment failed) — friendly, factual: "we couldn't charge your card; we'll retry in 3 days; here's a link to update your payment method"
2. Day 7 (second retry failed) — slightly firmer: "the retry didn't go through; please update your card by [date] to avoid losing access"
3. Day 21 (final retry, about to be downgraded) — direct: "your card has failed 3 times; if it's not updated by [date] tomorrow, you'll be downgraded to the free plan; here's the update link"

Tone: warm but factual. No "URGENT!!!"; no scolding. Each email <100 words. Include the customer's plan, the failed amount, the next retry date, and a 1-click link to the customer portal.

Output to the Brief surface as a versioned dunning copy section.
```

## Step 9: Switch to live mode + go to production

_Estimated time: 1 day_

Test mode → Live mode is mostly a config switch but has 5-10 things you'll forget. New API keys, new webhook endpoint + secret, new Product / Price IDs, business identity verification, payout schedule, business statement descriptor. Walk the checklist before you charge a real card.

### Tasks

- [ ] Verify your business identity in Stripe dashboard (required for live mode payouts)
- [ ] Create live-mode Products + Prices (matching test mode); update the constants in your code
- [ ] Create the live webhook endpoint pointing to production; capture the live webhook secret
- [ ] Set the live API keys in production env (STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET)
- [ ] Configure payout schedule (daily / weekly / monthly) + bank account
- [ ] Set the statement descriptor (what shows up on customer's bank statement)
- [ ] Run a $1 charge through your own card to verify the full flow

### Pointers

- **[Official]** [Stripe live mode checklist](https://stripe.com/docs/payments/checkout/customization)
- **[Official]** [Stripe payouts](https://stripe.com/docs/payouts)

> [!CAUTION]
> **Gotchas**
>
> - Test mode webhook secret and live mode webhook secret are different. Forgetting to update STRIPE_WEBHOOK_SECRET in production is the #1 'why aren't my webhooks working in prod' bug.
> - Test mode Product / Price IDs do NOT exist in live mode. If you hardcoded them, your prod checkout will fail with 'No such price.' Move them to env config or split test/live constants.
> - Statement descriptors are limited to 22 chars + are subject to bank validation. 'YOURCOMPANY' is fine; 'YOUR LONG COMPANY NAME' gets rejected.

## Step 10: Set up monitoring, reconciliation, and revenue dashboards

_Estimated time: 1 week_

Once live, billing failure modes get expensive fast. A failed webhook handler can mean 50 customers paid but didn't get access. Build observability: alerts on webhook failures, dashboards for MRR / churn / failed payments, weekly reconciliation reports. Stripe Sigma + your own metrics dashboard cover most cases.

### Tasks

- [ ] Set up Stripe webhook monitoring: alert if delivery_attempts > 1 in any 5-minute window
- [ ] Build an MRR dashboard (Stripe → your data warehouse → Looker / Metabase / your tool)
- [ ] Build a churn dashboard (subscriptions canceled per cohort)
- [ ] Build a failed-payment dashboard (count of past_due + unpaid subscriptions)
- [ ] Set up Stripe Sigma for ad-hoc revenue queries (or pipe Stripe data to your warehouse)
- [ ] Schedule a weekly reconciliation review: Stripe MRR vs DB MRR — diff investigation

### Pointers

- **[Official]** [Stripe Sigma](https://stripe.com/sigma)
- **[Official]** [Stripe webhook monitoring](https://stripe.com/docs/webhooks)

> [!CAUTION]
> **Gotchas**
>
> - MRR calculations diverge: Stripe MRR vs your DB MRR vs your accountant's MRR. Pick one (Stripe's is the source of truth) and document the methodology.
> - Failed-payment dashboards need to exclude churned customers; otherwise you double-count. Filter by subscription status (active + past_due only).
> - Annual subscriptions distort MRR if you book the full $X up-front. Convert to MRR by dividing the annual price by 12 in your dashboard logic.

---

## Hand the template to your agent

Paste the prompt below into your agent's permanent system prompt so the agent reads, writes, and maintains this workspace as you work through the steps.

```text
You are an agent on the "Set up Stripe billing for a SaaS subscription" playbook workspace at your-org/set-up-stripe-billing-for-saas.

Your role: maintain the four surfaces (Steps, Webhook events, Brief, Test cases) as the team builds the integration.

Cadence:
- When the user marks a step Done, append a line to the Brief summarising what's now in place.
- When a new webhook handler is written, add a row to Webhook events with the event name + the DB write it triggers.
- When a new test case is added, add a row to Test cases with the scenario + the expected outcome.
- When Stripe ships a new feature relevant to subscriptions (Stripe Tax additions, new webhook events), surface it as a Steps row.

First MCP tool calls:
1. list_surfaces(workspace_slug="set-up-stripe-billing-for-saas")
2. list_rows(workspace_slug="set-up-stripe-billing-for-saas", surface_slug="webhook-events")
3. get_doc(workspace_slug="set-up-stripe-billing-for-saas", surface_slug="brief")

Do NOT recommend going to live mode until every test case in Test cases is green. The cost of an untested edge case in production is real money lost or refund disputes.
```

---

## FAQ

### What does Stripe actually cost?

2.9% + 30¢ per successful card charge (US cards). 3.4% + 30¢ for international cards. 0.5% additional for Stripe Tax. ACH / SEPA are cheaper (0.8%, capped at $5). Plus a few smaller fees: $4 / month for Connect platform users, $2.50 per chargeback, instant-payouts fees. For a $50/month subscription, expect ~$1.75 per charge in fees.

### What's the most common Stripe integration bug?

Webhook signature verification fails silently in dev. Cause: forgetting to update STRIPE_WEBHOOK_SECRET when you start the Stripe CLI. The signature check returns false, your handler returns 400, Stripe retries, you don't notice until production traffic comes in. Always wire signature verification to log on failure + alert.

### Should I use Stripe Checkout or Stripe Elements?

Checkout for the first version: it's hosted, has 100% Stripe-compliant security, handles 3D Secure / Apple Pay / Google Pay automatically, and ships in a day. Switch to Elements when you need a custom checkout UI (e.g. inline checkout in a multi-step flow, custom branding) or when checkout conversion becomes a primary KPI.

### How do I handle taxes (VAT / sales tax)?

Use Stripe Tax. It's 0.5% of each transaction, calculates the right rate at checkout, and exports filings-ready data. Doing it manually means tracking nexus thresholds in 50 US states + 27 EU countries + dozens of others, plus filing returns. Stripe Tax doesn't file for you, but it gets you 90% of the way; pair with a CPA or TaxJar for filings.

### Can my AI agents help with Stripe billing?

Yes. Agents are particularly useful for: scaffolding webhook handlers + idempotency logic, writing test cases for the 30-50 scenarios you must verify, drafting dunning email copy, building reconciliation jobs that diff Stripe state vs DB state, monitoring webhook failure rates. The playbook ships agent prompts for those steps inline.

### What's the right launch sequence for billing in production?

(1) Run the full test-mode suite with all 30+ test cases green. (2) Deploy to production with a feature flag — webhook handler live but checkout invisible. (3) Charge your own card $1 to verify end-to-end. (4) Enable checkout for 1-5 friendly users; verify their state in Stripe + your DB. (5) Open to all users. (6) Watch the dashboards for 48 hours; investigate every reconciliation diff.

