What does the RevenueDot webhook event BILLING_ISSUE mean?

The RevenueDot webhook event BILLING_ISSUE means a subscription's renewal payment failed and the store is retrying it.

Quick facts#

Event BILLING_ISSUE
Where RevenueDot webhook (event.type), RevenueCat's webhook format
Sent to Every enabled webhook whose filters match
Fields that are specific to it grace_period_expiration_at_ms
What it means A renewal charge failed. The store retries; access may continue in a grace period.

When it is sent#

  • The store could not charge the customer's payment method at renewal.
  • grace_period_expiration_at_ms is always present and is null when there is no grace period.
  • RevenueDot also sends a CANCELLATION with cancel_reason: BILLING_ERROR, as RevenueCat does.

What your server should do#

  1. Keep access until grace_period_expiration_at_ms when it is set, otherwise follow the store's guidance for your app.
  2. Ask the customer to update their payment method.
  3. Expect RENEWAL if billing recovers, or EXPIRATION with BILLING_ERROR if the store gives up.
  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":"BILLING_ISSUE","app_user_id":"user_1","product_id":"pro_monthly","grace_period_expiration_at_ms":1790900000000} });
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 !== "BILLING_ISSUE") return "200 ignored";
  const grace = event.grace_period_expiration_at_ms;
  return grace ? "keep access until " + new Date(grace).toISOString().slice(0, 10) + ", ask to fix payment" : "ask to fix payment";
}

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

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

How RevenueDot produces it#

diffSubscription returns BILLING_ISSUE when a billing issue time appears on the stored purchase. Apple's DID_FAIL_TO_RENEW and Google's SUBSCRIPTION_IN_GRACE_PERIOD and SUBSCRIPTION_ON_HOLD all set it.

Source#

Checked: 2026-10-03

Edit this page on GitHub ↗ View as Markdown Last updated