🎯 Meta: cobrar dinero real sin liarla. La parte fácil de un checkout es el botón "Pagar"; la parte que separa un proyecto de portfolio de uno production-ready es todo lo demás: webhooks (la fuente de verdad), idempotencia, reembolsos, y qué hacer cuando la tarjeta fue cobrada pero tu servidor se cayó a mitad de la respuesta.
Versión: Stripe API 2026-06-20 (versionada por fecha, no semver) · SDK oficial por lenguaje (
stripe-node,stripe-python, etc.).⚠️ Nunca implementes cobros "a mano". No hay excepción razonable para escribir tu propio procesador de tarjetas: certificación PCI-DSS, fraude, disputas, regulación regional (SCA en la UE)… todo eso lo resuelve Stripe (o Mercado Pago/PayPal, mismos conceptos). Tu trabajo es integrarlo bien, no reinventarlo.
P.1 · El modelo mental: tu servidor nunca toca la tarjeta
El dato de la tarjeta jamás llega a tu backend. El flujo correcto:
┌──────────┐ ┌──────────┐ ┌─────────┐
│ Navegador│──① datos tarjeta──▶│ Stripe │ │ Tu │
│(Stripe.js│◀── token/PaymentMethod ──────── │ backend │
│ o Checkout) └──────────┘ └─────────┘
│ │──② PaymentMethod / redirect a Checkout ───────────▶│ │
└──────────┘ │ ③ crea │
│ el pago│
┌──────────┐◀── ③ crear PaymentIntent ───────┘ │
│ Stripe │ │
└────┬─────┘ │
│ ④ webhook: payment_intent.succeeded │
└───────────────────────────────────────────────▶│
⑤ AQUÍ confirmas el pedido⚠️ La regla de oro: tu backend jamás marca un pedido como "pagado" en el momento de la respuesta del checkout. Lo marca cuando llega el webhook. La respuesta al usuario puede perderse (el navegador se cierra, la red falla); el webhook de Stripe reintenta hasta que tu servidor responda
200. El webhook es la única fuente de verdad.
P.2 · Setup y claves
npm install stripe// stripe.ts
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2026-06-20',
});| Clave | Dónde vive | Para qué |
|---|---|---|
STRIPE_SECRET_KEY (sk_...) | Solo backend (.env, cap. 12) | Crear cargos, reembolsos — nunca al frontend |
STRIPE_PUBLISHABLE_KEY (pk_...) | Frontend (es pública, cap. 15.5) | Inicializar Stripe.js |
STRIPE_WEBHOOK_SECRET (whsec_...) | Solo backend | Verificar que el webhook es realmente de Stripe |
# Stripe CLI: reenvía webhooks a tu localhost durante desarrollo
stripe listen --forward-to localhost:3000/webhooks/stripe
# imprime un whsec_... temporal — úsalo en tu .env localP.3 · Checkout: la forma recomendada (Stripe hospeda el formulario)
Stripe Checkout es una página hospedada por Stripe: cero PCI-DSS de tu lado, formularios optimizados (Apple Pay, Google Pay, SCA automático). Es lo que debes usar salvo que necesites UI 100% propia (P.4).
// POST /checkout/:pedidoId — crea la sesión de pago
app.post('/checkout/:pedidoId', async (req, res) => {
const pedido = await db.pedido.findUnique({ where: { id: req.params.pedidoId } });
if (!pedido || pedido.estado !== 'pendiente') {
return res.status(400).json({ error: 'Pedido no válido para pagar' });
}
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: pedido.lineas.map((linea) => ({
price_data: {
currency: 'eur',
product_data: { name: linea.nombreProducto },
unit_amount: Math.round(linea.precio * 100), // ⚠️ SIEMPRE en céntimos, entero
},
quantity: linea.cantidad,
})),
// metadata: el puente entre el mundo de Stripe y TU base de datos
metadata: { pedidoId: pedido.id, userId: pedido.userId },
success_url: `${process.env.APP_URL}/pedidos/${pedido.id}?pago=ok`,
cancel_url: `${process.env.APP_URL}/pedidos/${pedido.id}?pago=cancelado`,
});
res.json({ url: session.url }); // el frontend redirige aquí
});// Frontend: solo pide la URL y redirige — nada de tarjetas en tu código
async function irACheckout(pedidoId: string) {
const res = await fetch(`/api/checkout/${pedidoId}`, { method: 'POST' });
const { url } = await res.json();
window.location.href = url; // el usuario paga EN Stripe
}🧠
success_urlno es confirmación de pago. Es solo UX — "gracias, ya procesamos tu pago" — pero el usuario pudo cerrar la pestaña antes de llegar, o el navegador pudo fallar. La confirmación real es el webhook (P.5). Nunca actives el pedido aquí.⚠️ Importes en céntimos, siempre enteros.
19.9 €es1990. Trabajar con floats para dinero es la fuente de bugs de redondeo más clásica de todo backend — usa enteros (céntimos) o un tipoDecimalen tu BD (cap. 01), nuncafloat/double.
P.4 · Payment Intents: cuando necesitas UI propia
Si el checkout hospedado no encaja (marketplace con múltiples vendedores, suscripciones con UI custom), usas Payment Intents directamente con Stripe Elements en tu frontend:
// Backend: crea el intent con el importe exacto (SIEMPRE calculado en servidor)
app.post('/pagos/intent', async (req, res) => {
const pedido = await calcularTotalDelPedido(req.body.pedidoId); // ⚠️ nunca confíes en el precio del cliente
const intent = await stripe.paymentIntents.create({
amount: Math.round(pedido.total * 100),
currency: 'eur',
metadata: { pedidoId: pedido.id },
automatic_payment_methods: { enabled: true }, // Stripe elige el mejor método por región
});
res.json({ clientSecret: intent.client_secret }); // esto SÍ va al frontend
});// Frontend (React) — Stripe Elements
import { loadStripe } from '@stripe/stripe-js';
import { Elements, PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js';
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PK);
function FormularioPago({ clientSecret }: { clientSecret: string }) {
const stripe = useStripe();
const elements = useElements();
const [procesando, setProcesando] = useState(false);
async function confirmar(e: React.FormEvent) {
e.preventDefault();
if (!stripe || !elements) return;
setProcesando(true);
const { error } = await stripe.confirmPayment({
elements,
confirmParams: { return_url: `${window.location.origin}/pedidos/confirmacion` },
});
if (error) setProcesando(false); // el usuario ve el error de Stripe (tarjeta rechazada…)
// si NO hay error, el navegador redirige a return_url automáticamente
}
return (
<form onSubmit={confirmar}>
<PaymentElement />
<button disabled={!stripe || procesando}>{procesando ? 'Procesando…' : 'Pagar'}</button>
</form>
);
}⚠️ El importe se calcula SIEMPRE en el servidor, a partir de tu base de datos — nunca confíes en un
totalque te mande el frontend. Es el mismo principio que "el precio vive en el servidor" del cap. 15.2: cualquiera puede editar el JS del navegador y mandartetotal: 1.
Autenticación fuerte (SCA) y 3D Secure, sin que tú la implementes
Si vendes a clientes en la UE/Reino Unido, la normativa PSD2 exige Strong Customer Authentication (SCA): el banco del cliente debe verificar su identidad (código enviado al móvil, huella, Face ID) antes de aprobar muchos pagos online. El mecanismo que lo cumple es 3D Secure 2 (3DS2).
confirmPayment() ──▶ Stripe evalúa el riesgo
│
┌────────┴────────┐
Bajo riesgo/exento Requiere 3DS
(frictionless) │
│ ▼
│ El banco muestra un
│ challenge (código SMS,
│ app del banco, biometría)
│ │
▼ ▼
Pago aprobado Cliente confirma → aprobado
(o cancela → falla)🧠 No implementas 3DS tú mismo — lo dispara Stripe automáticamente. Con
automatic_payment_methods: { enabled: true }(P.4) o Checkout hospedado (P.3), Stripe decide si el pago necesita el challenge (redirige al cliente a la pantalla del banco y vuelve) o puede procesarse frictionless (sin pasos extra, cuando aplica una exención de riesgo bajo). Tu único trabajo es manejar el estado intermedio:
// Frontend: confirmPayment ya gestiona la redirección al challenge del banco por ti
const { error } = await stripe.confirmPayment({
elements,
confirmParams: { return_url: `${window.location.origin}/pedidos/confirmacion` },
});
// Si Stripe necesita 3DS, el navegador redirige al banco y VUELVE solo a return_url
// con el resultado — no hay una rama de código distinta que escribir para el challenge.⚠️ Un pago puede quedar en
requires_actionsi el cliente cierra la pestaña del banco a mitad del challenge. Tu webhook (P.5) recibirápayment_intent.payment_failedo elPaymentIntentseguirá pendiente — nunca actives el pedido salvo que llegue el evento de éxito. Prueba este caso con la tarjeta4000 0025 0000 3155(P.9): fuerza el challenge de 3DS en modo test para ver el flujo completo antes de ir a producción.
P.5 · Webhooks: la única fuente de verdad
// ⚠️ El body debe llegar CRUDO (sin parsear a JSON) para verificar la firma
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
let evento: Stripe.Event;
try {
evento = stripe.webhooks.constructEvent(
req.body,
req.headers['stripe-signature'] as string,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch (err) {
return res.status(400).send(`Firma inválida: ${(err as Error).message}`);
}
// Idempotencia: Stripe puede reenviar el MISMO evento más de una vez
const yaProcesado = await db.eventoStripe.findUnique({ where: { id: evento.id } });
if (yaProcesado) return res.json({ recibido: true }); // no lo proceses dos veces
switch (evento.type) {
case 'checkout.session.completed': {
const session = evento.data.object as Stripe.Checkout.Session;
await activarPedido(session.metadata!.pedidoId, session.payment_intent as string);
break;
}
case 'payment_intent.payment_failed': {
const intent = evento.data.object as Stripe.PaymentIntent;
await marcarPedidoFallido(intent.metadata.pedidoId);
break;
}
case 'charge.refunded': {
const charge = evento.data.object as Stripe.Charge;
await marcarPedidoReembolsado(charge.metadata.pedidoId);
break;
}
}
await db.eventoStripe.create({ data: { id: evento.id, tipo: evento.type } }); // marca procesado
res.json({ recibido: true }); // ⚠️ SIEMPRE 200 si lo procesaste — si no, Stripe reintenta
});🧠 Por qué el body crudo: la verificación de firma (
constructEvent) recalcula un HMAC sobre los bytes exactos del cuerpo. Si tu framework ya parseó el JSON (reordenó claves, cambió espacios), la firma no coincide y todo webhook falla — el bug más común al integrar Stripe. En Express: montaexpress.raw()solo en esta ruta, antes deexpress.json()global.⚠️ Verifica la firma SIEMPRE. Sin esto, cualquiera puede hacer
POSTa tu endpoint de webhook simulandocheckout.session.completedy activar pedidos gratis. Es la inyección de confianza más peligrosa de un e-commerce.
Eventos que te importan de verdad:
| Evento | Cuándo llega | Qué hacer |
|---|---|---|
checkout.session.completed | Checkout hospedado, pago OK | Activa el pedido |
payment_intent.succeeded | Payment Intents, pago OK | Activa el pedido |
payment_intent.payment_failed | Tarjeta rechazada | Marca fallido, notifica al usuario |
charge.refunded | Reembolso procesado | Actualiza estado, revierte stock |
charge.dispute.created | El cliente disputó el cargo (chargeback) | Alerta al equipo — vas a perder el dinero salvo que respondas con evidencia |
P.6 · Idempotencia end-to-end (el concepto que lo sostiene todo)
Dos peligros distintos, dos soluciones distintas:
1. El cliente reintenta la creación del pago (doble click, timeout y retry del frontend):
// Idempotency-Key: si repites la MISMA key, Stripe devuelve el MISMO resultado sin cobrar dos veces
await stripe.paymentIntents.create(
{ amount: 1990, currency: 'eur', metadata: { pedidoId } },
{ idempotencyKey: `pedido-${pedidoId}-intento-1` },
);2. Stripe reentrega el mismo webhook (tu servidor no respondió a tiempo, hubo un timeout de red): lo resuelve la tabla eventoStripe de P.5 — comprobar evento.id antes de procesar.
💡 Es el mismo patrón de idempotencia del apéndice N (cola offline) y del cap. 20 (refresh tokens): cualquier operación con efectos secundarios que pueda repetirse necesita una clave de idempotencia. Dinero es el caso donde más caro sale olvidarlo.
P.7 · Reembolsos y cancelaciones
app.post('/pedidos/:id/reembolsar', requireRole('admin'), async (req, res) => {
const pedido = await db.pedido.findUnique({ where: { id: req.params.id } });
if (pedido!.estado !== 'pagado') {
return res.status(400).json({ error: 'Solo se reembolsan pedidos pagados' });
}
const reembolso = await stripe.refunds.create({
payment_intent: pedido!.paymentIntentId,
reason: 'requested_by_customer',
});
// ⚠️ NO marques el pedido como reembolsado aquí todavía —
// espera el webhook charge.refunded (P.5) como fuente de verdad,
// igual que con el pago original.
res.json({ reembolsoId: reembolso.id, estado: reembolso.status });
});Reembolso parcial: amount: 500 (solo 5,00 €). Devuelve stock en el mismo handler del webhook que confirma el reembolso — no antes, no en el endpoint que lo solicita.
P.8 · Suscripciones (el otro modelo de negocio)
// Checkout en modo suscripción: Stripe gestiona el ciclo de cobro por ti
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: 'price_1AbCdEfGh...', quantity: 1 }], // precio pre-creado en el Dashboard
customer_email: usuario.email,
metadata: { userId: usuario.id },
success_url: `${APP_URL}/suscripcion/ok`,
cancel_url: `${APP_URL}/suscripcion/cancelada`,
});Eventos de suscripción que necesitas manejar (mismo patrón webhook de P.5):
| Evento | Significa |
|---|---|
customer.subscription.created | Alta — activa el plan en tu BD |
invoice.paid | Cobro periódico OK — extiende el acceso |
invoice.payment_failed | Tarjeta caducada/rechazada — Stripe reintenta solo (dunning) |
customer.subscription.deleted | Cancelada (por el usuario o tras fallos de cobro) — revoca acceso |
🧠 No calcules tú los ciclos de facturación. Stripe cobra automáticamente cada periodo, reintenta cobros fallidos con su propia lógica de reintentos ("dunning"), y prorratea cambios de plan. Tu backend solo escucha los webhooks y refleja el estado.
P.9 · Modo test y checklist antes de producción
# Tarjetas de prueba (modo test, nunca cobran de verdad)
4242 4242 4242 4242 # éxito
4000 0000 0000 9995 # fondos insuficientes
4000 0025 0000 3155 # requiere autenticación 3D Secure (SCA)Checklist de producción:
□ Claves live (sk_live_/pk_live_) SOLO en variables de entorno de producción.
□ Webhook configurado con la URL de producción + su propio whsec_.
□ El endpoint de webhook verifica la firma y es idempotente (P.5, P.6).
□ Los importes se calculan en el servidor, nunca se confía en el cliente (P.4).
□ El pedido se activa SOLO en el webhook, nunca en success_url (P.3).
□ Logs de cada evento de Stripe procesado (para auditoría de disputas).
□ Manejas charge.dispute.created con alerta al equipo.
□ Página de checkout servida por HTTPS (obligatorio para Stripe.js, cap. 13).P.10 · Buenas prácticas
- Checkout hospedado por defecto. Payment Intents + Elements solo si de verdad necesitas UI propia — te ahorra PCI-DSS y meses de edge cases de UX de pago.
- El webhook es la fuente de verdad.
success_urles UX, no confirmación. - Idempotencia en dos capas:
Idempotency-Keyal crear el pago,evento.idal procesar el webhook. - Dinero en enteros (céntimos), nunca floats.
- Importes calculados en servidor, siempre, desde tu base de datos.
metadataes tu puente: mete el id de tu pedido/usuario en cada objeto de Stripe para poder reconciliar sin ambigüedad.- body crudo en la ruta de webhook, antes de cualquier middleware que parsee JSON.
- Modo test hasta el checklist de P.9 completo. Nunca pruebes con tarjetas reales "para ver si funciona".
✅ Ejercicio del apéndice
Cobra de verdad en la Cantina (cap. 22):
1. Checkout hospedado: al confirmar el carrito, crea una Checkout Session con
line_items desde TU base de datos (nunca desde el frontend) y metadata.pedidoId.
2. Endpoint de webhook con verificación de firma + tabla de idempotencia por evento.id.
3. checkout.session.completed → transiciona el pedido a 'pagado' (máquina de estados
del cap. 22) y descuenta stock DENTRO de la misma transacción.
4. payment_intent.payment_failed → marca el pedido como fallido y notifica (SSE, cap. 21).
5. Endpoint de reembolso para admin: stripe.refunds.create + espera el webhook
charge.refunded para devolver stock y notificar al cliente.
6. Prueba con las tarjetas de test: éxito, fondos insuficientes, y 3D Secure.
7. Simula un webhook reenviado dos veces (mismo evento.id) y demuestra con un test
que el pedido NO se activa ni se descuenta el stock dos veces.
8. Checklist de P.9 completo antes de "lanzar" (en test, claro).💡 Pistas
stripe listen --forward-to localhost:3000/webhooks/stripe --print-jsonte deja ver el payload exacto de cada evento mientras desarrollas.- El bug nº1 en Express: montar
app.use(express.json())global ANTES de definir la ruta de webhook. La ruta de webhook necesitaexpress.raw()— decláralo antes o en una ruta aparte que no pase por el JSON parser global. - Para el test del punto 7: llama dos veces a tu handler con el mismo
evento.id(mockeandoconstructEvent) y verifica quedb.pedido.updatecon el descuento de stock se llamó una sola vez.
🧠 Autoevaluación
success_url es solo una redirección de UX tras el pago: el navegador puede cerrarse o la red puede fallar antes de llegar. El webhook lo envía Stripe directamente a tu servidor y reintenta hasta recibir 200 — es la única confirmación que no depende del cliente.
constructEvent verifica una firma HMAC calculada sobre los bytes exactos que Stripe envió. Si un middleware ya parseó y reserializó el JSON, los bytes cambian (orden de claves, espacios) y la verificación de firma falla siempre.
La Idempotency-Key al crear el PaymentIntent/Checkout Session: si Stripe recibe dos peticiones con la misma key, devuelve el resultado de la primera sin ejecutar la segunda.
Todo lo que envía el navegador es manipulable (cap. 15.2): un atacante podría editar el total en el JS y pagar 1 céntimo por un pedido de 200 €. El servidor recalcula el precio real desde su propia base de datos antes de crear el cargo.
Es 3D Secure 2, el mecanismo con el que Stripe cumple la exigencia de Strong Customer Authentication de la normativa PSD2. Tú no escribes ese flujo: confirmPayment() (o Checkout hospedado) ya redirige al cliente al challenge del banco y vuelve solo a tu return_url con el resultado — tu trabajo es no activar el pedido hasta confirmar el éxito por webhook.
Volver al: README.md · Relacionado: 20-autenticacion-fullstack.md, 22-proyecto-final.md, C-seguridad-backend.md