Webhooks de Stripe en PHP
Qué es un webhook de Stripe: un POST que Stripe envía a una URL tuya cada vez que algo relevante sucede en tu cuenta: un pago se acreditó, una suscripción se renovó, una disputa se abrió, una tarjeta guardada está por vencer. El payload viene firmado con un secreto compartido para que tu servidor pueda comprobar que la petición vino de Stripe y no de alguien que adivinó la URL. En PHP esa verificación es una sola llamada al SDK; lo que consume tiempo es la fontanería alrededor.
Un endpoint de producción tiene tres piezas: una ruta HTTP que lee el body crudo de la petición, una llamada a Stripe\Webhook::constructEvent() que rechaza todo lo que tenga firma inválida, y un dispatcher que devuelve 200 rápido y hace el trabajo pesado de forma asíncrona. Si alguna de esas tres falla, los bugs son silenciosos (eventos aceptados pero nunca procesados) o ruidosos a las 3 a.m. (Stripe reintenta durante tres días, tus logs se llenan de fallos de firma que solo ves cuando alguien se queja).
Esta guía cubre el endpoint completo, las trampas de body en Laravel y Symfony, reintentos e idempotencia, y pruebas locales con Stripe CLI. Asume que ya tienes el SDK instalado y las claves configuradas; si no, empieza por la integración de pagos con Stripe.
Endpoint mínimo funcional
Un solo archivo, sin framework, sin cola. Este ejemplo muestra la ceremonia mínima que Stripe necesita antes de que tu lógica de negocio se ejecute:
<?php
require __DIR__ . '/vendor/autoload.php';
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
$secret = getenv('STRIPE_WEBHOOK_SECRET');
try {
$event = \Stripe\Webhook::constructEvent($payload, $signature, $secret);
} catch (\UnexpectedValueException $e) {
http_response_code(400);
exit('Payload inválido');
} catch (\Stripe\Exception\SignatureVerificationException $e) {
http_response_code(400);
exit('Firma inválida');
}
// Firma verificada, $event es un objeto Stripe\Event
error_log("Recibido {$event->type} ({$event->id})");
http_response_code(200);
Cuatro cosas a notar.
php://input es el body crudo, sin parsear. Si lees el body de otra forma, la verificación de firma falla. Esa frase es la mayor fuente de bugs de webhook en PHP, y la mitad de este artículo trata sobre las maneras en que los frameworks dificultan respetarlo.
constructEvent lanza dos excepciones distintas. UnexpectedValueException significa que el body no es JSON válido: probablemente un health check o alguien curioseando tu URL. SignatureVerificationException significa que el JSON parseó pero la firma no coincide: el secreto es incorrecto, el endpoint equivocado, o algo está modificando el body en tránsito. Registra cada una de forma diferente.
La respuesta 200 sale antes de cualquier operación lenta. Stripe espera un 2xx antes de tu lógica compleja. Si tardas doce segundos escribiendo en tres bases de datos y enviando dos emails, Stripe deja de esperar, marca la entrega como fallida, y reintenta; ahora tienes dos eventos que deduplicar.
$event->type y $event->id son los dos campos que usarás en cada handler. El tipo enruta al handler, el ID es tu clave de idempotencia.
Dónde obtener el secreto del webhook
En el panel de Stripe, bajo Developers > Webhooks. Crea un endpoint, pega tu URL pública, selecciona los eventos que quieres recibir (empieza con pocos: payment_intent.succeeded, charge.refunded, lo que corresponda a tu dominio; siempre puedes agregar más), y Stripe te da un secreto que empieza con whsec_.
Los modos test y live tienen paneles separados y secretos separados. Cuando cambias tu STRIPE_SECRET_KEY de test a live, también cambias STRIPE_WEBHOOK_SECRET, y cada entorno necesita su propio par. Mezclarlos produce la misma SignatureVerificationException que un payload manipulado, y el mensaje de error no menciona el modo.
Varios endpoints en una misma cuenta tienen secretos diferentes. Si separas recibos y suscripciones en dos URLs, cada una tiene su propio whsec_. No intentes compartir una variable de entorno entre ambos; configúralos por separado.
Body crudo: las trampas de Laravel y Symfony
Ambos frameworks tienen middleware que parsea el JSON entrante a un array antes de que tu controlador lo vea. Normalmente es útil. Para un webhook de Stripe es fatal: la firma se calcula sobre la secuencia de bytes exacta que Stripe envió, y json_decode($body) seguido de json_encode($array) no es byte-idéntico al original (espacios, orden de claves, precisión numérica difieren).
Laravel. El body crudo sigue disponible vía $request->getContent() (string) incluso después de que el framework haya poblado $request->all(). Esa cadena es la que pasas a constructEvent:
use Illuminate\Http\Request;
public function handle(Request $request)
{
$payload = $request->getContent();
$signature = $request->header('Stripe-Signature', '');
$secret = config('services.stripe.webhook_secret'); // STRIPE_WEBHOOK_SECRET en .env
try {
$event = \Stripe\Webhook::constructEvent($payload, $signature, $secret);
} catch (\UnexpectedValueException $e) {
abort(400, 'JSON inválido');
} catch (\Stripe\Exception\SignatureVerificationException) {
abort(400, 'Firma inválida');
}
// dispatch según $event->type
return response('', 200);
}
Excluye la ruta de la protección CSRF. VerifyCsrfToken devuelve 419 mucho antes de que constructEvent tenga oportunidad de ejecutarse. En Laravel 11+ esto se configura en bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: [
'stripe/webhook', // ruta exacta
'stripe/*', // o wildcard para rutas anidadas
]);
})
Mantén la ruta fuera de cualquier middleware de autenticación. Stripe no envía cookies de sesión ni tokens bearer, y los redirects de auth rompen el body crudo igual que el CSRF.
Symfony. El body crudo es $request->getContent() (mismo nombre de método, diferente clase: Symfony\Component\HttpFoundation\Request). getContent() se puede llamar varias veces sin problema. La trampa no es el consumo sino que $request->toArray() seguido de json_encode() no será byte-idéntico a lo que Stripe firmó. Pasa la cadena cruda a constructEvent, no el array re-encodificado.
Idempotencia: Stripe reintenta durante tres días
Stripe intenta entregar cada evento durante hasta tres días con backoff exponencial en modo live, o tres veces en unas horas en sandbox. Los reintentos ocurren con cualquier respuesta no-2xx, cualquier timeout, y ocasionalmente con una entrega exitosa que el sistema de Stripe no pudo confirmar. La consecuencia es inevitable: tu handler verá el mismo event.id más de una vez, y “procesar el evento” tiene que significar “procesarlo como máximo una vez sin importar cuántas veces llegue”. Cualquier handler no trivial necesita tratar event.id como clave de deduplicación:
function handleEvent(\Stripe\Event $event, \PDO $db): void
{
// Insert-or-ignore sobre event.id. La PK es el ID del evento de Stripe.
// Sintaxis PostgreSQL abajo; en MySQL usa INSERT IGNORE INTO ...,
// en SQLite la forma ON CONFLICT funciona tal cual.
$stmt = $db->prepare(
'INSERT INTO processed_webhooks (event_id, type, processed_at)
VALUES (:id, :type, NOW())
ON CONFLICT (event_id) DO NOTHING'
);
$stmt->execute(['id' => $event->id, 'type' => $event->type]);
if (0 === $stmt->rowCount()) {
return; // ya procesado, nada que hacer
}
match ($event->type) {
'payment_intent.succeeded' => onPaymentSucceeded($event->data->object),
'charge.refunded' => onRefund($event->data->object),
'customer.subscription.deleted' => onSubscriptionCancelled($event->data->object),
default => null,
};
}
El patrón: insertar primero, lógica de negocio solo si el insert fue exitoso. La deduplicación es atómica con tu base de datos, no una carrera de check-then-act en Redis.
Para webhooks que llegan a un fleet con balanceador de carga, la misma tabla y constraint unique funcionan entre máquinas. Para cuentas de alto volumen, particiona la tabla por mes y purga las particiones viejas; los reintentos de Stripe solo van tres días, así que la unicidad de event_id más allá de un mes es peso muerto.
Respuesta 200 rápida, trabajo real en cola
El patrón de dos fases:
- En el handler HTTP: verificar firma, persistir
event.idy payload completo en tu BD o cola, devolver200de inmediato. - En un worker: tomar el registro, despachar según
event.type, hacer el trabajo real con reintentos de tu lado.
La separación importa porque tu código lento es tu problema, no de Stripe. Gateway de email caído? Tu cola reintenta. Migración de base de datos bloqueó una tabla por sesenta segundos? Tu worker espera, Stripe no. El endpoint de webhook no hace nada que pueda fallar más allá de la verificación de firma.
Laravel con cola:
public function handle(Request $request)
{
$payload = $request->getContent();
$signature = $request->header('Stripe-Signature', '');
try {
$event = \Stripe\Webhook::constructEvent(
$payload, $signature, config('services.stripe.webhook_secret')
);
} catch (\UnexpectedValueException $e) {
abort(400);
} catch (\Stripe\Exception\SignatureVerificationException) {
abort(400);
}
ProcessStripeEvent::dispatch($event->id, $event->type);
return response('', 200);
}
Un detalle importante de producción: no serialices el objeto Stripe\Event completo en el payload del job. Arrastra todo el grafo de objetos anidados (datos del cliente, método de pago, dirección de facturación) hacia Redis o tu tabla de cola en MySQL. Envía solo el event ID y el type, y en el worker re-obtén de Stripe con \Stripe\Event::retrieve($id). Los datos personales se quedan del lado de Stripe hasta que el worker los necesite.
Trabajar con el payload del evento
Cada evento tiene el mismo sobre:
{
"id": "evt_1ABCDEf2ghIJK3LmNOPQr456",
"type": "payment_intent.succeeded",
"created": 1715775847,
"data": {
"object": {}
},
"livemode": false,
"api_version": "2025-03-31.basil"
}
data.object es el recurso del evento: un PaymentIntent, un Charge, un Customer, una Subscription. En el SDK lo accedes como $event->data->object, y el IDE lo autocompleta si indexa el SDK.
Para pagos únicos, los eventos a manejar son payment_intent.succeeded, payment_intent.payment_failed, charge.refunded y charge.dispute.created (este último es el que desearás haber manejado antes del primer contracargo). Las integraciones con Checkout agregan checkout.session.completed y, para métodos diferidos como SEPA, checkout.session.async_payment_succeeded. La facturación de suscripciones es la superficie más verbosa: customer.subscription.created/updated/deleted, más invoice.paid e invoice.payment_failed para el movimiento real del dinero. Y customer.source.expiring te avisa una semana antes de que una tarjeta guardada deje de funcionar, el evento que silenciosamente salva la tasa de retención.
Suscríbete en el Dashboard solo a los eventos que realmente manejas en código; suscribirte a todo hace ilegible el log de eventos al depurar un flujo concreto. La lista completa está en docs.stripe.com/api/events/types.
Metadata es el puente con tu dominio. Cuando creas un PaymentIntent, Checkout Session o Subscription, adjunta un array metadata con tus IDs internos:
$stripe = new \Stripe\StripeClient(getenv('STRIPE_SECRET_KEY'));
$intent = $stripe->paymentIntents->create([
'amount' => 2500,
'currency' => 'usd',
'metadata' => ['order_id' => (string) $order->id],
]);
Esa metadata viaja con cada evento sobre ese intent. En el webhook:
$orderId = $event->data->object->metadata['order_id'] ?? null;
Sin lookup en base de datos por ID de Stripe, sin joins frágiles. Los valores de metadata tienen que ser strings; haz cast explícito a los enteros.
El orden de los eventos no está garantizado
La documentación de Stripe lo advierte: el orden de entrega no está garantizado. En la práctica:
customer.subscription.updatedpuede llegar antes quecustomer.subscription.createdpara la misma suscripción.charge.refundedpuede aterrizar antes quecharge.succeededsi miras el log de webhooks con atención.
Para la mayoría de los flujos de negocio no importa: tu dedup por event.id más un retrieve() en el handler (para obtener el estado actual del objeto) te hace independiente del orden. Si realmente necesitas ordenamiento, el patrón es: en cada evento, consulta el recurso en el API y actúa sobre su estado actual, no sobre el snapshot del evento:
case 'customer.subscription.updated':
// No confiar en el snapshot del evento; obtener la verdad actual
$subscription = $stripe->subscriptions->retrieve($event->data->object->id);
updateLocalStatus($subscription);
break;
Una llamada extra al API; vale la pena cuando las transiciones de estado realmente importan.
Pruebas locales con Stripe CLI
No puedes registrar http://localhost:8000/stripe/webhook como endpoint de Stripe: necesita una URL accesible públicamente. La respuesta actual es Stripe CLI, que abre un túnel específicamente para webhooks sin exponer todo tu servidor local a internet:
# Login (abre el navegador para vincular el CLI con tu cuenta)
stripe login
# Reenviar eventos a tu endpoint local
stripe listen --forward-to localhost:8000/stripe/webhook
El CLI imprime un secreto de firma en cuanto arranca, diferente al del Dashboard y específico de esta sesión de reenvío. Configúralo en tu entorno local:
STRIPE_WEBHOOK_SECRET=whsec_xxx_del_cli
Desde otra terminal, dispara eventos sintéticos:
stripe trigger payment_intent.succeeded
stripe trigger charge.refunded
stripe trigger customer.subscription.deleted
Cada uno llega a tu endpoint con un payload completo y correctamente firmado. Puedes ejecutar toda tu lógica de handler, incluyendo escrituras en BD y despacho a colas, contra estos eventos.
Para eventos basados en tiempo (renovaciones de suscripción, fin de trial, cancelación al final del período), stripe trigger no alcanza porque esos solo se disparan cuando avanza el tiempo real. Usa test clocks para simular el tiempo:
# Crear un test clock desde un timestamp dado
stripe test_helpers test_clocks create --frozen-time 1735689600
# Avanzar 35 días para cruzar un ciclo mensual
stripe test_helpers test_clocks advance <clock_id> --frozen-time 1738713600
Adjunta el clock a un Customer al crearlo, y cada evento de facturación que produzca ese recurso sigue el tiempo simulado.
Trampas de proxy reverso y edge network
El contrato del body crudo se extiende más allá de tu código PHP. Cualquier cosa entre Stripe y tu handler que modifique los bytes rompe la firma.
Cloudflare y CDNs similares pueden comprimir con gzip o minificar respuestas por defecto. Lo que hay que verificar es si tu plan aplica transformaciones al body de peticiones entrantes. Para la ruta del webhook, agrega una Configuration Rule de Cloudflare que desactive las optimizaciones y deje el body intacto.
nginx proxy_pass suele ser transparente, pero settings de buffering combinados con módulos como sub_filter o un WAF ocasionalmente alteran los bytes. Si las verificaciones de firma pasan en local pero fallan detrás del proxy, revisa proxy_buffering, proxy_request_buffering y cualquier directiva de reescritura de body en la ruta del webhook.
Para diagnosticar un fallo de firma en producción: registra los primeros 50 bytes de $payload junto con el header de firma, y compáralos con el payload en los logs del Dashboard para ese evento. Si los bytes difieren, algo en tu infraestructura está reescribiendo el body.
Versión del API y webhook
La forma de los payloads de eventos está atada a la versión del API fijada en el endpoint del Dashboard, no a la que configures en tu código SDK. Si tu endpoint está fijado en 2020-08-27 y tus llamadas al SDK usan 2025-03-31.basil, los eventos que Stripe te envía tienen la forma de 2020, aunque tus llamadas salientes usen 2025.
En la práctica: los nombres de campos y el anidamiento que ves en la documentación actual pueden no coincidir con lo que tu endpoint recibe. Verifica la versión en Developers > Webhooks > [tu endpoint] > API version. Para un endpoint nuevo, fija la misma versión que usas en tus llamadas salientes y mantenlas sincronizadas. Más detalles sobre versiones en la referencia del API de Stripe en PHP.
Errores comunes
VerifyCsrfToken devuelve 419. Específico de Laravel. Excluye la ruta del webhook del CSRF; revisa la sección de Laravel arriba.
SignatureVerificationException en cada petición. Tres causas habituales en orden de frecuencia: el endpoint usa el whsec_ incorrecto (mezcla test/live o copy-paste del endpoint equivocado), un middleware está re-encodificando el body, o un proxy lo está modificando. Registra los primeros bytes de $payload y compáralos con el log del Dashboard.
Respuesta 200 pero los eventos se marcan como “Failed” en el Dashboard. Tu endpoint tarda demasiado. Devuelve 200 antes de que tu handler haga trabajo real.
Respuesta 403 en el endpoint. Tu servidor o un middleware de autenticación bloquea la petición antes de que llegue al handler. Verifica que la ruta no esté detrás de auth middleware y que tu firewall o WAF no filtre peticiones sin cookies.
Los eventos llegan desordenados. Ya cubierto: no confíes en el snapshot del evento, consulta el estado actual desde el API.
La guía de errores comunes de Stripe en PHP cubre también los códigos HTTP de webhook (307, 400, 403, 404, 405, 500) y los errores TLS.
Qué sigue
El endpoint de webhook es la forma de enterarte de resultados que ocurren fuera de tu ciclo request-response: cuando un débito SEPA se acredita cuatro días después del checkout, cuando una suscripción se renueva el próximo mes, cuando un cliente disputa un cobro seis semanas después. Con el endpoint en su lugar, agregar un nuevo tipo de evento es un cambio de una línea en tu dispatch map.
Si prefieres no mantener un dispatcher a mano, spatie/laravel-stripe-webhooks envuelve todo el patrón (verificación de firma, Job dispatch por tipo de evento) en un paquete Laravel.
Para la lógica de las comisiones que Stripe cobra por transacción, la guía de pricing desglosa los fees en código.
¿Has visto algo inexacto en esta página?
Reportar un error