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.
Prerrequisitos
- En el Dashboard, ir a Configuración → Notificaciones.
- Activar el canal Webhook.
- Ingresar la URL del endpoint propio (debe ser HTTPS).
- Generar (botón "Generar") o pegar un secreto de firma — opcional, pero muy recomendado: sin él, los envíos salen sin firmar.
- 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"
}
| Header | Siempre presente | Descripción |
|---|---|---|
Content-Type | Sí | application/json |
X-Webhook-Event | Sí | Mismo valor que event en el body |
X-Webhook-Timestamp | Sí | Mismo valor que timestamp en el body, ISO 8601 |
X-Webhook-Signature | Solo si se configuró un secreto | Ver "Verificar la firma" abajo |
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));
}
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.
| Evento | Cuándo se dispara | properties 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_declinedconsecutivos para la misma suscripción, seguidos depayment_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
- Crear un endpoint de prueba (por ejemplo con webhook.site) y configurarlo como la URL del webhook, con un secreto cualquiera.
- Forzar un evento real: una compra de prueba rechazada (tarjeta de prueba "Rechazada", ver Pagos → Cómo probar) dispara
payment_declinedde inmediato. - Confirmar que el payload recibido trae
X-Webhook-Signaturey 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.