Webhooks

Los webhooks notifican a tu aplicación en tiempo real cuando ocurren eventos — pagos, cambios de suscripción, reembolsos y más. En lugar de consultar el estado de un pago una y otra vez, registrás una URL y Recurrente le hace un POST cada vez que pasa algo.

Consulta el catálogo completo de eventos para ver cuándo se emite cada webhook, su esquema y un payload de ejemplo.

Integrarte toma cuatro pasos: registrás tu endpoint, manejás el evento, verificás la firma y lo probás.

1. Registra tu endpoint

Un endpoint es una URL de tu servidor donde querés recibir las notificaciones (ej. https://tusitio.com/webhooks/recurrente).

Vía Dashboard

Dentro de tu cuenta de Recurrente, ve a ConfiguraciónDesarrolladores y API, haz clic en “Webhooks”, y añade la URL a donde quieres que sean enviados los requests.

Vía API

$curl -X POST https://app.recurrente.com/api/webhook_endpoints \
> -H "X-SECRET-KEY: ..." \
> -H "Content-Type: application/json" \
> -d '{
> "url": "https://tusitio.com/webhooks/recurrente",
> "description": "Webhook de producción"
> }'

Podés registrar varias URLs (producción, staging, logging); cada evento se envía a todas.

2. Maneja el evento

Cada webhook entrega un evento en formato JSON con esta estructura. Tu handler ramifica según el campo type para saber qué método de pago lo generó, y según status para saber qué pasó.

El payload tiene tres capas:

Envelope — los campos de identificación del evento:

  • type — el tipo de pago: payment, bank_transfer, crypto, balance, cash.
  • event_type — el evento: intent.succeeded, intent.failed, intent.pending, intent.canceled, intent.paid.
  • status — el estado: pending, succeeded, failed, canceled, paid. Algunos estados intermedios (p. ej. de tarjeta) se exponen como pending; el valor exacto del procesador siempre está en raw_status.
  • raw_status, id, receipt_number, api_version, created_at.

Datos del pagoamount_in_cents, currency, customer, product, tax_invoice_url, checkout, payment.

details — campos específicos del método de pago. Para payment: comisiones, cuotas, canal, etc. Para bank_transfer: bank_reference (referencia asignada por el banco o la red de pago) y sender_comment (comentario libre que el pagador escribió en la transferencia).

Ejemplo (intent.succeeded, pago con tarjeta):

1{
2 "type": "payment",
3 "event_type": "intent.succeeded",
4 "api_version": "2026-06-01",
5 "id": "pi_id123",
6 "receipt_number": 100482,
7 "status": "succeeded",
8 "raw_status": "succeeded",
9 "created_at": "2026-06-01T14:03:21Z",
10 "customer_id": "cus_id123",
11 "user_id": "usr_id123",
12 "customer": {
13 "id": "cus_id123",
14 "email": "cliente@example.com",
15 "full_name": "Ana Lopez",
16 "nit": "CF",
17 "phone": "+50255551234"
18 },
19 "amount_in_cents": 25000,
20 "currency": "GTQ",
21 "product": { "id": "prod_id123" },
22 "tax_invoice_url": null,
23 "checkout": { "id": "ch_id123", "status": "paid" },
24 "payment": { "id": "pa_id123" },
25 "details": {
26 "failure_reason": null,
27 "fee": 875,
28 "tax_invoicing_fee": 0,
29 "vat_withheld": 0,
30 "vat_withheld_currency": "GTQ",
31 "used_presaved_payment_method": false,
32 "installments": 1,
33 "channel": "Tarjeta",
34 "products": [{ "id": "prod_id123", "quantity": 1 }]
35 }
36}

El envelope y los datos del pago son iguales para todos los tipos; solo cambian type y el contenido de details. Así, un solo handler cubre todos los métodos de pago:

1app.post("/webhooks/recurrente", (req, res) => {
2 const intent = req.body
3
4 switch (intent.type) {
5 case "payment": /* intent.details.fee, .installments, ... */ break
6 case "bank_transfer": /* intent.details.bank_reference, .sender_comment */ break
7 case "crypto":
8 case "balance":
9 case "cash": break
10 }
11
12 // estado: siempre intent.status ∈ { pending, succeeded, failed, canceled, paid }
13 res.sendStatus(200)
14})

También podés consultar cualquier pago por API con GET /api/intents/{id} — devuelve exactamente el mismo formato que el webhook.

3. Verifica la firma

Cada webhook incluye una firma para que verifiques que viene de Recurrente y no fue alterado. Recomendamos fuertemente verificar las firmas en producción.

Signing Secret

Cada endpoint tiene un signing secret único, con el formato whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw. Hay dos formas de obtenerlo:

  • Vía API: el campo signingSecret se devuelve en la respuesta al crear el endpoint (POST /api/webhook_endpoints). Solo se devuelve en esa respuesta de creación, así que guárdalo de forma segura.
  • Vía dashboard: lo puedes encontrar en cualquier momento en el dashboard de Svix — accesible desde Configuración → Desarrolladores y API → Webhooks en tu cuenta de Recurrente.

Verificar con Librerías Oficiales (Recomendado)

La forma más fácil de verificar firmas es usando las librerías de verificación de webhooks de Svix, disponibles para muchos lenguajes:

1// npm install svix
2const { Webhook } = require("svix");
3
4const wh = new Webhook(secret);
5// Lanza error si falla, retorna el contenido verificado si es exitoso
6const payload = wh.verify(rawBody, headers);

Debes usar el body crudo del request al verificar webhooks. Si parseas el JSON y lo re-serializas, la verificación de firma fallará.

Verificar Manualmente

Cada request de webhook incluye tres headers:

HeaderDescripción
svix-idIdentificador único del mensaje (se mantiene igual en reintentos)
svix-timestampTimestamp Unix (segundos) de cuando se envió el webhook
svix-signatureFirma(s) codificada(s) en Base64, con prefijo v1,

Paso 1. Construye el contenido firmado concatenando el svix-id, svix-timestamp, y el body crudo del request, separados por puntos:

signed_content = "${svix_id}.${svix_timestamp}.${body}"

Paso 2. Decodifica la porción en base64 de tu signing secret (todo después de whsec_) y úsalo como llave para un hash HMAC-SHA256 del contenido firmado:

1const crypto = require("crypto");
2
3const secretBytes = Buffer.from(secret.split("_")[1], "base64");
4const signature = crypto
5 .createHmac("sha256", secretBytes)
6 .update(signedContent)
7 .digest("base64");

Paso 3. Compara tu firma calculada con la del header svix-signature. El valor del header tiene el prefijo v1, — quita ese prefijo antes de comparar. Usa una comparación de tiempo constante para prevenir ataques de timing.

Paso 4. Opcionalmente, verifica que el svix-timestamp esté dentro de unos minutos del tiempo actual de tu servidor para prevenir ataques de replay.

4. Pruébalo

El modo test envía webhooks simulados sin mover dinero. Pagar un checkout de prueba con la tarjeta 4242 4242 4242 4242 envía payment_intent.succeeded e intent.succeeded (type: payment) solo a los endpoints registrados con llaves TEST.

También puedes abrir el dashboard de webhooks y usar el tab Testing para enviar a tu endpoint un ejemplo de cualquiera de los eventos del catálogo. El mensaje muestra el payload, los intentos y si la entrega fue exitosa. Los pagos de prueba solo simulan tarjeta; para ejercitar el procesamiento real de otros métodos, usa el ambiente correspondiente.

Tipos de eventos

Eventos de pago (intent.*)

EventoCuándo se emite
intent.succeededCobro exitoso (cualquier método de pago). Los fondos ya están en tu balance.
intent.pendingCobro iniciado o pendiente (transferencia, cripto).
intent.failedCobro fallido.
intent.canceledIntent cancelado.
intent.paidSolo para pagos con balance: le llega a la cuenta pagadora (la cuenta cuyo balance fue debitado), si tiene un endpoint configurado.

Un mismo cobro con balance dispara dos webhooks independientes: intent.succeeded (type: balance) a la cuenta del comercio y intent.paid a la cuenta pagadora.

Eventos de suscripción

EventoCuándo se emite
subscription.createSe crea una suscripción. Si el producto es recurrente, se emite además del intent.succeeded con la información de la suscripción.
subscription.past_dueEl cobro automático de una suscripción falla por primera vez.
subscription.pauseSe pausa la suscripción. Una suscripción pausada no se vuelve a cobrar hasta que sea reactivada.
subscription.unpauseSe reanuda una suscripción pausada.
subscription.reactivateSe reactiva una suscripción cancelada dentro del período permitido.
subscription.updateCambia información general, como el método de pago.
subscription.item_added, subscription.item_removedSe agrega o remueve un producto recurrente.
subscription.invoice_item_addedSe agrega un cargo único a la próxima factura.
subscription.cancelEl cobro automático falla por tercera vez.

Nota: en una suscripción, cuando un pago falla, Recurrente intenta cobrarlo de nuevo 3 y 5 días después. Si ambos re-intentos son fallidos, en ese momento se cancela la suscripción.

Otros eventos

EventoCuándo se emite
setup_intent.succeededSe inicia exitosamente una suscripción con período de prueba, o se tokeniza una tarjeta sin cobrarla.
setup_intent.cancelledNo se logra tokenizar una tarjeta sin cobrarla (p. ej. el primer pago de una suscripción con período de prueba falla).
payment_intent.*, bank_transfer_intent.*, automated_bank_transfer_intent.succeeded, balance_intent.*, crypto_intent.*, cash_intent.succeededEventos legacy por método de pago.
refund.createReembolso procesado.
dispute.create, dispute.updateEventos de contracargo. Ver Ciclo de vida de contracargos.
account_connection.createUna cuenta hija se conecta a una plataforma.
partner_commission_invoice.issued, partner_commission_invoice.failedEl DTE diario de comisiones fue emitido o llegó a una falla terminal.

Eventos de movimiento de dinero

EventoCuándo se emite
transfer.sent, transfer.receivedTransferencia p2p enviada / recibida.
withdrawal.create, withdrawal.updateRetiro bancario creado / cambio de estado.
swap.create, swap.updateEnvío de stablecoin creado / cambio de estado.

Los eventos withdrawal.* y swap.* incluyen una llave transfer con la misma forma unificada que devuelve GET /api/transfers/{id} (los transfer.* ya la usan como forma principal). Si integras con el endpoint unificado, lee siempre esa llave — ver la guía Mover dinero.

Reintentos

Si tu endpoint no responde con un código 2xx, Recurrente reintentará enviar el webhook:

  • Inmediatamente después del primer fallo
  • 1 minuto después
  • 5 minutos después
  • 30 minutos después
  • 2 horas después
  • 6 horas después

Mejores Prácticas

  • Retorna 2xx rápidamente — Confirma la recepción antes de hacer procesamiento pesado
  • Maneja duplicados — Los webhooks pueden ser entregados más de una vez; usa el ID del evento para idempotencia
  • Verifica la fuente — Valida las firmas de los webhooks como se describe arriba
  • Procesa de forma asíncrona — Usa una cola para procesamiento pesado después de confirmar recepción

¿Integraste antes de junio 2026 con los eventos por tipo (payment_intent.*, bank_transfer_intent.*, etc.)? Siguen funcionando sin cambios — ver Eventos legacy o la guía para migrar al formato unificado.