Webhooks salientes de eventos de pago

PayWL puede notificar a un endpoint propio del tenant, en tiempo real, cada vez que ocurre un evento de pago sobre cualquier suscripción: compras, renovaciones, rechazos, cancelaciones por impago y anulaciones. Esto le permite al tenant construir su propia lógica de negocio — por ejemplo, avisarle a un suscriptor que su medio de pago necesita actualizarse — sin que PayWL tenga que enviar ese correo directamente.

Mecanismo: un solo webhook por tenant, configurable en Configuración → Notificaciones del Dashboard. No requiere contactar a soporte — el propio admin del tenant activa el canal, pega su URL y genera un secreto de firma.

Prerrequisitos

  1. En el Dashboard, ir a Configuración → Notificaciones.
  2. Activar el canal Webhook.
  3. Ingresar la URL del endpoint propio (debe ser HTTPS).
  4. Generar (botón "Generar") o pegar un secreto de firma — opcional, pero muy recomendado: sin él, los envíos salen sin firmar.
  5. Guardar.

Formato del envío

Cada evento es un POST con este body:

{
  "event": "payment_declined",
  "email": "[email protected]",
  "properties": {
    "context": "renewal",
    "subscription_id": "6459649e-b0e1-4644-87e1-90dddc1b1024",
    "plan_name": "Plan Individual",
    "decline_reason": "Transaction DECLINED: Fondos insuficientes",
    "will_retry": true,
    "next_retry_date": "2026-11-01T13:00:00.000Z",
    "attempt_number": 2
  },
  "timestamp": "2026-10-30T13:38:51.854Z"
}
HeaderSiempre presenteDescripción
Content-TypeSíapplication/json
X-Webhook-EventSíMismo valor que event en el body
X-Webhook-TimestampSíMismo valor que timestamp en el body, ISO 8601
X-Webhook-SignatureSolo si se configuró un secretoVer "Verificar la firma" abajo
Timeout y reintentos: el envío tiene un timeout de 5 segundos y un solo intento — si el endpoint no responde 2xx a tiempo, el evento se descarta (queda en el log del engine, pero no hay cola de reintento ni dead-letter queue). Diseñar el endpoint receptor para responder rápido (confirmar recepción y procesar el evento de forma asíncrona del otro lado, si hace falta).

Verificar la firma

Si se configuró un secreto, cada request incluye X-Webhook-Signature: el HMAC-SHA256 del body (JSON ya serializado, exactamente como se envió), usando el secreto como clave, codificado en hexadecimal.

// Node.js
const crypto = require('crypto');

function isValid(rawBody, signatureHeader, secret) {
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));
}
Usar el body crudo (raw), no el objeto ya parseado. Volver a serializar el JSON parseado (JSON.stringify(JSON.parse(rawBody))) puede cambiar el orden de las keys o el formato de espacios y romper la verificación — hay que firmar/comparar contra los bytes exactos que llegaron.

Catálogo de eventos

Todos los eventos de pago usan properties.context (cuando aplica) para indicar el origen: new_subscription, renewal, plan_change o early_renewal.

EventoCuándo se disparaproperties principales
payment_approved Un cobro real fue aprobado: compra nueva, renovación, upgrade de plan o renovación anticipada. context, subscription_id, plan_name, amount_cents, currency, provider, external_transaction_id
payment_declined Un cobro fue rechazado. will_retry indica si todavía quedan reintentos automáticos programados (solo aplica a renovaciones). context, plan_name, amount_cents, currency, decline_reason, will_retry, next_retry_date (si will_retry=true), attempt_number
payment_retry_window_exhausted Se agotaron los reintentos automáticos de una renovación (por defecto, 7 días desde el primer rechazo). La suscripción queda PAST_DUE — todavía no cancelada. subscription_id, plan_name, attempts_made, first_failure_at
subscription_cancelled_nonpayment La suscripción fue cancelada definitivamente por falta de pago (varios días después de agotada la ventana de reintentos). subscription_id, plan_name, cancelled_at
payment_voided Un admin anuló una factura/pago desde el Dashboard (genera Nota Crédito en Siigo, si aplica). subscription_id, plan_name, amount_cents, currency, credit_note_number, reason_code

Limitaciones conocidas

  • No existe un evento de "medio de pago inválido permanentemente" (ej. una autorización retirada del lado del banco/billetera). Detectarlo automáticamente requeriría consultar el estado del medio de pago directamente contra la pasarela, algo que PayWL no hace hoy en el ciclo de reintentos. En la práctica, varios payment_declined consecutivos para la misma suscripción, seguidos de payment_retry_window_exhausted, son la señal de que probablemente se trata de esto — la decisión de tratarlo como tal queda del lado de quien integra.
  • No hay eventos de contracargo/disputa. PayWL no procesa notificaciones de chargeback de ninguna pasarela todavía.
  • Un solo intento de envío, sin cola de reintento (ver arriba) — si el endpoint receptor estuvo caído en el momento exacto del evento, ese envío puntual se pierde.

Cómo probar

  1. Crear un endpoint de prueba (por ejemplo con webhook.site) y configurarlo como la URL del webhook, con un secreto cualquiera.
  2. Forzar un evento real: una compra de prueba rechazada (tarjeta de prueba "Rechazada", ver Pagos → Cómo probar) dispara payment_declined de inmediato.
  3. Confirmar que el payload recibido trae X-Webhook-Signature y que el HMAC calculado contra el secreto configurado coincide.

Preguntas frecuentes

¿Puedo configurar más de una URL, o filtrar por tipo de evento?
No por ahora — es una URL por tenant y recibe todos los eventos de pago (más los eventos de trial que ya existían en este mismo canal: trial_ending_soon, trial_converted, etc.). Filtrar por event del lado del endpoint receptor es la forma de quedarse solo con lo que interesa.

¿Qué pasa si no configuro un secreto?
El webhook sigue funcionando, pero sin X-Webhook-Signature — cualquiera que conozca la URL podría enviarle requests falsos a tu endpoint. Se recomienda siempre configurar uno.

¿El email del suscriptor siempre viene presente?
Sí, salvo casos extremos donde no se pudo resolver (se envía como string vacío) — nunca bloquea el envío del evento.