---
title: "Stripe Payments: Best Practices"
slug: "payments-stripe"
category: "backend"
tags: ["stripe", "payments", "backend", "security", "webhooks"]
status: "stable"
last_updated: 2026-06-16
summary: "House standard for Stripe: verify webhooks on the raw body, set the price server-side, fulfill from events, and own your tax liability."
related: ["[[backend/webhooks]]", "[[ops/secrets-and-env]]", "[[backend/auth-sessions]]", "[[ops/pre-launch-checklist]]", "[[backend/fastapi]]", "[[coding/python-security]]"]
---

## 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).

```ts
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.

```ts
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 [[ops/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 [[backend/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 [[ops/pre-launch-checklist]] before going live.

## Related

- [[backend/webhooks]] for the general raw-body and replay-safety pattern.
- [[ops/secrets-and-env]] for storing live and test keys per environment.
- [[backend/auth-sessions]] for tying payments to authenticated users.
- [[backend/fastapi]] for the Python equivalent of the raw-body webhook route.
- [[coding/python-security]] for input-trust rules that apply to amount handling.
- [[ops/pre-launch-checklist]] for the go-live tax and key-rotation gates.
