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

# Reval webhooks: receive subscription events on your server

> Subscribe your URLs to the 18 events: the 15 subscription lifecycle events plus the 3 for abandoned checkouts and carts. HMAC signature, retries with growing waits, delivery log and test send from the admin.

A webhook is Reval telling your server something happened: a cycle was charged, a charge failed, a customer paused or cancelled. It complements the [API](https://docs.appreval.com/en/developers/api.md): you query the API, webhooks notify you. Everything is configured in **Reval → Integrations → Webhooks**.

> [!NOTE]
>
> Webhooks are *best-effort* with respect to the main flow: a dead endpoint never holds up a charge or an order. Reval queues the delivery, attempts it separately and retries on failure.

***

## Setting up an endpoint

1. In **Reval → Integrations → Webhooks**, paste your server's **URL** (it must be `https`) and pick which **events** it subscribes to.
2. Save. Reval generates a **signing secret** (`whsec_…`) for that endpoint: reveal it with "Show" and copy it to your server.
3. Use **"Send test"**: your endpoint receives a `test` event and the admin shows the result (HTTP status or error).

You can have up to **10 endpoints** per store, each with its own event list and its own secret. **Pausing** an endpoint stops sending to it without deleting its configuration or history; **Delete** removes both.

> [!WARNING]
>
> Payloads include the subscriber's personal data (email, name, city). That is why only `https` URLs are accepted, and Reval refuses internal or private addresses (localhost, LAN IPs, hosts without a domain). The hostname is checked again at every delivery, after DNS resolution: if it starts pointing to a private address, the delivery is dropped with an error.

***

## The events

The same 15 subscription events Reval sends to Klaviyo and Shopify Flow, with the same data, plus the 3 for [abandoned checkouts and carts](https://docs.appreval.com/en/merchant/abandoned-checkouts.md), which have no subscription yet and go only to Klaviyo and webhooks. Each delivery's `topic` is `subscription.<event>`, `checkout.<event>` or `cart.<event>`:

| Topic | When |
| - | - |
| `subscription.created` | The customer completed checkout and Mercado Pago authorized the subscription |
| `subscription.charged` | A cycle's charge was approved (the first or a renewal) |
| `subscription.payment_failed` | Mercado Pago rejected a cycle's charge |
| `subscription.upcoming_renewal` | N days before the next charge (per your reminder setting) |
| `subscription.item_out_of_stock` | A cycle's order was held because an item is out of stock |
| `subscription.item_back_in_stock` | An order held for stock was finally created |
| `subscription.item_variant_deleted` | A product on the subscription was deleted from Shopify |
| `subscription.paused` | Paused from the portal, the admin or Mercado Pago |
| `subscription.resumed` | A paused subscription was resumed |
| `subscription.cancelled` | Cancelled from the portal, the admin or Mercado Pago |
| `subscription.completed` | The subscription reached the plan's maximum cycles |
| `subscription.payment_method_updated` | The customer changed the card from the portal |
| `subscription.surprise_applied` | A Surprises reward was granted |
| `subscription.cycle_skipped` | The customer skipped their next delivery |
| `subscription.auto_merged` | The cycle shipped on one order together with another of the customer's subscriptions |
| `checkout.started` | A customer typed their email in Reval's checkout (abandoned checkouts). No `subscription_id`: there is no subscription yet |
| `cart.started` | An identifiable customer (logged in or known email) filled the subscription cart. No `subscription_id` |
| `cart.emptied` | That customer emptied the cart. The filter that stops the cart recovery flow |
| `test` | "Send test" from the admin |

***

## The delivery

Each delivery is a `POST` to your URL with a JSON body and these headers:

| Header | Content |
| - | - |
| `Content-Type` | `application/json` |
| `X-Reval-Topic` | The topic, e.g. `subscription.cancelled` |
| `X-Reval-Delivery` | Delivery id. **Stays the same across retries and resends**: use it to avoid processing the same thing twice |
| `X-Reval-Shop` | The `tu-tienda.myshopify.com` domain |
| `User-Agent` | `Reval-Webhooks/1.0 (+https://docs.appreval.com)`, in case you filter by agent |
| `X-Reval-Signature` | The signature, see below |

```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": "Specialty coffee 500 g",
    "product_names": "Specialty coffee 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": "Travelling"
  }
}
```

`data` carries **the same properties Klaviyo and Shopify Flow receive**: customer identity, product, amounts (raw and formatted), plan, cycle number, dates, shipping address and portal links, plus each event's own properties (`reason`, `source`, `detail`, `held_product_names`, `merged_with_subscription_ids`, etc.).

## The `data` properties

The properties are the same for every event, plus each event's own. They are `snake_case`; raw amounts are numbers and the `_formatted` ones are strings ready for an email. Labels (`frequency_label`, `next_payment_date_label`, `reason`, `reason_text`) follow the **store's configured language** (Spanish or Portuguese); for conditions use the raw properties (`status`, `frequency_type`, `source`, ISO dates) and `reason_key`, which do not change with the language.

| Property | What it is |
| - | - |
| `subscription_id` | Reval id of the subscription |
| `status` | Status: `pending`, `active`, `paused`, `cancelled` or `completed` |
| `customer_email` | Subscriber's email (the subscription's identity) |
| `customer_first_name / customer_last_name` | First and last name, from the shipping address |
| `product_variant` | Shopify GID of the main variant |
| `product_name / product_variant_title / product_image_url` | Name, variant title and image of the main product |
| `product_names` | Every product in the subscription, comma-separated |
| `quantity / item_count` | Quantity of the main product and number of lines |
| `amount / amount_formatted` | Current recurring amount, raw and formatted in the store's currency |
| `next_charge_amount / next_charge_amount_formatted` | What the next cycle charges (differs from `amount` while a first-order discount applies) |
| `currency` | Currency code: `ARS`, `CLP`, `MXN`, `BRL`, `COP`… |
| `total_spent / total_spent_formatted` | Lifetime spend on this subscription |
| `shipping_amount / shipping_title` | Shipping locked at signup: amount and method name |
| `frequency / frequency_type / frequency_label` | Cadence: number, unit (`days`, `weeks`, `months`) and label («Mensual») |
| `plan_name / discount_percent / first_order_discount_percent` | Plan and its discounts |
| `cycle_number / is_first_cycle` | Cycles charged so far and whether this is the first |
| `subscription_created_at / subscription_age_days` | Signup date and age in days |
| `last_payment_date / next_payment_date / next_payment_date_label / days_until_next_charge` | Last charge, next charge (ISO and formatted) and days left |
| `shipping_city / shipping_province / shipping_country` | Shipping destination |
| `portal_url / portal_subscription_url` | Link to the customer portal and to this subscription inside it |

Plus, depending on the event:

| Event | Own properties |
| - | - |
| `subscription.created` | `has_one_time` (whether the checkout included a one-time purchase), `reactivated_from` (id of the previous subscription, when a cancelled one is reactivated), `external_ref` (the reference of the checkout link that created it — see `checkoutLinkCreate`; null when it didn't come from a link) |
| `subscription.charged` | `order_type` (`first` or `renewal`), `charged_amount` |
| `subscription.payment_failed` | `detail` (raw Mercado Pago code), `reason_text` (reason in words), `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` (whether the whole cycle was skipped) |
| `subscription.paused / resumed / cancelled` | `source` (`customer`, `merchant`, `mp`, `api`), `reason` (the chosen reason, in the store's language; when an API cancel carries a `note`, it reads «Reason: note»), `reason_key` (the same reason as stored, always in Spanish, stable for conditions) |
| `subscription.completed` | `max_cycles` |
| `subscription.payment_method_updated` | — |
| `subscription.surprise_applied` | `surprise_type` (`discount` or `gift`), `surprise_rule_id`; plus `discount_amount` or `surprise_rule_name` and `gift_count` |
| `subscription.cycle_skipped` | `source`, `skipped_date`, `next_charge_date` |
| `subscription.auto_merged` | `merged_with_subscription_ids` (comma-separated), `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`. Without the subscription properties |
| `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`. Without the subscription properties |
| `test` | `test: true` and recognisably fake sample data (`sub_test`, `test@example.com`) |

Answer **any 2xx** as soon as you can, ideally before processing, and process in the background. Reval waits **10 seconds**; after that it gives up and counts the attempt as failed. Redirects are not followed.

***

## Verifying the signature

Each delivery is signed with the endpoint's secret so you can confirm Reval sent it and it was not altered:

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

* `t` is the send time in Unix seconds.
* `v1` is `HMAC-SHA256(secret, "<t>.<raw body>")` in hex.

Sign the **raw body** exactly as received (the bytes, not a re-serialized JSON), compare in constant time, and reject signatures more than 5 minutes off:

```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]
>
> If you think the secret leaked, delete the endpoint and create a new one: it gets a new secret. Events already queued for the deleted endpoint are discarded.

***

## Retries

If your server answers anything but 2xx, does not answer within 10 seconds or cannot be resolved, Reval retries with growing waits:

| Attempt | Wait since the previous one |
| - | - |
| 2 | 1 minute |
| 3 | 10 minutes |
| 4 | 1 hour |
| 5 | 4 hours |
| 6 | 12 hours |
| 7 | 24 hours |

After the seventh attempt the delivery is marked **Failed**. It is not lost: you see it in the log and can **resend** it once your server is back.

Because retries keep the same `X-Reval-Delivery`, an endpoint that answered 2xx late (say, right after the timeout) can receive the same event twice. Store the id and drop duplicates.

***

## Delivery log

Under each endpoint, **"View deliveries"** shows the last 50: event, date, attempt count, status (Pending, In progress, Delivered, Failed), HTTP status of the last response and the error if any. Each row has **"Resend"**, which sends exactly the same payload with the same id again.

***

## FAQ

**Are events from before the endpoint existed sent?** No. An endpoint receives the events that happen while it exists and is active. For history there is the [API](https://docs.appreval.com/en/developers/api.md).

**Can I receive only cancellations?** Yes: when creating or editing the endpoint, tick only the events you care about.

**What happens if I pause the endpoint?** Events that occur while it is paused are neither sent nor stored for it. Once resumed, it receives new ones.

**Can my endpoint be Zapier, Make or n8n?** Yes, any public `https` URL works. Those tools usually answer 2xx immediately, which is exactly what Reval expects.


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