skills-ws
Version:
85 agent skills for AI coding assistants — marketing, growth, web3, dev, design & operations. Built for OpenClaw, Claude Code, Cursor, and Codex.
2,306 lines • 81.2 kB
Markdown
---
name: saas-billing
description: "Express/Node SaaS billing with Stripe — subscriptions, usage billing (`billing.meterEvents`), webhooks, API key provisioning, dunning runbook, Adaptive Pricing, Stripe Tax. Use when building SaaS billing on an Express/Node backend; for Next.js see `stripe-billing`."
---
# SaaS Billing with Stripe — Expert Skill
> Disambiguation: this skill = Express/Node stack. For Next.js App Router + Server Actions billing, see `stripe-billing`. Both pin Stripe `apiVersion: '2025-09-30.clover'`.
> Production-grade billing integration for SaaS applications using Stripe.
> Covers subscription, usage-based, and hybrid billing models with complete Express.js examples.
---
## Table of Contents
1. [Core Concepts](#core-concepts)
2. [Billing Models](#billing-models)
3. [Stripe Products & Prices](#stripe-products--prices)
4. [Checkout Sessions](#checkout-sessions)
5. [Stripe Tax](#stripe-tax)
6. [Adaptive Pricing (Local-Currency Checkout)](#adaptive-pricing-local-currency-checkout)
7. [Subscription Lifecycle](#subscription-lifecycle)
8. [Webhook Handling](#webhook-handling)
9. [API Key Provisioning](#api-key-provisioning)
10. [Customer Portal](#customer-portal)
11. [Metered / Usage-Based Billing](#metered--usage-based-billing)
12. [Dunning & Failed Payments](#dunning--failed-payments)
13. [Security](#security)
14. [Testing](#testing)
15. [Common Mistakes](#common-mistakes)
16. [Complete Express.js Server Example](#complete-expressjs-server-example)
---
## Core Concepts
### Stripe Object Hierarchy
```
Customer
└── Subscription
├── Subscription Item (linked to a Price)
│ └── Price (linked to a Product)
│ └── Product
└── Invoice
└── Payment Intent → Payment Method
```
### Required Dependencies
```bash
npm install stripe express dotenv express-rate-limit
# Note: `crypto` is a Node.js core module — do NOT `npm install crypto`
# (that installs an abandoned, deprecated userland package). `require('crypto')` works out of the box.
# `body-parser` is unnecessary — Express 4.16+/5 ship `express.raw()` and `express.json()` built in.
```
Pin the Stripe SDK to a known major (`npm install stripe@^19`); the SDK major and the pinned `apiVersion` evolve together.
### Environment Variables
```env
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PORTAL_CONFIG_ID=bpc_... # optional
DATABASE_URL=postgres://...
```
### Stripe Client Initialization
```js
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2025-09-30.clover', // pin the API version
maxNetworkRetries: 2,
});
```
**Always pin your API version.** Stripe changes behavior across versions. Pinning prevents silent breakage.
> Version note (as of Jun 2026): `2025-09-30.clover` is the Clover-era pinned version used throughout this skill. Before copying these examples, confirm the version your account defaults to (Dashboard → Developers → API version / Workbench) and the version your installed SDK major expects — the SDK major (`stripe@19` here) and the API version evolve together. Verify the latest at https://docs.stripe.com/api/versioning and https://docs.stripe.com/changelog. Bumping the version may change object shapes (e.g. invoice/subscription fields), so test webhooks against the new version before deploying.
---
## Billing Models
### 1. Flat-Rate Subscription
Fixed price per billing period. Simplest model.
- **Example:** $29/month for Pro plan
- **Stripe price type:** `recurring` with `unit_amount`
- **Best for:** Simple SaaS with feature-gated tiers
### 2. Per-Seat / Per-Unit
Price × quantity. Quantity updated as team grows/shrinks.
- **Example:** $10/user/month
- **Stripe price type:** `recurring` with `unit_amount`, adjust `quantity` on subscription item
- **Best for:** Collaboration tools, team-based SaaS
### 3. Usage-Based (Metered)
Pay for what you use. Reported via the Billing Meters API (`billing.meterEvents`).
- **Example:** $0.01 per API call
- **Stripe price type:** `recurring` with `usage_type: 'metered'` and `meter: <meter_id>` (Billing Meters era)
- **Best for:** API platforms, infrastructure, AI/ML services (per-token, per-inference billing)
### 4. Tiered Pricing
Price changes at volume thresholds.
- **Example:** First 1000 calls free, next 10k at $0.005, then $0.001
- **Stripe price type:** `recurring` with `tiers_mode: 'graduated'` or `'volume'`
- **Best for:** APIs with volume discounts
### 5. Hybrid
Combines a base subscription fee with metered usage on top.
- **Example:** $49/month base + $0.02 per API call
- **Implementation:** Single subscription with two subscription items (one flat, one metered)
- **Best for:** Most real-world SaaS products
---
## Stripe Products & Prices
### Creating Products & Prices (API)
```js
// Create the product (represents your offering)
const product = await stripe.products.create({
name: 'Pro Plan',
description: 'Full access to all features',
metadata: {
tier: 'pro',
api_rate_limit: '1000',
},
});
// Flat recurring price
const monthlyPrice = await stripe.prices.create({
product: product.id,
unit_amount: 2900, // $29.00 in cents
currency: 'usd',
recurring: {
interval: 'month',
},
metadata: { plan: 'pro_monthly' },
});
// Annual price with discount
const annualPrice = await stripe.prices.create({
product: product.id,
unit_amount: 29000, // $290.00/year (saves ~$58)
currency: 'usd',
recurring: {
interval: 'year',
},
metadata: { plan: 'pro_annual' },
});
// Per-seat price
const perSeatPrice = await stripe.prices.create({
product: product.id,
unit_amount: 1000, // $10.00 per seat
currency: 'usd',
recurring: {
interval: 'month',
},
metadata: { plan: 'pro_per_seat' },
});
// Metered usage price — MODERN (Billing Meters era, the default for new builds).
// First create a Meter (once, persisted), then back the price with it.
// See "Metered / Usage-Based Billing" below for the full meter setup + event reporting.
const meter = await stripe.billing.meters.create({
display_name: 'API calls',
event_name: 'api_request', // you send events with this event_name
default_aggregation: { formula: 'sum' }, // 'sum' | 'count' | 'last'
});
const usagePrice = await stripe.prices.create({
product: product.id,
currency: 'usd',
recurring: {
interval: 'month',
usage_type: 'metered',
meter: meter.id, // ← binds this price to the meter (required for Meters-era usage)
},
unit_amount: 1, // $0.01 per unit (cents)
metadata: { plan: 'pro_api_usage' },
});
// Tiered price (graduated), also meter-backed
const tieredPrice = await stripe.prices.create({
product: product.id,
currency: 'usd',
recurring: {
interval: 'month',
usage_type: 'metered',
meter: meter.id,
},
billing_scheme: 'tiered',
tiers_mode: 'graduated',
tiers: [
{ up_to: 1000, unit_amount: 0 }, // first 1000 free
{ up_to: 10000, unit_amount: 1 }, // $0.01 each
{ up_to: 'inf', unit_amount_decimal: '0.5' }, // $0.005 each — use unit_amount_decimal for sub-cent
],
metadata: { plan: 'pro_tiered_api' },
});
```
> A `recurring.usage_type: 'metered'` price **without** `meter` falls back to the legacy
> subscription-item usage-record path (`createUsageRecord`), which is in maintenance mode for new
> integrations. Always set `meter` for new builds. The legacy path is documented in the appendix below.
### Best Practices for Products & Prices
- **Products = features/tiers.** Prices = billing variants (monthly, annual, per-seat).
- **Use `metadata`** extensively. Store your internal plan IDs, feature flags, rate limits.
- **Never delete prices.** Archive them with `active: false`. Existing subscriptions reference them.
- **Use lookup_keys** for stable references: `await stripe.prices.list({ lookup_keys: ['pro_monthly'] })`.
---
## Checkout Sessions
Checkout Sessions are the **correct** way to collect payment. Don't build custom forms unless you have a very good reason.
### Payment Mode (One-Time)
```js
const session = await stripe.checkout.sessions.create({
mode: 'payment',
customer: customerId, // optional: attach to existing customer
line_items: [
{
price: 'price_xxx',
quantity: 1,
},
],
success_url: `${BASE_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${BASE_URL}/billing/cancel`,
});
```
### Subscription Mode
```js
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [
{
price: 'price_pro_monthly',
quantity: 1,
},
],
subscription_data: {
trial_period_days: 14,
metadata: {
user_id: userId,
plan: 'pro',
},
},
success_url: `${BASE_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${BASE_URL}/pricing`,
allow_promotion_codes: true,
// ─── Stripe Tax (see "Stripe Tax" section below for full setup) ───
automatic_tax: { enabled: true }, // calculate & collect tax automatically
billing_address_collection: 'required', // 'required' so Tax always has a location
customer_update: { address: 'auto', name: 'auto' }, // persist collected address onto the Customer
tax_id_collection: { enabled: true }, // collect B2B VAT/GST IDs (enables reverse-charge)
});
```
### Hybrid Subscription (Base + Metered)
```js
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [
{
price: 'price_base_monthly', // $49/month flat
quantity: 1,
},
{
price: 'price_api_metered', // usage-based
// no quantity for metered prices
},
],
success_url: `${BASE_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${BASE_URL}/pricing`,
});
```
### Success URL: Retrieving the Session
**Critical:** `{CHECKOUT_SESSION_ID}` is a Stripe template literal — Stripe replaces it with the real session ID at redirect time.
```js
// GET /billing/success?session_id=cs_test_xxx
app.get('/billing/success', async (req, res) => {
const { session_id } = req.query;
if (!session_id) {
return res.redirect('/pricing');
}
const session = await stripe.checkout.sessions.retrieve(session_id, {
expand: ['subscription', 'customer'],
});
// Show confirmation page — but DO NOT provision here.
// Provision in the webhook handler (checkout.session.completed).
// The success page is just a "thank you" screen.
res.render('billing-success', {
customerEmail: session.customer_details?.email || session.customer_email,
planName: session.subscription?.metadata?.plan || 'Pro',
});
});
```
**Never provision access on the success URL.** Users can navigate away, close the tab, or the redirect can fail. Always provision in webhooks.
---
## Stripe Tax
Stripe Tax automatically calculates and collects sales tax, VAT, and GST based on the customer's location and your registrations. For SaaS, this is almost always preferable to hand-rolling tax — Stripe maintains rates and rules across jurisdictions.
### One-time account setup (Dashboard / API)
1. **Set your origin address** and enable Tax: Dashboard → **Tax** → Settings (or `POST /v1/tax/settings` with `defaults` + `head_office`). Tax stays in a non-collecting "preview" state until origin + a registration exist.
2. **Add registrations** for every jurisdiction where you have nexus/obligation. Stripe only *collects* tax where you are registered; everywhere else it returns a zero-rate "not registered" line, not an error.
```js
// Register to collect in a jurisdiction (do this per state/country where you have nexus)
await stripe.tax.registrations.create({
country: 'US',
country_options: {
us: { state: 'CA', type: 'state_sales_tax' }, // e.g. California state sales tax
},
active_from: 'now',
});
// EU example (one-stop-shop style country registration)
await stripe.tax.registrations.create({
country: 'DE',
country_options: { de: { type: 'standard' } },
active_from: 'now',
});
// List what you're currently registered to collect
const regs = await stripe.tax.registrations.list({ status: 'active' });
```
> **Nexus is a legal/accounting determination, not a Stripe feature.** Where you must register depends on revenue/transaction thresholds per jurisdiction (e.g. US economic-nexus thresholds, EU OSS). This is tax advice — confirm registrations with a tax professional or accountant. Stripe will not register for you, and collecting tax you aren't registered for can create liability. Rates/thresholds change; verify at https://docs.stripe.com/tax.
### Tax behavior & tax codes on Products/Prices
Two settings drive correct calculation:
- **`tax_behavior`** on the Price — whether `unit_amount` is `inclusive` (tax baked into the displayed price, common in EU/UK) or `exclusive` (tax added on top, common in US). `unspecified` blocks `automatic_tax` from finalizing.
- **`tax_code`** on the Product — Stripe's product tax category (a `txcd_...` code). SaaS commonly uses `txcd_10103001` (Software as a service — B2B) or `txcd_10103000` (SaaS — general); downloadable software, e-books, and physical goods each have distinct codes. The wrong code means the wrong rate.
```js
const product = await stripe.products.create({
name: 'Pro Plan',
tax_code: 'txcd_10103001', // SaaS (B2B). Browse codes: stripe.taxCodes.list() or docs.
});
const price = await stripe.prices.create({
product: product.id,
unit_amount: 2900,
currency: 'usd',
recurring: { interval: 'month' },
tax_behavior: 'exclusive', // tax added on top of $29 (typical US SaaS)
});
// List available tax codes to find the right txcd_ for your product
const codes = await stripe.taxCodes.list({ limit: 50 });
```
> Tax-code identifiers (`txcd_...`) and their applicability change; **do not hardcode a code without verifying it** at https://docs.stripe.com/tax/tax-codes or via `stripe.taxCodes.list()`. As of Jun 2026 the SaaS codes above are current, but confirm for your product type.
### Enabling Tax in Checkout
```js
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [{ price: 'price_pro_monthly', quantity: 1 }],
automatic_tax: { enabled: true }, // turn on calculation
billing_address_collection: 'required', // Tax needs a location; 'required' is safest
customer_update: { address: 'auto', name: 'auto' }, // persist address onto the Customer
tax_id_collection: { enabled: true }, // collect B2B VAT/GST IDs → enables reverse-charge
success_url: `${BASE_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${BASE_URL}/pricing`,
});
```
- `automatic_tax` requires a determinable customer location. With Checkout, `billing_address_collection: 'required'` guarantees one; Stripe can also infer from a verified card / IP, but don't rely on that for finalizing invoices.
- `customer_update: { address: 'auto' }` is **mandatory** when you pass an existing `customer` and want the collected address saved back — otherwise tax recalculation on renewals has no address.
- **Reverse charge (B2B EU/UK):** when a business customer enters a valid VAT ID via `tax_id_collection`, intra-EU B2B sales are typically zero-rated with a reverse-charge note. Stripe handles the validation and invoice wording; you just enable collection.
### Tax on API-created subscriptions and one-off invoices
```js
// Subscription created directly via API
await stripe.subscriptions.create({
customer: customerId,
items: [{ price: 'price_pro_monthly' }],
automatic_tax: { enabled: true },
});
// One-off invoice
const invoice = await stripe.invoices.create({
customer: customerId,
automatic_tax: { enabled: true },
});
```
The Customer must have a valid `address` (or `tax.ip_address`) or Stripe cannot finalize a tax-enabled invoice — it will surface an error rather than guess.
### Testing tax
- Use a Checkout test address in a jurisdiction where you've added a (test-mode) registration — e.g. a California ZIP — and confirm a tax line appears.
- Confirm a non-registered jurisdiction yields a zero-rate "not registered" line, not a hard failure.
- Enter a valid EU VAT ID as a business customer and verify reverse-charge wording on the invoice.
- Inspect `invoice.total_tax_amounts` (and the `tax` breakdown) in the webhook payload to reconcile what was collected.
> Filing/remittance is **not** automatic on standard Tax. Stripe calculates and collects; remittance is handled via Stripe Tax filing/exports or your accountant. Treat collected tax as a liability you owe, not revenue.
---
## Adaptive Pricing (Local-Currency Checkout)
Adaptive Pricing lets Checkout present prices in the **buyer's local currency** with localized rounding, even though your Price is defined in a single base currency (e.g. USD). Stripe handles the FX conversion and settlement. It improves conversion for international buyers without you maintaining a Price per currency.
### Enabling it
Adaptive Pricing is primarily an **account/Dashboard setting** (Dashboard → Settings → Checkout and Payment Links → Adaptive Pricing), and applies to eligible Checkout Sessions automatically once enabled. Where the Session API exposes it, the surface looks like:
```js
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [{ price: 'price_pro_monthly', quantity: 1 }], // USD-based price
// Present localized currency to the buyer (account setting must also be on).
adaptive_pricing: { enabled: true },
success_url: `${BASE_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${BASE_URL}/pricing`,
});
```
> The exact API parameter surface and the list of supported buyer currencies/regions have shifted across releases. **As of Jun 2026, verify the current parameter name, eligibility, and enablement steps at https://docs.stripe.com/payments/checkout/adaptive-pricing before relying on it in code** — treat the account-level toggle as the source of truth and the API flag as advisory.
### Caveats & when NOT to use it
- **Don't combine with manual multi-currency Prices.** If you already maintain a Price per currency (`currency_options` / separate Prices), use those instead — mixing the two double-converts and confuses reporting.
- **FX and rounding** are Stripe-managed; you don't control the exact displayed amount, and presented amounts move with exchange rates. Don't advertise an exact foreign price you can't guarantee.
- **Reconciliation:** charges settle and report in your **settlement currency**; the buyer sees local. Your revenue analytics must reconcile on settlement currency, not the displayed amount, or MRR/ARR will look noisy.
- **Tax interaction:** local-currency display does not change *where* you owe tax — Stripe Tax still keys off the customer's location and your registrations (see above).
- **Not a substitute for true local pricing.** If you want deliberately different price points per market (psychological pricing, PPP discounts), use explicit per-currency Prices, not Adaptive Pricing's FX conversion.
---
## Subscription Lifecycle
### Creating a Customer
```js
async function getOrCreateStripeCustomer(user) {
if (user.stripe_customer_id) {
return user.stripe_customer_id;
}
const customer = await stripe.customers.create({
email: user.email,
name: user.name,
metadata: {
user_id: user.id,
},
});
await db.query(
'UPDATE users SET stripe_customer_id = $1 WHERE id = $2',
[customer.id, user.id]
);
return customer.id;
}
```
### Trials
```js
// Via Checkout Session
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [{ price: priceId, quantity: 1 }],
subscription_data: {
trial_period_days: 14,
},
// Collect payment method upfront (card saved, charged after trial)
payment_method_collection: 'always',
success_url: `${BASE_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${BASE_URL}/pricing`,
});
// Via API directly
const subscription = await stripe.subscriptions.create({
customer: customerId,
items: [{ price: priceId }],
trial_period_days: 14,
payment_behavior: 'default_incomplete',
expand: ['latest_invoice.payment_intent'],
});
```
### Upgrade / Downgrade (Plan Changes)
> **Never assume `items.data[0]` is "the plan".** Hybrid subscriptions (base +
> metered) have multiple items, and Stripe does not guarantee their order.
> Targeting the wrong item silently changes the metered item instead of the base
> price (or vice versa). Identify items explicitly — by `price.lookup_key`,
> product metadata, or a stored subscription-item id.
```js
// Resolve a specific subscription item by lookup_key (preferred) or by a
// predicate over its price/product. Falls back to throwing rather than guessing.
function findSubscriptionItem(subscription, { lookupKey, match } = {}) {
const items = subscription.items.data;
const found = items.find((it) =>
(lookupKey && it.price.lookup_key === lookupKey) ||
(match && match(it))
);
if (!found) {
throw new Error(
`No subscription item matched ${lookupKey ?? 'predicate'} ` +
`on ${subscription.id} (has ${items.length} item(s))`
);
}
return found;
}
// Change the BASE plan item only, leaving any metered item untouched.
async function changePlan(subscriptionId, newPriceId, { prorate = true, lookupKey } = {}) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
// Pick the base item explicitly. If the sub has exactly one item, that's it;
// otherwise require a lookupKey (or a metadata match) to be unambiguous.
const target = subscription.items.data.length === 1
? subscription.items.data[0]
: findSubscriptionItem(subscription, {
lookupKey,
match: (it) => it.price.recurring?.usage_type !== 'metered', // the flat/base item
});
return stripe.subscriptions.update(subscriptionId, {
items: [{ id: target.id, price: newPriceId }],
proration_behavior: prorate ? 'create_prorations' : 'none',
// For period-end downgrades, prefer a Subscription Schedule (below) — calling
// update() with proration_behavior: 'none' switches the price object NOW
// (no immediate proration, but the new price is on the subscription already).
});
}
// Upgrade immediately with proration (single-item sub)
await changePlan(subId, 'price_enterprise_monthly', { prorate: true });
// Hybrid sub: name the base item so the metered item isn't touched
await changePlan(subId, 'price_enterprise_monthly', { prorate: true, lookupKey: 'base_plan' });
// Downgrade at period end — use Subscription Schedules to defer the change.
// Simply calling subscriptions.update() with proration_behavior: 'none'
// still switches the price immediately (billing changes at next cycle, but
// the price object on the subscription changes right away).
async function downgradeAtPeriodEnd(subscriptionId, newPriceId) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
// Create a schedule from the existing subscription
const schedule = await stripe.subscriptionSchedules.create({
from_subscription: subscriptionId,
});
// Update the schedule: keep current phase, add new phase at period end.
// IMPORTANT: Use 'now' for start_date of the first phase, not
// subscription.current_period_start — that timestamp is in the past,
// and Stripe rejects past start_date values.
await stripe.subscriptionSchedules.update(schedule.id, {
end_behavior: 'release',
phases: [
{
items: [{ price: subscription.items.data[0].price.id, quantity: 1 }],
start_date: 'now',
end_date: subscription.current_period_end,
},
{
items: [{ price: newPriceId, quantity: 1 }],
start_date: subscription.current_period_end,
iterations: 1,
},
],
});
}
```
### Seat Changes
```js
// Update quantity on the per-seat item. Pass the seat item's lookup_key so this
// works on hybrid/multi-item subscriptions (metered items have no quantity).
async function updateSeats(subscriptionId, newQuantity, { lookupKey = 'per_seat' } = {}) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const seatItem = subscription.items.data.length === 1
? subscription.items.data[0]
: findSubscriptionItem(subscription, { lookupKey });
return stripe.subscriptionItems.update(seatItem.id, {
quantity: newQuantity,
proration_behavior: 'create_prorations',
});
}
```
### Cancellation
```js
// Cancel at period end (recommended — user keeps access until paid period expires)
async function cancelAtPeriodEnd(subscriptionId) {
return stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
});
}
// Cancel immediately (rare — refund / abuse scenarios)
async function cancelImmediately(subscriptionId) {
// `subscriptions.cancel` (DELETE) ends the subscription NOW. Its supported
// options are `invoice_now` and `prorate` — NOT `proration_behavior`
// (that belongs to subscriptions.update). Passing proration_behavior here
// is ignored/invalid depending on API version.
return stripe.subscriptions.cancel(subscriptionId, {
invoice_now: true, // finalize any pending metered usage into a final invoice
prorate: true, // credit unused time as a proration on that final invoice
});
// `prorate: true`/`invoice_now: true` are the cancel-time flags. Immediate
// cancellation does NOT auto-refund the customer — issue a refund or credit
// note separately if you owe money back:
// await stripe.refunds.create({ payment_intent: '<pi_...>' });
}
// Alternative: schedule a hard cancel at a specific future timestamp without
// ending access now. Use cancel_at (a Unix timestamp) on update:
async function cancelAt(subscriptionId, unixTs) {
return stripe.subscriptions.update(subscriptionId, {
cancel_at: unixTs,
proration_behavior: 'none', // proration_behavior IS valid on update
});
}
// Reactivate before period end
async function reactivateSubscription(subscriptionId) {
return stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: false,
});
}
```
### Pausing Subscriptions
Stripe supports pausing via `pause_collection`:
```js
// Pause — stop invoicing, keep subscription active
async function pauseSubscription(subscriptionId) {
return stripe.subscriptions.update(subscriptionId, {
pause_collection: {
behavior: 'void', // 'void' = skip invoices, 'keep_as_draft' = draft them
// resumes_at: Math.floor(Date.now() / 1000) + 30 * 86400, // optional auto-resume
},
});
}
// Resume — set pause_collection to null (not empty string) to clear the pause
async function resumeSubscription(subscriptionId) {
return stripe.subscriptions.update(subscriptionId, {
pause_collection: null,
});
}
```
**Decision:** Should paused users keep access? Usually no — revoke API keys / feature access on pause, restore on resume. Handle this in your webhook for `customer.subscription.updated`.
---
## Webhook Handling
This is the most critical section. **Get this wrong and you'll lose money, break provisioning, or create security holes.**
### The #1 Rule: Raw Body BEFORE express.json()
Stripe webhook signature verification requires the **raw request body**. If `express.json()` parses it first, the signature check will **always fail**.
```js
const express = require('express');
// Always pin your API version — see "Stripe Client Initialization" above.
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2025-09-30.clover',
});
const app = express();
// ┌─────────────────────────────────────────────────────────┐
// │ WEBHOOK ROUTE MUST BE REGISTERED BEFORE express.json() │
// └─────────────────────────────────────────────────────────┘
// Option A: Register webhook route with raw body parser FIRST
app.post(
'/webhooks/stripe',
express.raw({ type: 'application/json' }),
handleStripeWebhook
);
// THEN apply JSON parsing to everything else
app.use(express.json());
// Option B: If you can't control route order, use a custom verify function
// app.use(express.json({
// verify: (req, res, buf) => {
// if (req.originalUrl === '/webhooks/stripe') {
// req.rawBody = buf;
// }
// },
// }));
```
### Signature Verification
```js
async function handleStripeWebhook(req, res) {
const sig = req.headers['stripe-signature'];
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
let event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
} catch (err) {
console.error(`Webhook signature verification failed: ${err.message}`);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Process the event BEFORE responding — if you respond 200 first and
// processing fails, Stripe won't retry and the event is silently lost.
try {
await processWebhookEvent(event);
res.status(200).json({ received: true });
} catch (err) {
console.error(`Error processing webhook ${event.id}: ${err.message}`);
res.status(500).json({ error: 'Processing failed' });
// Stripe will retry on non-2xx responses
}
}
```
### Idempotency
Stripe may send the same event **multiple times**. Your handler MUST be idempotent.
```js
async function processWebhookEvent(event) {
// Atomically insert-or-skip to avoid TOCTOU race between SELECT and INSERT.
// If two identical events arrive concurrently, only one will proceed.
const result = await db.query(
`INSERT INTO processed_events (stripe_event_id, event_type, processed_at)
VALUES ($1, $2, NOW())
ON CONFLICT (stripe_event_id) DO NOTHING
RETURNING id`,
[event.id, event.type]
);
if (result.rows.length === 0) {
console.log(`Event ${event.id} already processed, skipping.`);
return;
}
// Process the event
await handleEvent(event);
}
```
**Database schema for idempotency:**
```sql
CREATE TABLE processed_events (
id SERIAL PRIMARY KEY,
stripe_event_id VARCHAR(255) UNIQUE NOT NULL,
event_type VARCHAR(100) NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Clean up old events periodically (keep 90 days)
CREATE INDEX idx_processed_events_date ON processed_events (processed_at);
```
### Essential Webhook Events
```js
async function handleEvent(event) {
const data = event.data.object;
switch (event.type) {
// ─── Checkout ──────────────────────────────────────────
case 'checkout.session.completed': {
await handleCheckoutCompleted(data);
break;
}
// ─── Subscription Lifecycle ────────────────────────────
case 'customer.subscription.created': {
await handleSubscriptionCreated(data);
break;
}
case 'customer.subscription.updated': {
// previous_attributes lives on event.data, NOT on event.data.object.
// Pass it as a second argument so the handler can detect what changed.
await handleSubscriptionUpdated(data, event.data.previous_attributes || {});
break;
}
case 'customer.subscription.deleted': {
await handleSubscriptionDeleted(data);
break;
}
// ─── Invoices & Payments ───────────────────────────────
case 'invoice.payment_succeeded': {
await handleInvoicePaymentSucceeded(data);
break;
}
case 'invoice.payment_failed': {
await handleInvoicePaymentFailed(data);
break;
}
// ─── Optional but Recommended ──────────────────────────
case 'customer.subscription.trial_will_end': {
// Fires 3 days before trial ends — send reminder email
await handleTrialEnding(data);
break;
}
case 'invoice.upcoming': {
// Fires ~3 days before next invoice — good for usage summary emails
await handleUpcomingInvoice(data);
break;
}
default:
console.log(`Unhandled event type: ${event.type}`);
}
}
```
### Event Handlers — Complete Implementations
```js
// ─── checkout.session.completed ────────────────────────────
// This is your PRIMARY provisioning trigger.
async function handleCheckoutCompleted(session) {
if (session.mode === 'subscription') {
const subscription = await stripe.subscriptions.retrieve(
session.subscription,
{ expand: ['items.data.price.product'] }
);
const customerId = session.customer;
const userId = session.metadata?.user_id
|| subscription.metadata?.user_id;
if (!userId) {
console.error('No user_id in checkout session metadata!');
return;
}
// Resolve the tier from the BASE (non-metered) item, not blindly data[0] —
// a hybrid sub also has a metered item whose product carries no tier.
const baseItem = subscription.items.data.length === 1
? subscription.items.data[0]
: subscription.items.data.find((it) => it.price.recurring?.usage_type !== 'metered')
|| subscription.items.data[0];
const tier = baseItem.price.product?.metadata?.tier || 'pro';
// Provision access
await db.query(
`UPDATE users SET
stripe_customer_id = $1,
stripe_subscription_id = $2,
plan = $3,
subscription_status = $4,
current_period_end = to_timestamp($5)
WHERE id = $6`,
[
customerId,
subscription.id,
tier,
subscription.status,
subscription.current_period_end,
userId,
]
);
// Generate API key if this is a new subscription
await provisionApiKey(userId);
console.log(`Provisioned subscription for user ${userId}`);
}
if (session.mode === 'payment') {
// One-time payment — fulfill the order
const userId = session.metadata?.user_id;
await fulfillOneTimePayment(userId, session);
}
}
// ─── customer.subscription.created ─────────────────────────
async function handleSubscriptionCreated(subscription) {
// Often redundant with checkout.session.completed,
// but useful for subscriptions created via API (not Checkout).
const userId = await getUserByCustomerId(subscription.customer);
if (!userId) return;
await db.query(
`UPDATE users SET
stripe_subscription_id = $1,
subscription_status = $2,
current_period_end = to_timestamp($3)
WHERE id = $4`,
[subscription.id, subscription.status, subscription.current_period_end, userId]
);
}
// ─── customer.subscription.updated ─────────────────────────
// Fires on: plan change, status change, trial end, pause, resume, etc.
// NOTE: This handler receives both the subscription object AND previousAttributes
// because previous_attributes lives on event.data, not on the object itself.
// The caller (handleEvent) must pass it separately — see below.
async function handleSubscriptionUpdated(subscription, previousAttributes = {}) {
const userId = await getUserByCustomerId(subscription.customer);
if (!userId) return;
// Detect plan change. The webhook payload's price.product is usually just a
// STRING id (not expanded), so re-fetch with expansion to read real metadata
// and resolve the base (non-metered) item rather than blindly using data[0].
let newTier = null;
if (previousAttributes.items) {
const full = await stripe.subscriptions.retrieve(subscription.id, {
expand: ['items.data.price.product'],
});
const baseItem = full.items.data.length === 1
? full.items.data[0]
: full.items.data.find((it) => it.price.recurring?.usage_type !== 'metered')
|| full.items.data[0];
newTier = baseItem.price.product?.metadata?.tier || null;
console.log(`User ${userId} changed plan; tier=${newTier ?? 'unknown'}`);
}
// Detect cancellation scheduled
if (subscription.cancel_at_period_end) {
console.log(`User ${userId} scheduled cancellation`);
// Send retention email, show reactivation option
}
// Detect pause
if (subscription.pause_collection) {
console.log(`User ${userId} paused subscription`);
await revokeApiKey(userId);
} else if (previousAttributes.pause_collection) {
console.log(`User ${userId} resumed subscription`);
await provisionApiKey(userId);
}
// Always update local state. Use COALESCE so a NULL plan (this update wasn't a
// plan change, or metadata was absent) does NOT erase the stored plan.
await db.query(
`UPDATE users SET
subscription_status = $1,
current_period_end = to_timestamp($2),
plan = COALESCE($3, plan),
cancel_at_period_end = $4
WHERE stripe_customer_id = $5`,
[
subscription.status,
subscription.current_period_end,
newTier || subscription.metadata?.plan || null,
subscription.cancel_at_period_end,
subscription.customer,
]
);
}
// ─── customer.subscription.deleted ─────────────────────────
// Subscription is fully cancelled / ended.
async function handleSubscriptionDeleted(subscription) {
const userId = await getUserByCustomerId(subscription.customer);
if (!userId) return;
// Revoke all access
await db.query(
`UPDATE users SET
subscription_status = 'canceled',
plan = 'free',
stripe_subscription_id = NULL
WHERE id = $1`,
[userId]
);
// Revoke API keys
await revokeApiKey(userId);
console.log(`Subscription deleted for user ${userId}, access revoked.`);
}
// ─── invoice.payment_succeeded ─────────────────────────────
// Fires on every successful payment (initial + renewals).
async function handleInvoicePaymentSucceeded(invoice) {
// Only process renewal invoices. Skip initial creation (handled by
// checkout.session.completed) and other non-cycle reasons like
// subscription_update, subscription_threshold, manual, etc.
if (invoice.billing_reason !== 'subscription_cycle') {
return;
}
// Renewal payment — extend access
const userId = await getUserByCustomerId(invoice.customer);
if (!userId) return;
const subscription = await stripe.subscriptions.retrieve(invoice.subscription);
await db.query(
`UPDATE users SET
subscription_status = 'active',
current_period_end = to_timestamp($1),
failed_payment_count = 0
WHERE id = $2`,
[subscription.current_period_end, userId]
);
console.log(`Renewal payment succeeded for user ${userId}`);
}
// ─── invoice.payment_failed ────────────────────────────────
async function handleInvoicePaymentFailed(invoice) {
const userId = await getUserByCustomerId(invoice.customer);
if (!userId) return;
const attemptCount = invoice.attempt_count;
await db.query(
`UPDATE users SET
subscription_status = 'past_due',
failed_payment_count = $1
WHERE id = $2`,
[attemptCount, userId]
);
// Send dunning email based on attempt count
if (attemptCount === 1) {
await sendEmail(userId, 'payment-failed-first', {
updatePaymentUrl: await createPortalSession(invoice.customer),
});
} else if (attemptCount === 2) {
await sendEmail(userId, 'payment-failed-second', {
updatePaymentUrl: await createPortalSession(invoice.customer),
daysUntilCancellation: 7,
});
} else if (attemptCount >= 3) {
await sendEmail(userId, 'payment-failed-final', {
updatePaymentUrl: await createPortalSession(invoice.customer),
});
// Consider revoking access at this point
}
console.log(`Payment failed (attempt ${attemptCount}) for user ${userId}`);
}
// ─── Helper: Resolve user from Stripe customer ID ─────────
async function getUserByCustomerId(stripeCustomerId) {
const result = await db.query(
'SELECT id FROM users WHERE stripe_customer_id = $1',
[stripeCustomerId]
);
return result.rows[0]?.id || null;
}
```
---
## API Key Provisioning
For SaaS products that expose an API, provision keys tied to the subscription lifecycle.
### Generating Secure API Keys
```js
const crypto = require('crypto');
// Generate a cryptographically secure API key.
// Use a PRODUCT-specific prefix (e.g. `myapp_live_`) — never `sk_`, which collides
// with Stripe secret keys (`sk_live_`/`sk_test_`) and confuses secret scanners.
function generateApiKey(prefix = 'myapp_live') {
const key = crypto.randomBytes(32).toString('hex'); // 64 hex chars
return `${prefix}_${key}`;
// Example: myapp_live_a1b2c3d4e5f6...
}
// Hash for storage (never store plaintext keys in your DB)
function hashApiKey(apiKey) {
return crypto.createHash('sha256').update(apiKey).digest('hex');
}
```
### Database Schema
```sql
CREATE TABLE api_keys (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
key_hash VARCHAR(64) NOT NULL UNIQUE,
key_prefix VARCHAR(24) NOT NULL, -- leading chars for display: "myapp_live_a1b2..."
name VARCHAR(100) DEFAULT 'Default',
scopes TEXT[] DEFAULT '{}',
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ
);
CREATE INDEX idx_api_keys_hash ON api_keys (key_hash) WHERE is_active = true;
CREATE INDEX idx_api_keys_user ON api_keys (user_id) WHERE is_active = true;
```
### Provisioning & Revocation
```js
async function provisionApiKey(userId) {
// Check if user already has an active key
const existing = await db.query(
'SELECT id FROM api_keys WHERE user_id = $1 AND is_active = true',
[userId]
);
if (existing.rows.length > 0) {
return; // Already has a key
}
const apiKey = generateApiKey(); // product-prefixed, e.g. myapp_live_...
const keyHash = hashApiKey(apiKey);
const keyPrefix = apiKey.substring(0, 18) + '...'; // store namespace + a few chars for display
await db.query(
`INSERT INTO api_keys (user_id, key_hash, key_prefix, name)
VALUES ($1, $2, $3, 'Default')`,
[userId, keyHash, keyPrefix]
);
// Send the key to the user (email, dashboard, etc.)
// This is the ONLY time the full key is visible.
await sendEmail(userId, 'api-key-provisioned', { apiKey, keyPrefix });
return apiKey;
}
async function revokeApiKey(userId) {
await db.query(
`UPDATE api_keys SET
is_active = false,
revoked_at = NOW()
WHERE user_id = $1 AND is_active = true`,
[userId]
);
}
// Validate an API key on incoming requests
async function validateApiKey(apiKey) {
const keyHash = hashApiKey(apiKey);
const result = await db.query(
`SELECT ak.id, ak.user_id, ak.scopes, u.plan, u.subscription_status
FROM api_keys ak
JOIN users u ON u.id = ak.user_id
WHERE ak.key_hash = $1
AND ak.is_active = true
AND (ak.expires_at IS NULL OR ak.expires_at > NOW())`,
[keyHash]
);
if (result.rows.length === 0) {
return null;
}
const keyData = result.rows[0];
// Check subscription is active
if (!['active', 'trialing'].includes(keyData.subscription_status)) {
return null;
}
// Update last_used_at (fire and forget)
db.query('UPDATE api_keys SET last_used_at = NOW() WHERE id = $1', [keyData.id]);
return keyData;
}
```
### API Key Authentication Middleware
```js
async function authenticateApiKey(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing API key' });
}
const apiKey = authHeader.substring(7);
const keyData = await validateApiKey(apiKey);
if (!keyData) {
return res.status(401).json({ error: 'Invalid or expired API key' });
}
req.userId = keyData.user_id;
req.plan = keyData.plan;
req.scopes = keyData.scopes;
next();
}
// Usage
app.get('/api/v1/data', authenticateApiKey, (req, res) => {
res.json({ userId: req.userId, plan: req.plan });
});
```
---
## Customer Portal
Let customers manage their own billing. Stripe's portal handles plan changes, payment methods, invoices, and cancellation.
### Configuration
```js
// Create portal configuration (do this once, store the ID)
const portalConfig = await stripe.billingPortal.configurations.create({
business_profile: {
headline: 'Manage your subscription',
privacy_policy_url: 'https://yourapp.com/privacy',
terms_of_service_url: 'https://yourapp.com/terms',
},
features: {
customer_update: {
enabled: true,
allowed_updates: ['email', 'address', 'tax_id'],
},
subscription_cancel: {
enabled: true,
mode: 'at_period_end',
cancellation_reason: {
enabled: true,
options: [
'too_expensive',
'missing_features',
'switched_service',
'unused',
'other',
],
},
},
subscription_update: {
enabled: true,
default_allowed_updates: ['price', 'quantity'],
proration_behavior: 'create_prorations',
products: [
{
product: 'prod_xxx',
prices: ['price_monthly', 'price_annual'],
},
],
},
payment_method_update: { enabled: true },
invoice_history: { enabled: true },
},
});
// Save portalConfig.id → STRIPE_PORTAL_CONFIG_ID
```
### Creating Portal Sessions
```js
app.post('/billing/portal', requireAuth, async (req, res) => {
const user = req.user;
if (!user.stripe_customer_id) {
return res.status(400).json({ error: 'No billing account found' });
}
const session = await stripe.billingPortal.sessions.create({
customer: user.stripe_customer_id,
return_url: `${BASE_URL}/dashboard/billing`,
configuration: process.env.STRIPE_PORTAL_CONFIG_ID, // optional
});
res.json({ url: session.url });
// Or redirect: res.redirect(303, session.url);
});
```
---
## Metered / Usage-Based Billing
> **Use Billing Meters for all new usage-based billing.** You send *meter events*
> keyed by `stripe_customer_id` (not subscription-item usage records); Stripe
> aggregates them against the meter that backs the price (see "Stripe Products &
> Prices" for creating the meter + meter-backed price). The legacy
> `subscriptionItems.createUsageRecord` path is in maintenance mode and is kept
> in the appendix at the end of this section only for existing integrations.
> Docs: https://docs.stripe.com/billing/subscriptions/usage-based
### Reporting Usage (Billing Meters — default)
```js
// Send a meter event. `event_name` MUST match the meter's event_name.
// `stripe_customer_id` is the aggregation key — NOT a subscription item id.
async function reportMeterEvent(customerId, value = 1, { eventName = 'api_request', timestamp, identifier } = {}) {
return stripe.billing.meterEvents.create({
event_name: eventName,
// `identifier` makes the event idempotent — Stripe de-dupes events that
// share the same identifier, so a retry after a network blip won't double-bill.
identifier, // e.g. a request id / ULID
timestamp, // Unix seconds; omit = "now". Most
// meters reject events older than ~35 days.
payload: {
stripe_customer_id: customerId, // required aggregation key
value: String(value), // payload values are strings
},
});
}
// Example: report API usage after each request (fire-and-forget, never block the response)
app.use('/api/v1', authenticateApiKey, async (req, res, next) => {
res.on('finish', () => {
// Resolve the Stripe customer id for this user (cache it on req in auth middleware
// to avoid a DB hit per request).
const customerId = req.stripeCustomerId;
if (!customerId) return;
reportMeterEvent(customerId, 1, {
identifier: req.id, // unique per request → idempotent
}).catch((err) => {
console.error('Failed to report meter event:', err.message);
enqueueUsageRetry({ customerId, value: 1, identifier: req.id }); // durable retry, see below
});
});
next();
});
```
> **Reporting !== invoicing.** Meter events feed an aggregated total that Stripe
> bills at the period boundary. There is no per-event charge, so emitting events
> is cheap — but it is also eventually-consistent, so don't read meter totals to
> enforce hard real-time quotas (use your own counter for that; see
> "Usage Limits" below).
### Batched / High-Volume Usage Reporting
At high request rates, prefer a **durable queue** (Redis Stream, SQS, Postgres
`outbox` table) over an in-memory accumulator — a process restart must not lose
billable usage. The aggregation key is the **customer**, and each batched event
should carry a stable `identifier` so retries stay idempotent.
```js
// Aggregate in-memory only as a write-coalescing buffer in FRONT of a durable
// queue. On every flush, generate ONE identifier per (customer, window) so a
// retried flush de-dupes instead of double-billing.
class UsageAccumulator {
constructor(flushIntervalMs = 60_000, { eventName = 'api_request' } = {}) {
this.counters = new Map(); // stripeCustomerId → count
this.eventName = eventName;
this.interval = setInterval(() => this.flush().catch(console.error), flushIntervalMs);
}
increment(customerId, amount = 1) {
this.counters.set(customerId, (this.counters.get(customerId) || 0) + amount);
}
async flush() {
const windowId = Math.floor(Date.now() / 60_000); // 1-min bucket → stable id
const entries = [...this.counters.entries()];
this.counters.clear();
for (const [customerId, value] of entries) {
if (value === 0) continue;
try {
await stripe.billing.meterEvents.create({
event_name: this.eventName,
identifier: `${customerId}:${windowId}`, // idempotent per customer per minute
payload: { stripe_customer_id: customerId, value: String(value) },
});
} catch (err) {
console.error(`Failed to report usage for ${customerId}:`, err.message);
// Re-buffer for the next flush (still de-duped by the windowId identifier).
this.counters.set(customerId, (this.counters.get(customerId) || 0) + value);
}
}
}
async shutdown() {
clearInterval(this.interval);
await this.flush(); // flush remaining buffer on SIGTERM so usage isn't lost
}
}
const usageTracker = new UsageAccumulator(60_000); // flush every 60s
process.on('SIGTERM', async () => { await usageTracker.shutdown(); process.exit(0); });
```
> **Caveat on `${customerId}:${windowId}` identifiers:** within a single window
> you must coalesce to exactly one event per customer (as above). If you instead
> emit multiple events per window, give each a unique identifier — reusing one
> identifier for different values means Stripe keeps only the first.
### Legacy appendix — `createUsageRecord` (maintenance mode, existing integrations only)
Only for subscriptions on **legacy metered prices created without a `meter`**.
Do not use for new builds.
```js
// LEGACY — keyed by subscription ITEM id, not customer. Prefer meter events above.
async function reportUsageLegacy(subscriptionItemId, quantity, timestamp = null) {
return stripe.subscriptionItems.createUsageRecord(subscriptionItemId, {
quantity,
timestamp: timestamp || Math.floor(Date.now() / 1000),
action: 'increment', // 'increment' adds to the period total; 'set' overwrites it
});
}
```
### Usage Limits & Rate Limiting Per Plan
```js
const PLAN_LIMITS = {
free: { monthly_api_calls: 100, rpm: 10 },
starter: { monthly_api_calls: 10_000, rpm: 60 },
pro: { monthly_api_calls: 100_000, rpm: 300 },
enterprise: { monthly_api_calls: Infinity, rpm: 1000 },
};
async function checkUsageLimit(userId, plan) {
const limits = PLAN_LIMITS[plan];
if (!limits) return false;
const result = await db.query(
`SELECT COUNT(*) as count FROM api_usage_log
WHERE user_id = $1
AND created_at >= date_trunc('month', NOW())`,
[userId]
);
const used = parseInt(result.rows[0].count);
return used < limits.monthly_api_calls;
}
```
---
## Dunning & Failed Payments
Dunning is the process of recovering failed payments. Stripe has Smart Retries built in, but you should also act on your side.
### Stripe Smart Retries Configuration
Configure in Stripe Dashboard → Settings → Billing → Subscription and emails:
- **Retry schedule:** Stripe retries 3-4 times over ~3 weeks by default
- **Customer emails:** Enable Stripe's built-in failed payment emails
- **Subscription status:** Moves from `active` → `past_due` → `unpaid` → `canceled`
### Your Dunning Logic
```js
// In your subscription status check middleware
async function requireActiveSubscription(req, res, next) {
const user = req.user;
switch (user.subscription_status) {
case 'active':
case 'trialing':
return next();
case 'past_due':
// Grace period — allow limited access but show warning
req.pastDue = true;
return next();
case 'unpaid':
case 'canceled':
return res.status(402).json({
error: 'subscription_required',
message: 'Your subscription has expired. Please update your payment method.',
portal_url: '/billing/portal',
});
default:
return res.status(403).json({ error: 'Unknown subscription status' });
}
}
```
### Grace Periods
```js
// Allow X days of access after payment failure before hard cutoff
const GRACE_PERIOD_DAYS = 7;
function isInGracePeriod(user) {
if (user.subscription_status !== 'past_due') return false;
const firstFailedAt = user.first_failed_payment_at;
if (!firstFailedAt) return true; // just failed, still in grace
const gracePeriodEnd = new Date(firstFailedAt);
gracePeriodEnd.setDate(gracePeriodEnd.getDate() + GRACE_PERIOD_DAYS);
return new Date() < gracePeriodEnd;
}
```
---
## Security
### Webhook Signature Verification (Mandatory)
Already covered above. **Never skip this.** Without it, anyone can POST fake events to your webhook endpoint.
### Timing-Safe Comparison for API Keys
```js
const crypto = require('crypto');
// WRONG — vulnerable to timing attacks
// if (providedKey === storedKey) { ... }
// RIGHT — constant-time comparison
function secureCompare(a, b) {
if (typeof a !== 'string' || typeof b !== 'string') return false;
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) return false;
return crypto.timingSafeEqual(bufA, bufB);
}
// For hashed keys (what you should actually do):
// Hash the incoming key, then compare hashes. SHA-256 is fixed-length,
// so timingSafeEqual works perfectly.
function validateKeyHash(providedKey, storedHash) {
const providedHash = crypto.createHash('sha256').update(providedKey).digest('hex');
return secureCompare(providedHash, storedHash);
}
```
### Rate Limiting
```js
const rateLimit = require('express-rate-limit');
// Global rate limit
const globalLimiter = rateLimit({
windowMs: 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests' },
});
// Per-plan rate limit — pre-create one limiter per plan to avoid
// creating a new rateLimit instance on every request (which resets
// the window each time, making it nonfunctional).
const planLimiters = Object.fromEntries(
Object.entries(PLAN_LIMITS).map(([plan, limits]) => [
plan,
rateLimit({
windowMs: 60 * 1000,
max: limits.rpm,
keyGenerator: (req) => req.userId,
standardHeaders: true,
message: {
error: 'rate_limit_exceeded',
limit: limits.rpm,
window: '1m',
},
}),
])
);
function planRateLimiter(req, res, next) {
const limiter = planLimiters[req.plan];
if (!limiter) return res.status(403).json({ error: 'No plan' });
return limiter(req, res, next);
}
// ⚠️ Do NOT rate-limit the Stripe webhook endpoint by request volume before
// verifying the signature. Stripe legitimately bursts events (backfills,
// migrations, incident recovery) and a 429 just triggers retries, growing a
// backlog and risking dropped events past Stripe's retry window.
//
// Instead: (1) verify the signature first — that IS your authentication and
// rejects forged/replayed payloads; (2) keep the handler cheap by enqueuing
// work and returning 200 fast; (3) protect the box with a generous infra-level
// connection/QPS cap (LB/WAF), not an app-level per-window cap that drops valid
// events. If you must cap in-app, cap AFTER verification and only on bodies that
// fail signature checks (i.e. throttle attackers, never Stripe).
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), handleStripeWebhook);
```
### Secure Key Storage
- **Never log full API keys.** Log only the prefix (`myapp_live_a1b2...`).
- **Never store plaintext keys.** Always hash with SHA-256.
- **Rotate webhook secrets** periodically via Stripe Dashboard.
- **Use separate restricted API keys** for different services (read-only for analytics, write for billing).
---
## Testing
### Test Mode
Stripe provides a full parallel test environment. Your test API keys (`sk_test_...`) hit the test environment.
```js
// Detect test mode
const isTestMode = process.env.STRIPE_SECRET_KEY.startsWith('sk_test_');
```
### Test Cards
| Card Number | Scenario |
| -------------------- | --------------------------------- |
| `4242 4242 4242 4242` | Success |
| `4000 0000 0000 3220` | 3D Secure required |
| `4000 0000 0000 9995` | Payment fails (insufficient funds)|
| `4000 0000 0000 0341` | Attaching fails |
| `4000 0025 0000 3155` | Requires authentication on all txns |
| `4000 0000 0000 0002` | Card declined |
**Expiry:** Any future date. **CVC:** Any 3 digits. **ZIP:** Any valid format.
### Stripe CLI for Local Webhook Testing
```bash
# Install
brew install stripe/stripe-cli/stripe
# Login
stripe login
# Forward webhooks to local server
stripe listen --forward-to localhost:3000/webhooks/stripe
# The CLI prints a webhook signing secret (whsec_...) — use it locally
# > Ready! Your webhook signing secret is whsec_xxx
# Trigger specific events
stripe trigger checkout.session.completed
stripe trigger invoice.payment_failed
stripe trigger customer.subscription.updated
# Trigger with custom data
stripe trigger checkout.session.completed \
--override checkout_session:metadata.user_id=test_123
```
### Integration Test Example
```js
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert');
describe('Billing Integration', () => {
let testCustomerId;
let testSubscriptionId;
before(async () => {
// Create test customer
// Create customer with a PaymentMethod (source/tok_visa is legacy)
const pm = await stripe.paymentMethods.create({
type: 'card',
card: { token: 'tok_visa' },
});
const customer = await stripe.customers.create({
email: 'test@example.com',
payment_method: pm.id,
invoice_settings: { default_payment_method: pm.id },
});
testCustomerId = customer.id;
});
after(async () => {
// Cleanup
if (testSubscriptionId) {
await stripe.subscriptions.cancel(testSubscriptionId);
}
if (testCustomerId) {
await stripe.customers.del(testCustomerId);
}
});
it('should create a subscription', async () => {
const subscription = await stripe.subscriptions.create({
customer: testCustomerId,
items: [{ price: 'price_test_monthly' }],
});
testSubscriptionId = subscription.id;
assert.strictEqual(subscription.status, 'active');
assert.strictEqual(subscription.items.data.length, 1);
});
it('should upgrade a subscription', async () => {
const subscription = await stripe.subscriptions.retrieve(testSubscriptionId);
const updated = await stripe.subscriptions.update(testSubscriptionId, {
items: [{
id: subscription.items.data[0].id,
price: 'price_test_annual',
}],
});
assert.strictEqual(updated.items.data[0].price.id, 'price_test_annual');
});
it('should cancel at period end', async () => {
const updated = await stripe.subscriptions.update(testSubscriptionId, {
cancel_at_period_end: true,
});
assert.strictEqual(updated.cancel_at_period_end, true);
assert.strictEqual(updated.status, 'active'); // still active until period end
});
});
```
### Testing Webhooks Programmatically
```js
const crypto = require('crypto');
function generateTestWebhookEvent(payload, secret) {
const timestamp = Math.floor(Date.now() / 1000);
const payloadString = JSON.stringify(payload);
const signedPayload = `${timestamp}.${payloadString}`;
const signature = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
return {
body: payloadString,
headers: {
'stripe-signature': `t=${timestamp},v1=${signature}`,
},
};
}
```
---
## Common Mistakes
### 1. Parsing JSON Before Webhooks
**Wrong:**
```js
app.use(express.json()); // This parses ALL requests including webhooks
app.post('/webhooks/stripe', handleWebhook); // Signature verification WILL FAIL
```
**Right:**
```js
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), handleWebhook);
app.use(express.json()); // After webhook route
```
### 2. Provisioning on Success URL Instead of Webhooks
**Wrong:**
```js
app.get('/success', async (req, res) => {
await activateSubscription(req.query.session_id); // User closes tab = no provisioning
});
```
**Right:** Provision in `checkout.session.completed` webhook. Success URL is just a thank-you page.
### 3. Not Handling Idempotency
**Wrong:**
```js
case 'checkout.session.completed':
await createAccount(data); // Duplicate event = duplicate account!
```
**Right:** Check `processed_events` table before acting. Use `INSERT ... ON CONFLICT DO NOTHING` or similar.
### 4. Storing API Keys in Plaintext
**Wrong:**
```sql
INSERT INTO api_keys (key) VALUES ('sk_live_actual_key_here');
```
**Right:** Store SHA-256 hash. Show the key once at creation. User must regenerate if lost.
### 5. Not Pinning Stripe API Version
**Wrong:**
```js
const stripe = require('stripe')(key); // Uses latest version — may break unexpectedly
```
**Right:**
```js
const stripe = require('stripe')(key, { apiVersion: '2025-09-30.clover' });
```
### 6. Ignoring `past_due` Status
If a payment fails, the subscription goes `past_due`. Many apps only check for `active` and immediately cut off access. This frustrates customers who just have an expired card.
**Right:** Implement grace periods. Send dunning emails. Give them time to update payment info.
### 7. Not Expanding Objects in Webhook Handlers
```js
// The webhook event only contains IDs, not full objects
// If you need product metadata, retrieve with expand:
const subscription = await stripe.subscriptions.retrieve(data.id, {
expand: ['items.data.price.product'],
});
```
### 8. Hardcoding Price IDs
**Wrong:**
```js
const PRICE_ID = 'price_1234567890'; // Breaks between test/live, fragile
```
**Right:** Use environment variables, lookup keys, or metadata:
```js
const prices = await stripe.prices.list({
lookup_keys: ['pro_monthly'],
limit: 1,
});
const priceId = prices.data[0].id;
```
### 9. Not Handling Trial Expiration
Trials end and `customer.subscription.updated` fires with `status: 'active'` (if payment succeeds) or `status: 'past_due'` (if it fails). Many devs forget to handle the failure case, leaving trialing users with indefinite free access.
### 10. Race Conditions Between Webhooks
Stripe doesn't guarantee event ordering. You might receive `customer.subscription.updated` before `checkout.session.completed`. Design handlers to be independent and idempotent.
---
## Complete Express.js Server Example
Putting it all together — a **runnable end-to-end demo**. It wires up every flow above, but it deliberately uses in-memory `Map`/`Set` stores so you can run it without a database. **This is not production-safe as written:** restarting the process drops all idempotency records and billing state, so duplicate webhooks would re-provision and re-bill. For production, replace the in-memory stores with the Postgres schema and transactional handlers shown earlier (`users`, `api_keys`, `processed_events`), and follow this webhook architecture:
1. **Verify** the Stripe signature (authentication).
2. **Persist** the event id (`INSERT ... ON CONFLICT DO NOTHING`) to dedupe.
3. **Enqueue** the work (durable queue / outbox) and return `200` fast.
4. **Process idempotently** in a worker; **re-fetch** the current Stripe object (`subscriptions.retrieve`, etc.) as the source of truth rather than trusting possibly-stale or out-of-order payload fields.
5. **Reconcile** periodically — list recent Stripe events / objects and repair any your handler missed (Stripe only retries for a limited window).
```js
// server.js — Complete SaaS Billing DEMO (in-memory stores; swap for Postgres in prod)
require('dotenv').config();
const express = require('express');
const crypto = require('crypto');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2025-09-30.clover',
maxNetworkRetries: 2,
});
const app = express();
const PORT = process.env.PORT || 3000;
const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`;
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// WEBHOOK ENDPOINT — MUST be before express.json()
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
app.post(
'/webhooks/stripe',
express.raw({ type: 'application/json' }),
async (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.error(`Webhook sig failed: ${err.message}`);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
try {
// Idempotency check (use your DB in production)
if (processedEvents.has(event.id)) {
return res.status(200).json({ received: true });
}
await routeEvent(event);
// Mark as processed AFTER success. If we add it before and
// processing fails, Stripe retries will be silently ignored.
processedEvents.add(event.id);
res.status(200).json({ received: true });
} catch (err) {
console.error(`Error processing ${event.type} (${event.id}):`, err);
// Don't add to processedEvents — let Stripe retry
res.status(500).json({ error: 'Processing failed' });
}
}
);
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// JSON parsing for all other routes
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
app.use(express.json());
// In-memory store (replace with DB in production)
const users = new Map();
const apiKeys = new Map();
const processedEvents = new Set();
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// CHECKOUT — Create session
// ⚠️ In production, protect this route with authentication middleware.
// Never trust userId from the request body alone — derive it from
// the authenticated session (e.g., req.user.id from JWT/session).
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
app.post('/billing/checkout', requireAuth, async (req, res) => {
const { priceId, email } = req.body;
const userId = req.user.id; // from auth middleware — never from body
// Get or create Stripe customer
let user = users.get(userId);
let customerId = user?.stripe_customer_id;
if (!customerId) {
const customer = await stripe.customers.create({
email,
metadata: { user_id: userId },
});
customerId = customer.id;
users.set(userId, { ...user, stripe_customer_id: customerId });
}
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [{ price: priceId, quantity: 1 }],
subscription_data: {
metadata: { user_id: userId },
},
success_url: `${BASE_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${BASE_URL}/pricing`,
allow_promotion_codes: true,
});
res.json({ url: session.url, sessionId: session.id });
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// BILLING PORTAL
// ⚠️ Always authenticate — customerId from the body is attacker-controlled.
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
app.post('/billing/portal', requireAuth, async (req, res) => {
// Look up the customer from the authenticated user, not from body
const user = users.get(req.user.id);
if (!user?.stripe_customer_id) {
return res.status(400).json({ error: 'No billing account found' });
}
const session = await stripe.billingPortal.sessions.create({
customer: user.stripe_customer_id,
return_url: `${BASE_URL}/dashboard`,
});
res.json({ url: session.url });
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// WEBHOOK EVENT ROUTER
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
async function routeEvent(event) {
const obj = event.data.object;
switch (event.type) {
case 'checkout.session.completed': {
if (obj.mode !== 'subscription') break;
// Retrieve the subscription once (with expansion) instead of twice
const sub = await stripe.subscriptions.retrieve(obj.subscription, {
expand: ['items.data.price.product'],
});
const userId = obj.metadata?.user_id || sub.metadata?.user_id;
if (!userId) {
console.error('checkout.session.completed: no user_id in metadata');
break;
}
const baseItem = sub.items.data.length === 1
? sub.items.data[0]
: sub.items.data.find((it) => it.price.recurring?.usage_type !== 'metered') || sub.items.data[0];
const plan = baseItem.price.product?.metadata?.tier || 'pro';
users.set(userId, {
...users.get(userId),
stripe_customer_id: obj.customer,
stripe_subscription_id: sub.id,
plan,
status: sub.status,
current_period_end: sub.current_period_end,
});
// Provision API key
const apiKey = generateApiKey();
const keyHash = hashKey(apiKey);
apiKeys.set(keyHash, { userId, plan, active: true });
// Never log the full API key — log only the prefix
console.log(`Provisioned user ${userId} on ${plan}. API key: ${apiKey.substring(0, 10)}...`);
break;
}
case 'customer.subscription.updated': {
const userId = findUserByCustomer(obj.customer);
if (!userId) break;
const user = users.get(userId);
users.set(userId, {
...user,
status: obj.status,
current_period_end: obj.current_period_end,
cancel_at_period_end: obj.cancel_at_period_end,
});
// Handle pause / resume
if (obj.pause_collection) {
revokeKeysForUser(userId);
console.log(`Subscription paused for ${userId}`);
} else if (event.data.previous_attributes?.pause_collection) {
// Was paused, now resumed — restore API keys
const apiKey = generateApiKey();
const keyHash = hashKey(apiKey);
apiKeys.set(keyHash, { userId, plan: user?.plan || 'pro', active: true });
console.log(`Subscription resumed for ${userId}, new API key provisioned`);
}
console.log(`Subscription updated for ${userId}: ${obj.status}`);
break;
}
case 'customer.subscription.deleted': {
const userId = findUserByCustomer(obj.customer);
if (!userId) break;
users.set(userId, {
...users.get(userId),
status: 'canceled',
plan: 'free',
stripe_subscription_id: null,
});
revokeKeysForUser(userId);
console.log(`Subscription canceled for ${userId}`);
break;
}
case 'invoice.payment_succeeded': {
if (obj.billing_reason === 'subscription_create') break;
const userId = findUserByCustomer(obj.customer);
if (!userId) break;
users.set(userId, {
...users.get(userId),
status: 'active',
failed_payments: 0,
});
console.log(`Renewal succeeded for ${userId}`);
break;
}
case 'invoice.payment_failed': {
const userId = findUserByCustomer(obj.customer);
if (!userId) break;
const user = users.get(userId);
const failCount = (user?.failed_payments || 0) + 1;
users.set(userId, {
...user,
status: 'past_due',
failed_payments: failCount,
});
console.log(`Payment failed for ${userId} (attempt ${failCount})`);
// Send dunning email here
break;
}
case 'customer.subscription.trial_will_end': {
const userId = findUserByCustomer(obj.customer);
console.log(`Trial ending soon for ${userId}`);
// Send trial ending email
break;
}
default:
console.log(`Unhandled: ${event.type}`);
}
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// HELPERS
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
function generateApiKey(prefix = 'myapp_live') { // product-specific, never `sk_`
return `${prefix}_${crypto.randomBytes(32).toString('hex')}`;
}
function hashKey(key) {
return crypto.createHash('sha256').update(key).digest('hex');
}
function findUserByCustomer(customerId) {
for (const [userId, user] of users) {
if (user.stripe_customer_id === customerId) return userId;
}
return null;
}
function revokeKeysForUser(userId) {
for (const [hash, data] of apiKeys) {
if (data.userId === userId) {
data.active = false;
}
}
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// API KEY AUTH MIDDLEWARE
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
function authenticateKey(req, res, next) {
const auth = req.headers.authorization;
if (!auth?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing API key' });
}
const key = auth.slice(7);
const hash = hashKey(key);
const keyData = apiKeys.get(hash);
if (!keyData || !keyData.active) {
return res.status(401).json({ error: 'Invalid API key' });
}
const user = users.get(keyData.userId);
if (!user || !['active', 'trialing'].includes(user.status)) {
return res.status(402).json({ error: 'Subscription inactive' });
}
req.userId = keyData.userId;
req.plan = user.plan;
next();
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// PROTECTED API ENDPOINT
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
app.get('/api/v1/data', authenticateKey, (req, res) => {
res.json({
message: 'Authenticated!',
userId: req.userId,
plan: req.plan,
});
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// START
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
app.listen(PORT, () => {
console.log(`Billing server on port ${PORT}`);
console.log(`Test mode: ${process.env.STRIPE_SECRET_KEY?.startsWith('sk_test_') ?? 'unknown'}`);
});
```
---
## Quick Reference: Webhook Events Cheat Sheet
| Event | When | Action |
|-------|------|--------|
| `checkout.session.completed` | Customer completes Checkout | **Provision access** |
| `customer.subscription.created` | Subscription created | Store subscription ID |
| `customer.subscription.updated` | Plan change, pause, trial end | Update plan/status |
| `customer.subscription.deleted` | Subscription fully canceled | **Revoke access** |
| `customer.subscription.trial_will_end` | 3 days before trial ends | Send reminder email |
| `invoice.payment_succeeded` | Payment collected | Extend access period |
| `invoice.payment_failed` | Payment failed | Start dunning flow |
| `invoice.upcoming` | ~3 days before next invoice | Send usage summary |
---
## Decision Flowchart
```
New customer wants to subscribe
→ Create Checkout Session (mode: 'subscription')
→ Customer completes payment
→ Webhook: checkout.session.completed
→ Provision access + generate API key
→ Store subscription ID in your DB
Customer wants to change plan
→ stripe.subscriptions.update() with new price
→ Webhook: customer.subscription.updated
→ Update plan in your DB
Payment fails
→ Webhook: invoice.payment_failed
→ Send dunning email with portal link
→ Grace period (7 days)
→ If still unpaid → revoke access
Customer cancels
→ stripe.subscriptions.update({ cancel_at_period_end: true })
→ Webhook: customer.subscription.updated (cancel_at_period_end: true)
→ Show reactivation option in UI
→ At period end: customer.subscription.deleted
→ Webhook: customer.subscription.deleted
→ Revoke API keys, downgrade to free
```
---
## Checklist: Go-Live
- [ ] Webhook endpoint registered in Stripe Dashboard (not just CLI)
- [ ] Webhook signing secret in production env vars
- [ ] All essential events selected in webhook config
- [ ] Idempotency implemented (processed_events table)
- [ ] Raw body parsing before `express.json()`
- [ ] API version pinned
- [ ] Test mode cards verified for all flows
- [ ] Dunning emails configured
- [ ] Customer portal configured
- [ ] Grace period logic for failed payments
- [ ] API keys hashed in database
- [ ] Rate limiting on API and webhook endpoints
- [ ] Success URL does NOT provision (webhooks do)
- [ ] `metadata.user_id` set on checkout sessions and subscriptions
- [ ] Error monitoring/alerting on webhook failures
- [ ] Stripe CLI webhook forwarding tested locally