Nota revisada y reescrita el 18 de octubre de 2026: la versión original tenía errores y datos sin fuente.
Cuando Stripe confirma un pago, tienes que hacer cosas: marcar el pedido como pagado, mandar el email, avisar al almacén. Hacerlo desde la página de "gracias por tu compra" no sirve: el usuario puede cerrarla antes de que cargue, y algunos métodos de pago se confirman minutos u horas después. Lo fiable es el webhook. Pero un webhook tiene sus propias reglas, y todas están en la documentación de Stripe.
1. Verificar la firma con el cuerpo sin tocar
Cualquiera puede hacer un POST a tu endpoint. Stripe firma cada envío con la cabecera Stripe-Signature, y la librería oficial la comprueba, pero necesita el cuerpo exactamente como llegó. Si tu framework ya lo ha parseado a JSON y lo vuelves a serializar, la firma no cuadra.
En el App Router de Next.js:
// app/api/stripe/webhook/route.js
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function POST(req) {
const body = await req.text(); // texto sin parsear
const signature = req.headers.get('stripe-signature');
let event;
try {
event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET);
} catch (err) {
return new Response(`Firma no válida: ${err.message}`, { status: 400 });
}
await queueEvent(event); // guardar y procesar aparte (ver punto 2)
return new Response(null, { status: 200 });
}
En el Pages Router hay que desactivar el parser con export const config = { api: { bodyParser: false } } y leer el buffer del req.
La librería rechaza firmas con más de cinco minutos de antigüedad, para que no te reenvíen un evento capturado. No pongas la tolerancia a 0: según Stripe, eso desactiva la comprobación por completo.
2. Responder 2xx antes del trabajo pesado
Stripe pide devolver un 2xx antes de cualquier lógica que pueda tardar. Si tu endpoint tarda demasiado o devuelve un error, Stripe lo da por fallido y lo reintenta: en modo live, durante un máximo de tres días, con espera exponencial. Si dentro del webhook generas una factura, llamas a GitHub y mandas tres emails, cualquier lentitud se convierte en reintentos, y los reintentos en acciones repetidas.
El patrón: verificar, guardar el evento, responder 200 y procesar en segundo plano (una cola, un job o un workflow de n8n que lee de la tabla).
3. Idempotencia: el mismo evento puede llegar dos veces
La documentación lo dice tal cual: un endpoint puede recibir el mismo evento más de una vez. La protección es registrar los event.id que ya has procesado:
CREATE TABLE stripe_events (
id text PRIMARY KEY, -- evt_...
type text NOT NULL,
received_at timestamptz DEFAULT now(),
processed_at timestamptz
);
async function queueEvent(event) {
// Si el id ya existe, no inserta nada: el duplicado se ignora
await db.query(
'INSERT INTO stripe_events (id, type) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING',
[event.id, event.type]
);
}
Hay un caso más: a veces Stripe genera dos objetos Event distintos para el mismo cambio. Para esos, la propia documentación recomienda deduplicar por el id del objeto en data.object junto con event.type. Además, cada acción debería ser idempotente por sí misma: "marcar el pedido pi_123 como pagado" se puede repetir sin daño, y "sumar 1 al contador de ventas" no.
4. No depender del orden
Stripe no garantiza que los eventos lleguen en el orden en que se generaron. Puede llegarte invoice.paid antes que customer.subscription.created. Y created va en segundos, así que dos eventos pueden tener la misma marca de tiempo. Si tu lógica necesita el estado actual, pídeselo a la API en lugar de reconstruirlo con los eventos:
if (event.type === 'checkout.session.completed') {
const session = await stripe.checkout.sessions.retrieve(event.data.object.id, {
expand: ['line_items'],
});
if (session.payment_status === 'paid') await fulfillOrder(session);
}
Fíjate en el payment_status: con métodos de pago asíncronos (transferencia, SEPA), checkout.session.completed llega antes de que el dinero esté confirmado. En ese caso, el evento que importa es checkout.session.async_payment_succeeded.
5. Probarlo en local
stripe listen --forward-to localhost:3000/api/stripe/webhook
stripe trigger checkout.session.completed
stripe listen te da un secreto de webhook temporal (whsec_...) para tu .env.local. Para probar los duplicados, reenvía un mismo evento desde el Dashboard o con stripe events resend evt_... y comprueba que la acción no se repite.