Stripe — Checkout, subscriptions, and webhooks that survive production
Wire an app to Stripe for one-time payments and subscriptions: a Checkout
Session to take money, a signature-verified webhook to learn the outcome, and
the billing portal so customers manage themselves. Targets stripe-node v22.x
(API version 2026-05-27.dahlia).
The one rule everything else hangs off:
- Stripe is the source of truth; your DB is a cache kept current by verified webhooks. A subscription's real state lives in Stripe. You mirror it locally only so you can render a paywall without a round-trip. The webhook is what keeps the mirror honest — never the client redirect, never polling.
When to use / When NOT to use
Use when: adding Checkout or a paywall; taking a one-time or recurring
charge; receiving Stripe webhooks; letting users cancel/upgrade via the portal;
debugging No signatures found matching, double-fired events, or a
checkout.session.completed that never arrives.
Do NOT use for:
- A generic, non-Stripe inbound webhook receiver (arbitrary HMAC, a
retry/queue consumer for any provider) →
webhooks. This skill only covers Stripe'sStripe-Signaturescheme and event model. - The invoice document's legal form — what must appear on it, dunning copy, payment-status as a business process → invoicing.
- Cash-flow forecasting, bank reconciliation, month-close → finance-ops.
- What to charge / plan tiers / packaging → pricing. This skill implements a price; it does not decide it.
- A typed client for some other REST API →
api-connector-builder.
Pick the surface
| You need | Use | Why |
|---|---|---|
| A link to sell one product, zero code | Payment Link | No backend; Stripe hosts everything. Outgrow it fast. |
| Hosted checkout, full control of session | Checkout Session | The default. Stripe hosts the page, handles SCA/3DS, PCI scope is minimal. |
| Your own embedded payment form | Payment Element + Checkout Session | Custom UI, but you render the form. Use the Element with a Checkout Session, not raw PaymentIntents, unless you have a reason. |
Default to Checkout Session unless a requirement forces otherwise.
Mental model
Customer ──> Price (you defined it in Stripe) ──> Checkout Session
│ │
│ customer pays (hosted)
▼ ▼
your DB <── webhook (verified) ── Event <── Subscription / Invoice
(a cache) (the real state, in Stripe)
Env vars used throughout — read from the environment, never hard-code:
STRIPE_SECRET_KEY—sk_test_…in dev,sk_live_…in prod.STRIPE_WEBHOOK_SECRET—whsec_…, one per endpoint; differs in test vs live.
Construct the client (pin the API version)
import Stripe from "stripe";
// Pin apiVersion explicitly so a dashboard-level version bump can never change
// your API behavior under you. Match the version your SDK release ships with.
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2026-05-27.dahlia",
});
Create a Checkout Session
// Subscription mode — recurring price.
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: "price_123", quantity: 1 }], // a recurring Price ID
success_url: `${BASE}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${BASE}/pricing`,
// Tie the session to YOUR user so the webhook can find the right row.
client_reference_id: userId,
// 14-day trial; omit for immediate billing.
subscription_data: { trial_period_days: 14 },
});
// Redirect the browser to session.url.
// Payment mode — one-time charge (e.g. a digital download).
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: [{ price: "price_onetime", quantity: 1 }],
success_url: `${BASE}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${BASE}/`,
client_reference_id: userId,
});
success_url is a UX redirect, not proof of payment. The customer can close the
tab before it loads, or hit the URL directly. Grant access from the webhook,
not the redirect.
The webhook handler (load-bearing)
This is where integrations break. Three non-negotiables:
- Verify against the RAW body.
constructEventrecomputes the signature over the exact bytes Stripe sent. Any middleware that parses/re-serializes the body (e.g.express.json()on this route) changes those bytes and you getNo signatures found matching the expected signature. - Return 2xx before slow work. Verify, record the event, return 200 immediately. Stripe treats a timeout as failure and retries — for up to 3 days in live mode — so slow handlers cause duplicate delivery.
- Be idempotent. Events are unordered and may arrive more than once.
Dedupe on
event.id, persisting the idempotency record in the same transaction as the business write. Default replay tolerance is 5 minutes.
// Express. The route gets the RAW body; do NOT mount express.json() here.
app.post("/webhook", express.raw({ type: "application/json" }), async (req, res) => {
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body, // raw Buffer, untouched
req.headers["stripe-signature"]!, // header name is lowercase
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch (err) {
return res.status(400).send(`Webhook Error: ${(err as Error).message}`);
}
// Dedupe + record in one transaction; skip if we have seen this event.id.
const fresh = await recordEventOnce(event.id, event.type);
if (!fresh) return res.status(200).send(); // already processed; ack and stop
// Return 2xx FAST. Hand heavy work to a queue/background job if it is slow.
switch (event.type) {
case "checkout.session.completed": {
const s = event.data.object as Stripe.Checkout.Session;
await grantAccess(s.client_reference_id!, s.customer as string);
break;
}
case "customer.subscription.updated":
case "customer.subscription.deleted": {
const sub = event.data.object as Stripe.Subscription;
await syncSubscription(sub.customer as string, sub.status); // mirror state
break;
}
case "invoice.payment_failed":
// dunning lives here; see references/webhook-events.md
break;
}
res.status(200).send();
});
See references/webhook-events.md for the full
event catalog per flow (subscription lifecycle, one-time payment, dunning) and
what each event should do to your DB.
Framework gotchas (raw body)
// Next.js App Router — read the raw text yourself; do not use req.json().
// app/api/webhook/route.ts
export async function POST(req: Request) {
const body = await req.text(); // raw string
const sig = req.headers.get("stripe-signature")!;
const event = stripe.webhooks.constructEvent(body, sig, secret);
// ...handle, then:
return new Response(null, { status: 200 });
}
// Edge / Cloudflare Workers — synchronous crypto is unavailable. Use the async
// API with the Web Crypto provider.
const event = await stripe.webhooks.constructEventAsync(
body, sig, secret, undefined, Stripe.createSubtleCryptoProvider(),
);
Subscribe to only the events you need
For a subscription paywall, listen to exactly these — listening to "all events" is discouraged and buries you in noise:
checkout.session.completed, customer.subscription.created,
customer.subscription.updated, customer.subscription.deleted,
invoice.paid, invoice.payment_failed.
Billing portal (self-service)
Do not build cancel/upgrade/update-card UI. Stripe hosts it.
const portal = await stripe.billingPortal.sessions.create({
customer: stripeCustomerId, // the Customer you stored
return_url: `${BASE}/account`,
});
// Redirect to portal.url — it is short-lived; mint it on demand, never cache it.
Idempotency on create calls
A retried POST (network blip, double-click) can create two subscriptions. Pass an idempotency key derived from the operation, not a random one:
await stripe.checkout.sessions.create(params, {
idempotencyKey: `checkout:${userId}:${planId}`,
});
Local testing — Stripe CLI, never hand-crafted JSON
stripe login
stripe listen --forward-to localhost:3000/api/webhook # prints a whsec_… secret
stripe trigger checkout.session.completed # fire a real test event
Put the printed whsec_… in STRIPE_WEBHOOK_SECRET for local runs. Hand-built
JSON will never pass signature verification — that is the point.
Going live
Compact checklist; depth in references/going-live.md:
-
apiVersionpinned explicitly in the client. - Swap
sk_test_/whsec_(test) for live values via env, not code. - Create the live webhook endpoint; copy its own signing secret.
- Restrict the event subscription to the allowlist above.
- Use restricted API keys for the server, not the unrestricted secret.
- Idempotency keys on all create calls.
- Confirm SCA/3DS is handled (Checkout does this for you).
Anti-patterns
| Bad | Good | Why |
|---|---|---|
express.json() on the webhook route |
express.raw() / req.text() |
Re-serialized body breaks the signature → No signatures found matching. |
No constructEvent — trust the payload |
Always verify the signature | Anyone can POST fake events to an unverified endpoint. |
Grant access on the success_url redirect |
Grant on checkout.session.completed |
The redirect is UX, not proof; it can be skipped or forged. |
| DB writes, then return 200 | Return 200 fast, queue slow work | Timeouts make Stripe retry → duplicate delivery. |
| Process every delivery | Dedupe on event.id in the write txn |
Events are unordered and at-least-once. |
Hard-coded sk_live_… in source |
process.env.STRIPE_SECRET_KEY |
Leaked keys = drained account; never commit them. |
No apiVersion |
Pin it explicitly | A dashboard version bump silently changes your API behavior. |
| Poll the API for subscription state | React to webhooks | Polling is slow, rate-limited, and races real events. |
| Listen to all event types | Subscribe to the allowlist | Noise, wasted handling, accidental side effects. |
| Cache the portal/session URL | Mint per request | These URLs are short-lived and single-use. |
See also
webhooks— generic, non-Stripe inbound event receivers.- invoicing — the invoice document and its legal form.
- pricing — deciding tiers and amounts before you wire them.
- secure-coding — secret handling, key restriction.