Skip to content

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

ProviderURLImplementado
StripePOST /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 (en Redis o tabla processed_webhooks) para ignorar duplicados.
  • Patrón: verificar estado del recurso antes de mutar. Ej: si el Payment ya está succeeded, no crear otra Membership.

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 secretSTRIPE_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 OK

Qué datos extraemos

Del CheckoutSession:

CampoOrigen Stripe
stripe_session_idsession.id
stripe_payment_intent_idsession.payment_intent
stripe_customer_idsession.customer
amountsession.amount_total / 100
currencysession.currency
metadata.user_id, metadata.membership_plan_idque 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 != paid no 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:

EventoFuenteAcción
status=sentmail.logMarcar EmailLog.status = sent
status=bouncedmail.logMarcar "no entregable"; evitar reenviar
DSN entrante (rebote 5xx)local mailerParsear 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_at

Implementació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=42

Dashboard 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 →

Manual de usuario HUMAE · Uso interno