> ## Documentation Index
> Fetch the complete documentation index at: https://docs.appreval.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks de Reval: recibí los eventos de suscripción en tu servidor

> Suscribí tus URLs a los 18 eventos: los 15 del ciclo de vida de las suscripciones y los 3 de checkouts y carritos abandonados. Firma HMAC, reintentos con espera creciente, registro de entregas y envío de prueba desde el admin.

Un webhook es Reval avisándole a tu servidor que algo pasó: se cobró un ciclo, un cobro falló, un cliente pausó o canceló. Es el complemento de la [API](https://docs.appreval.com/desarrolladores/api.md): la API se consulta, los webhooks te avisan. Todo se configura en **Reval → Integraciones → Webhooks**.

> [!NOTE]
>
> Los webhooks son *best-effort* respecto del flujo principal: un endpoint caído nunca frena un cobro ni la creación de un pedido. Reval encola el envío, lo intenta aparte y lo reintenta si falla.

***

## Configurar un endpoint

1. En **Reval → Integraciones → Webhooks**, pegá la **URL** de tu servidor (tiene que ser `https`) y elegí a qué **eventos** se suscribe.
2. Guardá. Reval genera un **secreto de firma** (`whsec_…`) para ese endpoint: lo ves con «Mostrar» y lo copiás a tu servidor.
3. Usá **«Enviar prueba»**: tu endpoint recibe un evento `test` y el admin muestra el resultado (código HTTP o error).

Podés tener hasta **10 endpoints** por tienda, cada uno con su propia lista de eventos y su propio secreto. **Pausar** un endpoint deja de enviarle eventos sin borrar su configuración ni su historial; **Borrar** elimina las dos cosas.

> [!WARNING]
>
> Los payloads incluyen datos personales del suscriptor (email, nombre, ciudad). Por eso solo se aceptan URLs `https`, y Reval rechaza direcciones internas o privadas (localhost, IPs de red local, hosts sin dominio). El nombre se vuelve a verificar al momento de cada envío, después de resolver el DNS: si pasa a apuntar a una dirección privada, la entrega se descarta con error.

***

## Los eventos

Son los mismos 15 eventos de suscripción que Reval envía a Klaviyo y Shopify Flow, con los mismos datos, más los 3 de [checkouts y carritos abandonados](https://docs.appreval.com/merchant/checkouts-abandonados.md), que no tienen suscripción todavía y salen solo a Klaviyo y a webhooks. El `topic` de cada envío es `subscription.<evento>`, `checkout.<evento>` o `cart.<evento>`:

| Topic | Cuándo |
| - | - |
| `subscription.created` | El cliente completó el checkout y Mercado Pago autorizó la suscripción |
| `subscription.charged` | Se aprobó el cobro de un ciclo (el primero o una renovación) |
| `subscription.payment_failed` | Mercado Pago rechazó el cobro de un ciclo |
| `subscription.upcoming_renewal` | N días antes del próximo cobro (según tu configuración de recordatorio) |
| `subscription.item_out_of_stock` | Un pedido del ciclo se retuvo porque falta stock |
| `subscription.item_back_in_stock` | Un pedido retenido por stock finalmente se creó |
| `subscription.item_variant_deleted` | Un producto de la suscripción fue eliminado de Shopify |
| `subscription.paused` | Pausa desde el portal, el admin o Mercado Pago |
| `subscription.resumed` | Se reanudó una suscripción pausada |
| `subscription.cancelled` | Cancelación desde el portal, el admin o Mercado Pago |
| `subscription.completed` | La suscripción alcanzó el máximo de ciclos del plan |
| `subscription.payment_method_updated` | El cliente cambió la tarjeta desde el portal |
| `subscription.surprise_applied` | Se entregó una recompensa de Sorpresas |
| `subscription.cycle_skipped` | El cliente salteó su próxima entrega |
| `subscription.auto_merged` | El ciclo salió en un solo pedido junto con otra suscripción del cliente |
| `checkout.started` | Un cliente cargó su email en el checkout de Reval (checkouts abandonados). No lleva `subscription_id`: todavía no hay suscripción |
| `cart.started` | Un cliente identificable (sesión iniciada o email conocido) llenó el carrito de suscripciones. Sin `subscription_id` |
| `cart.emptied` | Ese cliente vació el carrito. El filtro que corta el flow de recuperación del carrito |
| `test` | «Enviar prueba» desde el admin |

***

## El envío

Cada entrega es un `POST` a tu URL con un cuerpo JSON y estos headers:

| Header | Contenido |
| - | - |
| `Content-Type` | `application/json` |
| `X-Reval-Topic` | El topic, por ejemplo `subscription.cancelled` |
| `X-Reval-Delivery` | ID de la entrega. **Se mantiene en cada reintento y en cada reenvío**: usalo para no procesar dos veces lo mismo |
| `X-Reval-Shop` | El dominio `tu-tienda.myshopify.com` |
| `User-Agent` | `Reval-Webhooks/1.0 (+https://docs.appreval.com)`, por si filtrás por agente |
| `X-Reval-Signature` | La firma, ver abajo |

```json theme={null}
{
  "id": "cmg…",
  "topic": "subscription.cancelled",
  "created_at": "2026-10-16T14:03:11.000Z",
  "shop": "tu-tienda.myshopify.com",
  "subscription_id": "cmg…",
  "data": {
    "subscription_id": "cmg…",
    "status": "cancelled",
    "customer_email": "ana@example.com",
    "customer_first_name": "Ana",
    "product_name": "Café de especialidad 500 g",
    "product_names": "Café de especialidad 500 g",
    "amount": 18500,
    "amount_formatted": "$ 18.500,00",
    "currency": "ARS",
    "frequency": 1,
    "frequency_type": "months",
    "frequency_label": "Mensual",
    "cycle_number": 4,
    "next_payment_date": null,
    "portal_subscription_url": "https://tu-tienda.com/apps/subscriptions/portal?sub=cmg…",
    "source": "customer",
    "reason": "Me voy de viaje"
  }
}
```

`data` lleva **las mismas propiedades que reciben Klaviyo y Shopify Flow**: identidad del cliente, producto, montos (crudos y formateados), plan, número de ciclo, fechas, dirección de envío y links al portal, más las propiedades propias de cada evento (`reason`, `source`, `detail`, `held_product_names`, `merged_with_subscription_ids`, etc.).

## Las propiedades de `data`

Las propiedades son las mismas para todos los eventos, más las propias de cada uno. Van en `snake_case`; los montos crudos son números y los `_formatted` son strings listos para un email. Las etiquetas (`frequency_label`, `next_payment_date_label`, `reason`, `reason_text`) salen en el **idioma configurado de la tienda** (español o portugués); para condiciones usá las propiedades crudas (`status`, `frequency_type`, `source`, fechas ISO) y `reason_key`, que no cambian con el idioma.

| Propiedad | Qué es |
| - | - |
| `subscription_id` | ID de la suscripción en Reval |
| `status` | Estado: `pending`, `active`, `paused`, `cancelled` o `completed` |
| `customer_email` | Email del suscriptor (la identidad de la suscripción) |
| `customer_first_name / customer_last_name` | Nombre y apellido, de la dirección de envío |
| `product_variant` | GID de Shopify de la variante principal |
| `product_name / product_variant_title / product_image_url` | Nombre, título de variante e imagen del producto principal |
| `product_names` | Todos los productos de la suscripción, separados por coma |
| `quantity / item_count` | Cantidad del producto principal y cantidad de líneas |
| `amount / amount_formatted` | Monto recurrente actual, crudo y formateado en la moneda de la tienda |
| `next_charge_amount / next_charge_amount_formatted` | Lo que cobra el próximo ciclo (difiere de `amount` mientras rige un descuento de primer pedido) |
| `currency` | Código de moneda: `ARS`, `CLP`, `MXN`, `BRL`, `COP`… |
| `total_spent / total_spent_formatted` | Gasto acumulado en esta suscripción |
| `shipping_amount / shipping_title` | Envío fijado al suscribirse: monto y nombre del método |
| `frequency / frequency_type / frequency_label` | Cadencia: número, unidad (`days`, `weeks`, `months`) y etiqueta («Mensual») |
| `plan_name / discount_percent / first_order_discount_percent` | Plan y sus descuentos |
| `cycle_number / is_first_cycle` | Ciclos cobrados hasta ahora y si es el primero |
| `subscription_created_at / subscription_age_days` | Alta y antigüedad en días |
| `last_payment_date / next_payment_date / next_payment_date_label / days_until_next_charge` | Último cobro, próximo cobro (ISO y formateado) y días que faltan |
| `shipping_city / shipping_province / shipping_country` | Destino de envío |
| `portal_url / portal_subscription_url` | Link al portal del cliente y a esta suscripción dentro del portal |

Y además, según el evento:

| Evento | Propiedades propias |
| - | - |
| `subscription.created` | `has_one_time` (si el checkout incluyó una compra única), `reactivated_from` (id de la suscripción anterior, al reactivar una cancelada), `external_ref` (la referencia del link de checkout que la creó — ver `checkoutLinkCreate`; null si no vino de un link) |
| `subscription.charged` | `order_type` (`first` o `renewal`), `charged_amount` |
| `subscription.payment_failed` | `detail` (código crudo de Mercado Pago), `reason_text` (motivo en palabras), `amount` |
| `subscription.upcoming_renewal` | `days_before` |
| `subscription.item_out_of_stock` | `held_variants`, `held_product_names`, `held_count`, `inventory_policy` |
| `subscription.item_back_in_stock` | `shopify_order_id` |
| `subscription.item_variant_deleted` | `gone_variants`, `gone_count`, `cycle_skipped` (si el ciclo entero se salteó) |
| `subscription.paused / resumed / cancelled` | `source` (`customer`, `merchant`, `mp`, `api`), `reason` (el motivo elegido, en el idioma de la tienda; si una cancelación por API trae `note`, queda «Motivo: nota»), `reason_key` (el mismo motivo tal como está guardado, siempre en castellano, estable para condiciones) |
| `subscription.completed` | `max_cycles` |
| `subscription.payment_method_updated` | — |
| `subscription.surprise_applied` | `surprise_type` (`discount` o `gift`), `surprise_rule_id`; con `discount_amount` o con `surprise_rule_name` y `gift_count` |
| `subscription.cycle_skipped` | `source`, `skipped_date`, `next_charge_date` |
| `subscription.auto_merged` | `merged_with_subscription_ids` (separados por coma), `merged_count`, `shopify_order_id` |
| `checkout.started` | `checkout_id`, `checkout_kind` (`product` / `box` / `cart`), `customer_email`, `customer_first_name`, `customer_last_name`, `title`, `product_image_url`, `product_names`, `items_text`, `items` (`title`, `quantity`, `frequency_label`), `item_count`, `amount`, `amount_formatted`, `currency`, `shipping_city`, `shipping_province`, `shipping_country`, `recovery_url`, `checkout_started_at`. Sin las propiedades de suscripción |
| `cart.started` / `cart.emptied` | `checkout_id`, `checkout_kind` (`product` / `box` / `cart`), `customer_email`, `customer_first_name`, `customer_last_name`, `title`, `product_image_url`, `product_names`, `items_text`, `items` (`title`, `quantity`, `frequency_label`), `item_count`, `amount`, `amount_formatted`, `currency`, `shipping_city`, `shipping_province`, `shipping_country`, `recovery_url`, `checkout_started_at`. Sin las propiedades de suscripción |
| `test` | `test: true` y datos de ejemplo reconocibles (`sub_test`, `test@example.com`) |

Respondé **cualquier 2xx** lo antes posible —idealmente antes de procesar— y procesá en segundo plano. Reval espera **10 segundos**; después corta y cuenta el intento como fallido. Las redirecciones no se siguen.

***

## Verificar la firma

Cada envío va firmado con el secreto del endpoint para que puedas confirmar que lo mandó Reval y que no fue alterado:

```
X-Reval-Signature: t=1760623391,v1=5f1c…e9a2
```

* `t` es la hora del envío en segundos Unix.
* `v1` es `HMAC-SHA256(secreto, "<t>.<cuerpo crudo>")` en hexadecimal.

Firmá el **cuerpo crudo** tal como llegó (los bytes, no un JSON re-serializado), compará en tiempo constante y rechazá firmas con más de 5 minutos de diferencia:

```js theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyReval(rawBody, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(parts.v1 ?? "", "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}
```

```python theme={null}
import hmac, hashlib, time

def verify_reval(raw_body: bytes, header: str, secret: str, tolerance=300) -> bool:
    parts = dict(kv.split("=", 1) for kv in header.split(","))
    t = int(parts.get("t", "0"))
    if not t or abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))
```

> [!TIP]
>
> Si creés que el secreto se filtró, borrá el endpoint y creá uno nuevo: tiene secreto nuevo. Los eventos que ya estaban en cola para el endpoint borrado se descartan.

***

## Reintentos

Si tu servidor responde algo que no es 2xx, no responde en 10 segundos o no se puede resolver, Reval reintenta con espera creciente:

| Intento | Espera desde el anterior |
| - | - |
| 2 | 1 minuto |
| 3 | 10 minutos |
| 4 | 1 hora |
| 5 | 4 horas |
| 6 | 12 horas |
| 7 | 24 horas |

Después del séptimo intento la entrega queda **Fallida**. No se pierde: la ves en el registro y la podés **reenviar** cuando tu servidor vuelva.

Como los reintentos mantienen el mismo `X-Reval-Delivery`, un endpoint que respondió 2xx tarde (por ejemplo, justo después del timeout) puede recibir el mismo evento dos veces. Guardá el ID y descartá los repetidos.

***

## Registro de entregas

En cada endpoint, **«Ver entregas»** muestra las últimas 50: evento, fecha, cantidad de intentos, estado (Pendiente, En curso, Entregado, Fallido), código HTTP de la última respuesta y el error si lo hubo. Cada fila tiene **«Reenviar»**, que vuelve a mandar exactamente el mismo payload con el mismo ID.

***

## Preguntas frecuentes

**¿Se envían eventos de antes de crear el endpoint?** No. Un endpoint recibe los eventos que ocurren desde que existe y está activo. Para el histórico está la [API](https://docs.appreval.com/desarrolladores/api.md).

**¿Puedo recibir solo cancelaciones?** Sí: al crear o editar el endpoint, marcá solo los eventos que te interesan.

**¿Qué pasa si pauso el endpoint?** Los eventos que ocurran mientras está pausado no se le envían ni se guardan. Al reanudarlo recibe los nuevos.

**¿Mi endpoint puede estar en Zapier, Make o n8n?** Sí, cualquier URL `https` pública sirve. Esas herramientas suelen responder 2xx de inmediato, que es justo lo que Reval espera.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.