Overview
Stripe is the default payment processor for this stack, and a secure integration depends on four server-side invariants: webhook signatures verified against the raw request body, prices computed on the server, order fulfillment driven by events rather than client redirects, and explicit ownership of tax liability. Treat the client as untrusted. Every amount, every fulfillment decision, and every key scope is decided on your server.
Verify webhooks on the raw request body
Verify every webhook with stripe.webhooks.constructEvent(rawBody, sig, endpointSecret) using the exact bytes Stripe sent and the Stripe-Signature header. Stripe signs the raw payload, so any framework that parses the body to JSON first breaks verification: reordering keys, re-encoding, or adding whitespace all invalidate the signature (Stripe docs). You must hand constructEvent the unparsed body buffer.
In Express, register a raw body parser on the webhook route and mount express.json() only after it, so JSON parsing never runs before signature verification (Stripe docs).
import express from "express"
import Stripe from "stripe"
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!
const app = express()
// Raw body ONLY on this route. Do not put express.json() before it.
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
const sig = req.headers["stripe-signature"] as string
let event: Stripe.Event
try {
// req.body is a Buffer here, not parsed JSON.
event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret)
} catch (err) {
return res.status(400).send(`Webhook signature failed: ${(err as Error).message}`)
}
switch (event.type) {
case "checkout.session.completed":
case "payment_intent.succeeded":
fulfillOrder(event.data.object) // idempotent; safe on redelivery
break
}
res.json({ received: true }) // ack fast; do slow work async
})In a Next.js route handler, read await req.text() (or the raw arrayBuffer) and pass that string to constructEvent; never await req.json() first.
Set the price on the server, never trust the client
Create the PaymentIntent or Checkout Session server-side and compute the amount yourself from trusted data. Always decide how much to charge in your trusted environment, not the client, so a malicious caller cannot choose their own price (Stripe docs). The amount is a positive integer in the smallest currency unit (1099 means $10.99 for USD), and currency is a lowercase ISO code (Stripe docs). Look the price up by product ID server-side; reject any client-supplied amount.
Make create calls idempotent
Pass an idempotency key on every create call so network retries cannot double-charge. Stripe saves the status and body of the first request for a given key and replays it for later requests with the same key, which makes retries safe (Stripe docs). Use a V4 UUID or another high-entropy random string (Stripe docs). Only POST calls need keys; GET and DELETE are already idempotent.
await stripe.paymentIntents.create(
{ amount: priceFromDb, currency: "usd" },
{ idempotencyKey: orderId }
)Use restricted, mode-scoped keys
Give each service a restricted API key (RAK) with the minimum permissions it needs, not an unrestricted sk_ key that can do anything in the account (Stripe docs). Test and live modes have separate keys and separate data; a test key cannot touch live objects (Stripe docs). Store secrets per environment; see secrets-and-env. Stripe recommends RAKs over unrestricted keys, especially for keys handed to an AI agent (Stripe docs).
Fulfill from events, not the redirect
Trigger fulfillment from checkout.session.completed or payment_intent.succeeded in your webhook, not from the post-payment redirect. Fulfilling only on the landing page is unreliable: the customer may close the tab, and the redirect can be spoofed (Stripe docs). Make the handler idempotent because Stripe can redeliver events. Tie fulfillment to your own session and user records; see auth-sessions.
You own the tax liability
Stripe is not your merchant of record unless you use a Stripe merchant-of-record product. Sales tax, VAT, and GST liability are yours: you register, collect, file, and remit. Stripe Tax assists with calculation and filing but does not assume that liability outside its merchant-of-record offerings (Stripe docs). Confirm tax setup on the pre-launch-checklist before going live.
Related
- webhooks for the general raw-body and replay-safety pattern.
- secrets-and-env for storing live and test keys per environment.
- auth-sessions for tying payments to authenticated users.
- fastapi for the Python equivalent of the raw-body webhook route.
- python-security for input-trust rules that apply to amount handling.
- pre-launch-checklist for the go-live tax and key-rotation gates.