Overview

A secure Stripe integration rests on server-side invariants: webhook signatures verified on the raw body, prices computed on the server, fulfillment driven by events, a pinned API version, and explicit tax ownership. Treat the client as untrusted. The generic webhook pattern is in webhooks.

Verify webhooks on the raw request body

Verify every event with stripe.webhooks.constructEvent(rawBody, signature, endpointSecret) on the exact bytes Stripe sent; parsing or re-serializing the body first breaks the signature. Libraries reject events older than 5 minutes by default; a tolerance of 0 disables that check. Each endpoint has its own whsec_ secret, and test and live differ.

// Raw body ONLY on this route; mount express.json() after it.
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(req.body, req.headers["stripe-signature"] as string, endpointSecret)
  } catch (err) {
    return res.status(400).send(`Signature verification failed: ${(err as Error).message}`)
  }
  enqueue(event)          // slow work happens asynchronously
  res.json({ received: true })
})

In a Next.js route handler, pass await req.text(), never req.json(). Exempt the route from CSRF middleware in Rails, Django, and similar.

Pin the API version and expect messy delivery

  • The current API version is 2026-09-30.endive. Since 2024-09-30.acacia, Stripe ships monthly versions without breaking changes and a major release twice a year.
  • A webhook endpoint’s payloads use the API version set when it was created (else the account default), and stored events never change. Match it to the version your SDK pins; stripe-node 12 and later pin the version current at release.
  • Delivery is at least once and unordered. Dedupe on event ID (plus data.object.id and type for the rare double event), never order by created, and fetch the object when you need current state.
  • Return 2xx before slow work; redirects count as failures. Live mode retries for up to three days; sandbox retries three times over a few hours.
  • A rolled secret stays valid for up to 24 hours; accept either. Thin events (API v2) need a separate endpoint.

Set the price on the server

Create the PaymentIntent or Checkout Session on the server and compute the amount from trusted data. amount is a positive integer in the smallest currency unit (1099 is $10.99) and currency a lowercase ISO code. Look prices up by price ID and reject any amount the client sends.

Make create calls idempotent

Send an Idempotency-Key on every POST. Stripe stores the first request’s status and body for the key, including 500 errors, and replays them. Keys are up to 255 characters, pruned after at least 24 hours, and error if reused with different parameters. Use a V4 UUID or a per-operation value such as an order ID, never sensitive data. GET and DELETE need none.

await stripe.paymentIntents.create({ amount: priceFromDb, currency: "usd" }, { idempotencyKey: orderId })

Use restricted keys

Give each service a restricted key with only the permissions it needs, not an unrestricted sk_ key. Limit keys to known IP addresses where egress is stable, rotate on a schedule, and block sk_live_ and rk_live_ strings with a pre-commit hook. Keep keys in a vault (secrets-and-env).

Fulfill from events, idempotently

A customer can pay and lose connectivity before your landing page loads, so webhooks are required. For Checkout, handle checkout.session.completed and, for delayed methods such as bank debits, checkout.session.async_payment_succeeded (and async_payment_failed). The fulfillment function retrieves the Session with line_items expanded, acts only if payment_status is not unpaid, and records that it fulfilled.

Also call it from the success page so a present customer gets access at once. It will run more than once, possibly concurrently, so make it safe to repeat. With a success_url set, Checkout waits up to 10 seconds for your webhook to answer before redirecting. Tie fulfillment to your own user records (auth-sessions).

Own the tax liability unless you use Managed Payments

By default you register for, collect, file, and remit sales tax, VAT, and GST. Stripe Tax calculates and helps file but does not take liability. Stripe Managed Payments is the merchant-of-record product: Stripe is the seller of record and handles indirect tax on covered sales. Confirm the setup on the pre-launch-checklist.