What does the RevenueDot webhook event CANCELLATION mean?

The RevenueDot webhook event CANCELLATION means a subscription will not renew, or a purchase was refunded.

Quick facts#

Event CANCELLATION
Where RevenueDot webhook (event.type), RevenueCat's webhook format
Sent to Every enabled webhook whose filters match
Fields that are specific to it cancel_reason
What it means Auto-renew was turned off, or the purchase was refunded. Access continues to expiration_at_ms unless it was a refund. A refund has cancel_reason: CUSTOMER_SUPPORT and a negative price.

When it is sent#

  • The customer turned auto-renew off (cancel_reason: UNSUBSCRIBE), the store gave a reason such as DEVELOPER_INITIATED or PRICE_INCREASE, or a billing failure counted as a cancellation (BILLING_ERROR).
  • A refund gives cancel_reason: CUSTOMER_SUPPORT and a negative price.
  • It is not the end of access. EXPIRATION follows when the paid period ends.

What your server should do#

  1. For an unsubscribe, keep access until expiration_at_ms and start your win-back flow.
  2. For CUSTOMER_SUPPORT with a negative price, revoke access now.
  3. Use cancel_reason to separate voluntary from billing-related churn.
  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":"CANCELLATION","app_user_id":"user_1","product_id":"pro_monthly","cancel_reason":"UNSUBSCRIBE"} });
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 !== "CANCELLATION") return "200 ignored";
  if (event.cancel_reason === "CUSTOMER_SUPPORT") return "revoke access now, book the refund";
  return "keep access until expiration_at_ms, start win-back";
}

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

Run-checked: npm run check:snippets runs this snippet with Node and compares its output with keep access until expiration_at_ms (checked 2026-10-03).

How RevenueDot produces it#

diffSubscription returns CANCELLATION when auto-renew goes off (with the reason from the store, or BILLING_ERROR when a billing issue is stored, else UNSUBSCRIBE) and, with isRefund, when a refund time appears (reason CUSTOMER_SUPPORT). One-time purchases get the refund form too.

Source#

Checked: 2026-10-03

Edit this page on GitHub ↗ View as Markdown Last updated