What does the RevenueDot webhook event EXPIRATION mean?

The RevenueDot webhook event EXPIRATION means a customer's access has ended.

Quick facts#

Event EXPIRATION
Where RevenueDot webhook (event.type), RevenueCat's webhook format
Sent to Every enabled webhook whose filters match
Fields that are specific to it expiration_reason
What it means Access ended: the period ran out, billing retry gave up or the subscription paused.

When it is sent#

  • The paid period, and any grace period, passed with no renewal.
  • expiration_reason says why: UNSUBSCRIBE, BILLING_ERROR, DEVELOPER_INITIATED, PRICE_INCREASE, CUSTOMER_SUPPORT, UNKNOWN or SUBSCRIPTION_PAUSED.
  • It is recorded once per period by a scheduled job that runs every minute, so it can arrive up to a minute after the expiry time.

What your server should do#

  1. Remove the entitlements of the subscription.
  2. Use expiration_reason to choose the win-back message: a billing error needs a payment prompt.
  3. Keep the customer's data.
  4. Deduplicate on event.id: deliveries can repeat, because anything but HTTP 200 is retried after 5, 10, 20, 40 and 80 minutes.

Example#

JavaScript
import { createHmac, timingSafeEqual } from "node:crypto";

// X-RevenueCat-Webhook-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">
function verify(rawBody, header, secret, toleranceSeconds = 300) {
  const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header ?? "");
  if (!m || Math.abs(Date.now() / 1000 - Number(m[1])) > toleranceSeconds) return false;
  const expected = createHmac("sha256", secret).update(`${m[1]}.${rawBody}`).digest();
  return timingSafeEqual(expected, Buffer.from(m[2], "hex"));
}

// Sign a sample delivery the way RevenueDot does, then handle it.
const secret = "whsec_test_secret";
const rawBody = JSON.stringify({ api_version: "1.0", event: {"id":"66339910-3BFF-49F4-B873-D1283D673DE2","type":"EXPIRATION","app_user_id":"user_1","product_id":"pro_monthly","expiration_reason":"BILLING_ERROR"} });
const t = Math.floor(Date.now() / 1000);
const header = `t=${t},v1=${createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex")}`;

const seen = new Set(); // use a database table in production

function handle(rawBody, header) {
  if (!verify(rawBody, header, secret)) return "401 bad signature";
  const { event } = JSON.parse(rawBody);
  if (seen.has(event.id)) return "200 duplicate";
  seen.add(event.id);
  if (event.type !== "EXPIRATION") return "200 ignored";
  return event.expiration_reason === "BILLING_ERROR" ? "remove access, ask for a new payment method" : "remove access, start win-back";
}

console.log(handle(rawBody, header));

Run-checked: npm run check:snippets runs this snippet with Node and compares its output with ask for a new payment method (checked 2026-10-03).

How RevenueDot produces it#

A scheduled job (services/tick.ts) records EXPIRATION for every subscription whose access, grace period included, has ended, and claims each one once so replicas do not send it twice. The reason comes from the stored state: paused, then a billing issue, then the stored cancel reason, else UNSUBSCRIBE.

Source#

Checked: 2026-10-03

Edit this page on GitHub ↗ View as Markdown Last updated