Webhooks Stripe en PHP: un controlador confiable en el que puede confiar
Un webhook Stripe que funciona en su computadora portátil aún no es un webhook en el que pueda confiarle dinero. Esta guía muestra cómo verificar firmas, reconocer rápidamente, deduplicar eventos y manejar reintentos en PHP y Laravel sin formato, con código que puede pegar en un proyecto.
En resumen
- Trate el webhook, no la redirección del navegador, como la fuente de verdad para conocer el estado del pago.
- Lea el cuerpo de la solicitud sin procesar y verifique el encabezado
Stripe-Signatureantes de tocar la carga útil. - Devuelve una respuesta 2xx rápidamente y haz el trabajo real en una cola o después de almacenar el evento.
- Almacene el ID del evento con un índice único: Stripe puede entregar el mismo evento más de una vez y fuera de orden.
- Pruebe con Stripe CLI (
stripe listenystripe trigger) antes de publicarlo.
Si aceptas pagos con Stripe, los webhooks Stripe en PHP son la parte que decide si los pedidos se marcan como pagados, se activan las suscripciones y los reembolsos se reflejan en tu base de datos. La página de pago es sólo la puerta de entrada: los clientes cierran sus cuentas, los bancos confirman tarde y algunos métodos de pago liquidan minutos o días después. El webhook es el mensaje de Stripe que dice lo que realmente sucedió.
Creamos y mantenemos integraciones de pago para clientes y para nuestros propios productos (por ejemplo , Bill, que acepta pagos Stripe y PayPal en una página de pago pública). Este artículo es la lista de verificación que seguimos cuando escribimos un punto final de webhook Stripe en PHP.
Cómo funcionan los webhooks Stripe en un minuto
Registra una URL HTTPS en el Panel Stripe (o a través de la API) y elige qué tipos de eventos recibir. Cuando sucede algo (se completa una sesión de pago, se paga una factura, se reembolsa un cargo), Stripe envía una solicitud POST con un objeto de evento JSON a esa URL. Su servidor debe responder con un código de estado 2xx. Si no es así, Stripe vuelve a intentarlo con un retroceso exponencial durante hasta tres días en modo en vivo.
Tres hechos dan forma a todo el diseño:
- Los eventos pueden llegar más de una vez. Los reintentos y los problemas de red hacen que los duplicados sean normales.
- Los eventos pueden llegar desordenados. Es posible que vea
invoice.paidantes decustomer.subscription.created. - Cualquiera puede PUBLICAR en su URL. Sin verificación de firma, un atacante puede marcar su propio pedido como pagado.
Paso 1. Instale el SDK y cree el punto final
Utilice la biblioteca oficial, stripe/stripe-php, y mantenga el punto final en secreto (comienza con whsec_) en una variable de entorno, nunca en el repositorio.
composer require stripe/stripe-php
# .env
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
Cada punto final de webhook en el Panel tiene su propio secreto de firma, y el secreto de stripe listen en desarrollo vuelve a ser diferente. Un error muy común de "fallo en la verificación de firma" es simplemente usar el incorrecto.
Paso 2. Verificar la firma en el cuerpo sin editar.
Stripe firma cada solicitud. El encabezado Stripe-Signature contiene una marca de tiempo y una o más firmas calculadas como HMAC-SHA256 de timestamp.payload con su secreto de punto final. La biblioteca comprueba la firma y rechaza los eventos de más de cinco minutos de forma predeterminada.
El detalle importante: la verificación necesita los bytes sin procesar exactos que envió Stripe. Si un marco analiza el JSON y lo vuelve a codificar, los espacios en blanco cambian y la firma ya no coincide.
<?php
// public/webhooks/stripe.php (plain PHP)
require __DIR__ . '/../../vendor/autoload.php';
$payload = file_get_contents('php://input');
$sigHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
$secret = getenv('STRIPE_WEBHOOK_SECRET');
try {
$event = \Stripe\Webhook::constructEvent($payload, $sigHeader, $secret);
} catch (\UnexpectedValueException $e) {
http_response_code(400); // invalid JSON
exit;
} catch (\Stripe\Exception\SignatureVerificationException $e) {
http_response_code(400); // bad or missing signature
exit;
}
En Laravel, lea $request->getContent() en lugar de $request->all() y excluya la ruta de la protección CSRF (los webhooks no llevan un token CSRF; la firma es la protección). Si puede, coloque la ruta fuera del grupo de middleware de sesión web.
Paso 3. Hacer que el controlador sea idempotente
Idempotente significa: procesar el mismo evento dos veces da el mismo resultado que procesarlo una vez. El enfoque confiable más simple es una tabla de ID de eventos procesados con un índice único. Insertar primero; Si la inserción falla porque el ID existe, ya habrá visto el evento.
CREATE TABLE stripe_events (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
event_id VARCHAR(64) NOT NULL,
type VARCHAR(100) NOT NULL,
payload JSON NOT NULL,
status ENUM('received','processed','failed') NOT NULL DEFAULT 'received',
received_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
processed_at TIMESTAMP NULL,
UNIQUE KEY uniq_event (event_id)
);
try {
$pdo->prepare('INSERT INTO stripe_events (event_id, type, payload) VALUES (?, ?, ?)')
->execute([$event->id, $event->type, $payload]);
} catch (PDOException $e) {
if ($e->getCode() === '23000') { // duplicate key: already received
http_response_code(200);
exit;
}
throw $e;
}
Almacenar la carga útil sin procesar le brinda un seguimiento de auditoría: cuando un cliente pregunta "¿por qué me cobraron dos veces?", puede reproducir exactamente lo que Stripe le dijo. También permite reprocesar eventos fallidos después de corregir un error.
Paso 4. Confirme rápidamente, procese por separado
Stripe espera una respuesta en unos segundos. Si su administrador envía correos electrónicos, llama a otras API o genera archivos PDF en línea, el tiempo de espera expirará, Stripe lo volverá a intentar y usted volverá a hacer el mismo trabajo. Haga el mínimo en la solicitud (verificar, almacenar, responder 200) y ejecutar la lógica real en un trabajador de cola (colas Laravel, Symfony Messenger, un trabajo cron que lee status = 'received' o un trabajador administrado por un supervisor).
Si el evento no se puede almacenar en absoluto (base de datos inactiva), devuelva un estado 5xx. Ese es el único caso en el que desea que Stripe vuelva a intentarlo más tarde.
Paso 5. Maneja los eventos que importan
No necesitas todos los tipos de eventos. Suscríbase solo a lo que utiliza su lógica empresarial; Menos suscripciones significan menos ruido y una superficie de ataque más pequeña. Un conjunto típico:
checkout.session.completed: el cliente finalizó el pago. Busque su pedido utilizandoclient_reference_ido metadatos que configuró al crear la sesión.payment_intent.succeededypayment_intent.payment_failed: para flujos de pago personalizados.invoice.paidyinvoice.payment_failed- facturación de suscripción.customer.subscription.updatedcustomer.subscription.deleted: cambios y cancelaciones de planes.charge.refundedycharge.dispute.created: dinero que regresa.
switch ($event->type) {
case 'checkout.session.completed':
$session = $event->data->object;
$orderId = $session->client_reference_id;
if ($session->payment_status === 'paid') {
markOrderPaid($orderId, $session->payment_intent);
}
break;
case 'invoice.payment_failed':
notifyBillingProblem($event->data->object->customer);
break;
case 'charge.refunded':
markOrderRefunded($event->data->object->payment_intent);
break;
default:
// unknown or unsubscribed type: acknowledge and ignore
break;
}
Dos hábitos previenen la mayoría de los errores. En primer lugar, nunca confíes en los importes del navegador: compara el importe pagado y la moneda del evento con el pedido en tu base de datos. En segundo lugar, debido a que los eventos pueden estar desordenados, haga que los cambios de estado sean monótonos: no mueva un pedido de "pagado" a "pendiente" porque un evento anterior llegó tarde. En caso de duda, obtenga el objeto actual de la API en lugar de confiar en la instantánea del evento.
Paso 6. Pruebe localmente con Stripe CLI
La Stripe CLI reenvía eventos reales en modo de prueba a su máquina, por lo que no necesita un túnel público:
stripe login
stripe listen --forward-to http://localhost:8000/webhooks/stripe
# in another terminal:
stripe trigger checkout.session.completed
stripe trigger invoice.payment_failed
stripe listen imprime un secreto whsec_ temporal. Ponlo en tu .env local. También pruebe las rutas infelices a propósito: envíe una solicitud con una firma incorrecta (espera 400), envíe el mismo evento dos veces (espera un efecto de base de datos) y detenga su trabajador de cola por un minuto para ver que los eventos se acumulan en lugar de desaparecer.
Errores comunes que vemos en las auditorías
- Utilizando la redirección como comprobante de pago. Cualquiera puede abrir la página
success_url; muestra "confirmando tu pago" hasta que llegue el webhook. - Análisis de JSON antes de la verificación. Los cuerpos recodificados rompen la firma; Los analizadores del cuerpo del marco son un culpable frecuente.
- Un secreto para probar y vivir. Los modos de prueba y en vivo tienen claves independientes y puntos finales de webhook independientes.
- Sin registro. Sin eventos almacenados y un canal de falla visible, los clientes encuentran errores de pago.
- Manejadores lentos. Cualquier cosa que dure más de unos segundos desencadena reintentos y trabajo duplicado.
- Bloqueo de Stripe en el firewall o WAF. Si usa Cloudflare u otro WAF, permita la ruta del webhook y verifique que las reglas del bot no devuelvan 403 a los servidores de Stripe.
Monitoreo: cómo saber que sigue funcionando
Un webhook puede fallar meses después del lanzamiento: un cambio implementado cambia el nombre de una columna, se rota un secreto y un certificado caduca. El Panel Stripe muestra los intentos de entrega y las fallas por punto final, y Stripe le envía un correo electrónico cuando un punto final sigue fallando. Por su parte, esté alerta sobre tres cosas: un número creciente de eventos received o failed, una caída repentina en los eventos por hora y cualquier tasa de 4xx o 5xx en la ruta del webhook. Un pequeño trabajo de conciliación diario que compara los pedidos pagados en su base de datos con la lista de pagos exitosos de Stripe capta el resto.
¿Cómo podemos ayudar?
Diseñamos y creamos integraciones de webhooks y pagos de producción con comprobaciones de firmas, idempotencia, reintentos y registros que su equipo puede leer. Si ya tiene un flujo Stripe y desea un segundo par de ojos, una revisión o consulta de arquitectura es la forma más rápida de encontrar los puntos débiles. Para obtener una visión más amplia de cómo sacar eventos críticos de Zapier o Make, lea cuando se rompe el código y, para conocer otro proveedor, consulte nuestra guía para la integración WayForPay en PHP.
Preguntas frecuentes
¿Por qué falla la verificación de la firma del webhook Stripe ?
Las causas habituales son el uso del secreto de firma incorrecto (prueba versus vivo, CLI versus punto final del panel), verificar un cuerpo analizado o recodificado en lugar del cuerpo de la solicitud sin formato, o un proxy que modifica la carga útil. Verifique con los bytes sin procesar exactos con el secreto whsec_ del punto final.
¿Cómo evito procesar un evento Stripe dos veces?
Almacene cada ID de evento en una tabla con un índice único e insértelo antes de realizar cualquier trabajo. Si la inserción falla con una clave duplicada, devuelva 200 y omita el procesamiento. También haga que la acción empresarial en sí sea segura para repetirse.
¿Qué código de respuesta debería devolver un webhook Stripe ?
Devuelve un código 2xx (200 o 204) tan pronto como se verifique y almacene el evento. Devuelve 400 para una firma no válida y 5xx solo cuando no pudiste almacenar el evento y quieres que Stripe lo vuelva a intentar.
¿Durante cuánto tiempo Stripe vuelve a intentar los webhooks fallidos?
En modo en vivo, Stripe vuelve a intentarlo con un retroceso exponencial de hasta tres días. En el modo de prueba, la ventana de reintento es mucho más corta, por lo que no confíe en los reintentos del modo de prueba para juzgar el comportamiento de producción.
¿Puedo probar los webhooks Stripe sin una URL pública?
Sí. Ejecute stripe listening --forward-to con Stripe CLI para reenviar eventos de prueba a localhost y use stripe trigger para generar eventos de muestra.
Llamada de descubrimiento gratuita
Traiga una pila desordenada o una integración estancada - Integrono trazará el primer paso más inteligente.
Obtiene una visión senior sobre trampas de presupuesto, dependencias y si conviene comprar, ensamblar o construir - antes de que empiece el trabajo de pago. El CEO sigue al tanto.