> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.recurrente.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.recurrente.com/_mcp/server.

# Webhooks

> Recibe notificaciones en tiempo real sobre eventos

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](/referencia-api/api-reference/webhook-events/intent-succeeded-webhook) 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.

### Cuentas conectadas: verificación y FEL

* `verification.completed`: la verificación de la cuenta fue aceptada.
* `verification.rejected`: uno o más documentos fueron rechazados; revisa `document_errors`.
* `tax_invoice_settings.enabled`: FEL terminó de activarse y está listo para emitir. El campo `tax_invoicing` contiene el estado público, sin credenciales del proveedor.

La plataforma recibe estos eventos con `connected: true` y el `account_id` de la cuenta hija. Los eventos de aceptación y activación conservan el estado al ocurrir la transición; para el estado actual de FEL consulta `GET /connected_accounts/{id}/tax_invoicing`. No se emiten de nuevo por guardar datos sin cambiar el estado. Sus esquemas y ejemplos están en el [catálogo de eventos](/referencia-api/api-reference/webhook-events).

### Cuentas nuevas y compatibilidad

Las cuentas creadas después de activar este cambio reciben únicamente `intent.*` cuando existe un reemplazo unificado: tarjeta, transferencia bancaria, cripto, balance y efectivo. Las cuentas existentes conservan ambos formatos, incluso si registran un endpoint nuevo. Los eventos sin reemplazo, como `automated_bank_transfer_intent.*`, `setup_intent.*`, `subscription.*` y `refund.*`, no cambian; tampoco se eliminan los eventos de creación o actualización que no tienen equivalente unificado.

La regla depende de la cuenta que recibe el webhook: una plataforma existente conserva ambos formatos para sus cuentas conectadas nuevas. Cada Sandbox nuevo usa el formato unificado, aunque su cuenta LIVE sea anterior al cambio.

El payload unificado incluye `legacy_id`, el ID del mismo cobro en el evento anterior por tipo. Úsalo para vincular registros históricos durante la migración; el ID canónico sigue siendo `id`.

## 1. Registra tu endpoint

Un endpoint es una URL pública de tu servidor donde querés recibir las notificaciones (ej. `https://tusitio.com/webhooks/recurrente`). Las direcciones privadas, locales y de metadata no se aceptan.

### Vía Dashboard

Dentro de tu cuenta de Recurrente, ve a **Configuración** → **Desarrolladores y API**, haz clic en **"Webhooks"**, y añade la URL a donde quieres que sean enviados los requests.

### Vía API

```bash
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`, `legacy_id`, `receipt_number`, `api_version`, `created_at`.

**Datos del pago** — `amount_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):*

```json
{
  "type": "payment",
  "event_type": "intent.succeeded",
  "api_version": "2026-06-01",
  "id": "in_id123",
  "legacy_id": "pa_id123",
  "receipt_number": 100482,
  "status": "succeeded",
  "raw_status": "succeeded",
  "created_at": "2026-06-01T14:03:21Z",
  "customer_id": "cus_id123",
  "user_id": "usr_id123",
  "customer": {
    "id": "cus_id123",
    "email": "cliente@example.com",
    "full_name": "Ana Lopez",
    "nit": "CF",
    "phone": "+50255551234"
  },
  "amount_in_cents": 25000,
  "currency": "GTQ",
  "product": { "id": "prod_id123" },
  "tax_invoice_url": null,
  "checkout": { "id": "ch_id123", "status": "paid" },
  "payment": { "id": "pa_id123" },
  "details": {
    "failure_reason": null,
    "fee": 875,
    "tax_invoicing_fee": 0,
    "vat_withheld": 0,
    "vat_withheld_currency": "GTQ",
    "used_presaved_payment_method": false,
    "installments": 1,
    "channel": "Tarjeta",
    "products": [{ "id": "prod_id123", "quantity": 1 }]
  }
}
```

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:

```js
app.post("/webhooks/recurrente", (req, res) => {
  const intent = req.body

  switch (intent.type) {
    case "payment":       /* intent.details.fee, .installments, ... */ break
    case "bank_transfer": /* intent.details.bank_reference, .sender_comment */ break
    case "crypto":
    case "balance":
    case "cash":          break
  }

  // estado: siempre intent.status ∈ { pending, succeeded, failed, canceled, paid }
  res.sendStatus(200)
})
```

> **Note**
>
> 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](https://docs.svix.com/receiving/verifying-payloads/how), disponibles para muchos lenguajes:

**`Node.js`**

```javascript title="Node.js"
// npm install svix@2.5.0
const { Webhook } = require("svix");

const wh = new Webhook(secret);
// Verifica el cuerpo original antes de interpretar el JSON.
wh.verify(rawBody, headers);
const payload = JSON.parse(rawBody);
```

**`Python`**

```python title="Python"
# pip install svix
from svix.webhooks import Webhook

wh = Webhook(secret)
# Lanza error si falla, retorna el contenido verificado si es exitoso
payload = wh.verify(raw_body, headers)
```

**`Ruby`**

```ruby title="Ruby"
# gem install svix
require "svix"

wh = Svix::Webhook.new(secret)
# Lanza error si falla, retorna el contenido verificado si es exitoso
payload = wh.verify(raw_body, headers)
```

**`PHP`**

```php title="PHP"
// composer require svix/svix
$wh = new \Svix\Webhook($secret);
// Verifica el cuerpo original antes de interpretar el JSON.
$payload = $wh->verify($raw_body, $headers);
```

> **Warning**
>
> 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:

| Header           | Descripción                                                       |
| ---------------- | ----------------------------------------------------------------- |
| `svix-id`        | Identificador único del mensaje (se mantiene igual en reintentos) |
| `svix-timestamp` | Timestamp Unix (segundos) de cuando se envió el webhook           |
| `svix-signature` | Firma(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:

```javascript
const crypto = require("crypto");

const secretBytes = Buffer.from(secret.split("_")[1], "base64");
const signature = crypto
  .createHmac("sha256", secretBytes)
  .update(signedContent)
  .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

Un Sandbox envía webhooks sin mover dinero. Pagar un checkout del Sandbox con la tarjeta `4242 4242 4242 4242` envía `intent.succeeded` (`type: payment`). Los Sandboxes anteriores al cambio conservan además `payment_intent.succeeded`. Las entregas van solo a los endpoints registrados con la llave **TEST** de ese Sandbox.

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.*`)

| Evento             | Cuándo se emite                                                                                                                         |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
| `intent.succeeded` | Cobro exitoso (cualquier método de pago). Los fondos ya están en tu balance.                                                            |
| `intent.pending`   | Cobro iniciado o pendiente (transferencia, cripto).                                                                                     |
| `intent.failed`    | Cobro fallido.                                                                                                                          |
| `intent.canceled`  | Intent cancelado.                                                                                                                       |
| `intent.paid`      | Solo para pagos con balance: le llega a la cuenta **pagadora** (la cuenta cuyo balance fue debitado), si tiene un endpoint configurado. |

> **Note**
>
> 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

| Evento                                                 | Cuándo se emite                                                                                                                     |
| ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
| `subscription.create`                                  | Se 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_due`                                | El cobro automático de una suscripción falla por primera vez.                                                                       |
| `subscription.pause`                                   | Se pausa la suscripción. Una suscripción pausada no se vuelve a cobrar hasta que sea reactivada.                                    |
| `subscription.unpause`                                 | Se reanuda una suscripción pausada.                                                                                                 |
| `subscription.reactivate`                              | Se reactiva una suscripción cancelada dentro del período permitido.                                                                 |
| `subscription.update`                                  | Cambia información general, como el método de pago.                                                                                 |
| `subscription.item_added`, `subscription.item_removed` | Se agrega o remueve un producto recurrente.                                                                                         |
| `subscription.invoice_item_added`                      | Se agrega un cargo único a la próxima factura.                                                                                      |
| `subscription.cancel`                                  | El 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

| Evento                                                                                                                                                   | Cuándo se emite                                                                                                        |
| -------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `setup_intent.succeeded`                                                                                                                                 | Se inicia exitosamente una suscripción con período de prueba, o se tokeniza una tarjeta sin cobrarla.                  |
| `setup_intent.cancelled`                                                                                                                                 | No 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.succeeded` | Eventos legacy por método de pago.                                                                                     |
| `refund.create`                                                                                                                                          | Reembolso procesado.                                                                                                   |
| `dispute.create`, `dispute.update`                                                                                                                       | Eventos de contracargo. Ver [Ciclo de vida de contracargos](/guias-espanol/guias/ciclo-de-vida-de-contracargos).       |
| `account_connection.create`                                                                                                                              | Una cuenta hija se conecta a una plataforma.                                                                           |
| `partner_commission_invoice.issued`, `partner_commission_invoice.failed`                                                                                 | El DTE diario de comisiones fue emitido o llegó a una falla terminal.                                                  |

### Eventos de movimiento de dinero

| Evento                                                         | Cuándo se emite                                                           |
| -------------------------------------------------------------- | ------------------------------------------------------------------------- |
| `transfer.sent`, `transfer.received`                           | Transferencia p2p enviada / recibida.                                     |
| `transfer.pending`, `transfer.in_review`, `transfer.cancelled` | P2P en evaluación automática / revisión humana / rechazado tras revisión. |
| `withdrawal.create`, `withdrawal.update`                       | Retiro bancario creado / cambio de estado.                                |
| `swap.create`, `swap.update`                                   | Enví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](/guias-espanol/guias/mover-dinero).

Para retiros, lee `payload.withdrawal.status` (o `payload.transfer.status` en la forma unificada). `withdrawal.update` con `status: "sent"` es éxito terminal para retiros locales: ejecuta tu siguiente paso y no esperes un `completed` posterior. Si llega `completed`, trátalo solo como confirmación adicional.

## 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

---

*¿Tu cuenta recibe los eventos por tipo (`payment_intent.*`, `bank_transfer_intent.*`, etc.)? Siguen funcionando sin cambios — ver [Eventos legacy](/webhooks-legacy) o la guía para [migrar al formato unificado](/guias-espanol/guias/migrar-a-webhooks-unificados).*

Los P2P pueden quedar en `in_review`. El monto queda reservado hasta la aprobación. Si se rechazan, pasan a `cancelled` y el monto vuelve al remitente.