Stripe Integration
How to integrate Stripe Checkout + webhooks + customer portal for a SaaS, end to end.
Why
Stripe is the default payment processor for SaaS because it handles PCI compliance, 135+ currencies, and a mature webhook system out of the box. Building payments yourself means storing card numbers, which puts you in PCI-DSS Level 1 scope — an audit that costs $50k+ per year. Stripe Elements + Checkout shift the card input to Stripe-hosted iframes, so your servers never see a PAN and you stay PCI-SAQ-A. The webhook architecture is what makes Stripe robust: idempotent events, automatic retries with exponential backoff, and a signed payload that proves the event came from Stripe. Getting the integration right the first time prevents a class of bugs that are expensive to fix later — double-charges, missed entitlements, orphaned subscriptions.
When
When you launch a paid SaaS or add paid plans to a free product. Before writing any checkout code, confirm Stripe is available in your country and supports your business type (some restrict digital services).
How
1. Create a Stripe account, verify business details, and switch from test to live mode only after full E2E testing in test mode 2. Install the official SDK: npm install stripe, and initialize with the secret key from environment variables (never commit it) 3. Create a Product + Price for each plan via the Dashboard or API (stripe.products.create / stripe.prices.create) 4. Build the checkout session server-side: stripe.checkout.sessions.create with mode='subscription', line_items, success_url, cancel_url 5. Implement the webhook handler at /api/stripe-webhook: verify the signature with stripe.webhooks.constructEvent using the raw body and webhook secret 6. Handle these events at minimum: checkout.session.completed, customer.subscription.updated, customer.subscription.deleted, invoice.payment_failed 7. Make every webhook handler idempotent: check if you already processed this event id before acting (store event ids in a table) 8. Embed the Customer Portal (stripe.billingPortal.sessions.create) so users can update card / cancel without your support 9. Write an E2E test using stripe.webhooks.generateTestHeaderString and fixture events before going live
Example
// server: create checkout session
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: "price_abc123", quantity: 1 }],
success_url: "https://app.example.com/billing?session_id={CHECKOUT_SESSION_ID}",
cancel_url: "https://app.example.com/pricing",
customer_email: user.email,
metadata: { userId: user.id },
});
// webhook handler (raw body required)
app.post("/api/stripe-webhook", express.raw({ type: "application/json" }), async (req, res) => {
const sig = req.headers["stripe-signature"]!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (err) {
return res.status(400).send("Invalid signature");
}
if (await processedAlready(event.id)) return res.json({ received: true });
switch (event.type) {
case "checkout.session.completed":
await grantEntitlement(event.data.object.metadata.userId);
break;
case "invoice.payment_failed":
await dunningWorkflow(event.data.object.customer);
break;
}
await markProcessed(event.id);
res.json({ received: true });
});Common Problems
- ⚠Verifying the webhook signature with a parsed JSON body instead of the raw body — the signature is computed over the exact bytes, so body-parser destroys it. Use express.raw on the webhook route only.
- ⚠Not making webhook handlers idempotent — Stripe retries on any non-2xx response, so a handler that grants entitlement on every call double-grants on retry. Store the event id and skip if seen.
- ⚠Using the test secret key in production or vice versa — keys start with sk_test_ and sk_live_, but a config bug can ship the wrong one. Add a startup assertion that fails fast if the key prefix does not match NODE_ENV.
- ⚠Forgetting to handle customer.subscription.deleted — the user cancels but your app keeps granting access because you only listen for checkout.session.completed. Entitlements must be derived from the current subscription status, not the checkout event.
Best Practices
- ✓Store the stripe_customer_id on your user row on first checkout — you need it for the Customer Portal, refunds, and dunning, and creating a duplicate customer per charge breaks subscription management.
- ✓Use Stripe's idempotency keys (the Idempotency-Key header) on all charge and refund requests so a network retry does not double-charge the customer.
- ✓Subscribe to Stripe's webhook events in the Dashboard and select only the events you handle — receiving events you ignore wastes requests and increases the chance of a bug when you accidentally start handling one.
- ✓Run the Stripe CLI local listener (stripe listen --forward-to localhost:3000/api/stripe-webhook) during development so you test webhooks without deploying.