Apariencia
Webhooks
Los webhooks son cómo proveedores externos notifican a HUMAE de eventos asíncronos: un pago completado, un correo con bounce, etc.
Endpoints expuestos
| Provider | URL | Implementado |
|---|---|---|
| Stripe | POST /api/webhooks/stripe | ✅ MVP |
(Storage y correo son self-hosted en HUMAE — no hay webhooks externos para estos dominios. El monitoreo pasa por logs locales y failed_jobs.)
Todos son públicos (sin auth:sanctum) pero autenticados por firma HMAC del provider.
Principios comunes
1. Validación de firma
Cada webhook valida la firma antes de parsear el body:
php
try {
$event = Webhook::constructEvent($payload, $sigHeader, $secret);
} catch (SignatureVerificationException) {
return response('Invalid signature', 400);
}Si falla, rechaza con 400. El provider no reintenta firmas inválidas.
2. Idempotencia
- Los providers reintentan si no reciben 2xx en ~10 segundos.
- Cada evento tiene un
idúnico. HUMAE guarda la lista de ids procesados (enRediso tablaprocessed_webhooks) para ignorar duplicados. - Patrón: verificar estado del recurso antes de mutar. Ej: si el
Paymentya estásucceeded, no crear otraMembership.
3. Respuesta rápida
- Webhook debe responder en menos de 10 segundos (límite Stripe).
- Si el trabajo es pesado, encolarlo:
ProcessStripeEventJob::dispatch($event)y responder 200 inmediatamente. - Por ahora, el trabajo es suficientemente rápido para hacerlo sincrono.
4. Rate limit
throttle:60,1— máximo 60 requests por minuto por IP.- Previene floods accidentales.
- Los providers no exceden esto en operación normal.
Webhook de Stripe (detallado)
URL
- Prod:
https://api.humae.com.mx/api/webhooks/stripe - Dev: expuesto via
stripe listen --forward-to localhost:8000/api/webhooks/stripe
Configuración en Stripe
Dashboard → Developers → Webhooks → Add endpoint:
- URL: como arriba
- Events:
checkout.session.completed✅ (MVP)payment_intent.payment_failed(Fase 2)charge.refunded(Fase 2)
- API version: latest
Copiar Signing secret → STRIPE_WEBHOOK_SECRET en .env.
Flujo completo
Candidato paga en Stripe Checkout
│
▼
Stripe:
- Valida tarjeta
- Procesa cobro
- Emite checkout.session.completed
│
▼
Stripe POST /api/webhooks/stripe
Headers:
Stripe-Signature: t=123,v1=abc...
Body:
{ id: "evt_...", type: "checkout.session.completed",
data: { object: { id: "cs_...", payment_status: "paid", ... } } }
│
▼
Backend:
1. Lee raw body + header
2. Webhook::constructEvent(body, sig, secret)
→ valida HMAC, si falla devuelve 400
3. Lee event.type
4. Si == 'checkout.session.completed':
a. Extrae session = event.data.object
b. MembershipService::activateFromCheckoutSession(session)
- DB::transaction:
- Busca Payment por stripe_session_id
- Si status == succeeded, return (idempotencia)
- Crea Membership con expires_at = now + 180 days
- Actualiza Payment → succeeded, paid_at, stripe_payment_intent_id
- Envía MembershipActivatedNotification
5. Devuelve 200 OKQué datos extraemos
Del CheckoutSession:
| Campo | Origen Stripe |
|---|---|
stripe_session_id | session.id |
stripe_payment_intent_id | session.payment_intent |
stripe_customer_id | session.customer |
amount | session.amount_total / 100 |
currency | session.currency |
metadata.user_id, metadata.membership_plan_id | que enviamos al crear la sesión |
Tests de integridad
tests/Feature/Api/V1/Membership/WebhookTest.php:
- ✅ Webhook con firma válida activa membresía.
- ✅ Webhook con firma inválida responde 400.
- ✅ Webhook disparado dos veces → solo una membresía creada (idempotencia).
- ✅ Sesión con
payment_status != paidno activa.
Se mockea StripeClient con $this->app->instance(StripeClient::class, $fakeClient).
Telemetría de correo (Fase 2 — sin webhooks externos)
HUMAE envía con Postfix local, así que no hay webhook de un SaaS. La telemetría se construye parseando /var/log/mail.log:
| Evento | Fuente | Acción |
|---|---|---|
status=sent | mail.log | Marcar EmailLog.status = sent |
status=bounced | mail.log | Marcar "no entregable"; evitar reenviar |
| DSN entrante (rebote 5xx) | local mailer | Parsear y marcar complained/bounced |
Modelo propuesto
EmailLog
├── id
├── notification_id (UUID, ref al database notification)
├── recipient_email
├── status (queued, sent, bounced, deferred)
├── postfix_queue_id (ej. "4Xyz123..." del header Message-ID)
├── sent_at, bounced_at
├── created_atImplementación: un job IngestMailLogJob lee /var/log/mail.log (rotativo) y actualiza EmailLog por postfix_queue_id.
Manejo de errores
Webhook falla (excepción en el service)
- Se loguea con
Log::error('[StripeWebhook] failed', ['event_id' => $eventId, 'error' => $e]). - Devuelve 500 a Stripe → Stripe reintenta automáticamente.
- Alerta en Sentry si hay DSN configurado.
- Admin puede reintentar manualmente desde
/admin/webhooks(Fase 2).
Webhook llega pero el evento es desconocido
php
Log::info('[StripeWebhook] ignored event', ['type' => $event->type]);
return response('', 200);Devuelve 200 para que Stripe no reintente. Nueva regla: si agregamos más eventos, actualizar el switch.
Monitoreo
Logs
Cada webhook exitoso/fallido escribe un log entry:
[2026-04-19 10:15:32] production.INFO: [StripeWebhook] checkout.session.completed processed. session=cs_test_... payment_id=42Dashboard admin (Fase 2)
/admin/webhooks:
- Tabla de webhooks recibidos (últimos 30 días).
- Filtros por provider, event type, status.
- Botón "Reintentar" para webhooks que fallaron.
- Gráfico de success rate.
Alertas
Fase 2:
- Si bounce rate en
mail.log> 5% en 1 hora → alert a admin. - Si 5+ webhooks de Stripe fallan consecutivos → alert.
- Si
mailq> 50 mensajes → alert (Postfix no está drenando la cola).
Seguridad
- Secrets en
.env, nunca en repo. - Rotar secrets cada 90 días.
- Logs NO incluyen el payload completo (puede tener PII); solo metadata clave.
- Webhooks solo se aceptan en HTTPS (en prod). En dev HTTP está OK vía
stripe listen.
Siguiente
Sección de referencia consolidada: Máquinas de estado →

