Skip to content

8 min read

RevenueCat webhooks for MRR: events, auth and idempotency

Which RevenueCat webhook events change MRR, why cancellation isn't churn until expiration, and a TypeScript handler that checks auth and applies each event once.

RevenueCat webhooks send your server one POST per subscription event: a purchase, a renewal, a cancellation, a failed payment. If you want MRR in your own database, or in anything other than RevenueCat's dashboard, these events are how you keep it current between API syncs. The catch is that only some of them change MRR, one of the most common ones changes it later than you'd think, and every event can arrive more than once.

This is the reference I'd want next to the editor: which events move MRR, why cancellation isn't churn, how to check the request, how to process each event exactly once, and a handler you can adapt.

Which RevenueCat webhook events change MRR?

EventWhat happenedEffect on MRR
INITIAL_PURCHASEFirst purchase of a subscription product. With period_type of TRIAL, a trial startedPaid: add it as new MRR. Trial: zero until it converts
RENEWALA new period was billed. Includes a trial converting to paidTrial conversion: new MRR. Otherwise MRR stays, at the price of this period
CANCELLATIONAuto-renew was turned off, or the purchase was cancelled by the storeNone yet. Record the intent and wait
UNCANCELLATIONAuto-renew was turned back on before the period endedNone. Clear the cancellation
EXPIRATIONThe subscription ended and access stoppedRemove it. This is churn
BILLING_ISSUEA renewal payment failed and the store is retryingNone yet. Flag the MRR as at risk
PRODUCT_CHANGEThe customer switched to another productPossibly expansion or contraction, once the new product is billed

A few other events arrive on the same webhook and should be handled deliberately rather than by accident:

  • TEST is what the "send test event" button sends. Accept it, change nothing.
  • NON_RENEWING_PURCHASE is a one-time purchase. It is revenue, but not recurring, so it stays out of MRR.
  • SUBSCRIPTION_PAUSED is a pause scheduled for the end of the period. The MRR goes away when the period ends, and an EXPIRATION follows then.
  • TRANSFER means purchases moved from one app user ID to another. It changes who the customer is, not how much they pay.

Also check the environment field on every event. Sandbox purchases from TestFlight and development builds arrive with SANDBOX and should never reach your production numbers.

Trials, intro offers and the period_type field

period_type is what separates a trial from a paid period. NORMAL is a regular paid period. TRIAL is free. INTRO is a discounted introductory period, which is paid, and you need a rule for it: count it at the discounted price, or leave it out until the full price kicks in. Either is defensible. Switching between them later makes every historical chart wrong, so pick one and write it down next to the rest of your MRR definitions.

Product changes take effect later than the event

PRODUCT_CHANGE is sent when a change is initiated, which isn't always when it takes effect. On the App Store, a downgrade usually waits for the next renewal. If you update MRR on the event, you'll book contraction weeks early. The safer pattern is to note the pending product and let the next RENEWAL, which carries the product actually billed, set the new amount.

Why is a cancellation not churn until expiration?

When someone cancels a monthly subscription on day 3, they have paid for the month and keep access until it ends. They might change their mind, in which case you get an UNCANCELLATION. Or the period ends and you get an EXPIRATION. Only the second one is churn.

Counting churn at CANCELLATION has two costs. Your MRR drops weeks before revenue actually stops, and every uncancellation looks like a reactivation, which inflates both churn and recovery. Your churn rate then measures intent, not loss.

The cancellation is still useful information. Keep a second number next to MRR: the MRR of subscriptions that are active but cancelled. That's the churn already scheduled for the coming weeks, and the one you can still do something about. Read cancel_reason too. It distinguishes a customer turning off auto-renew from a billing failure or a cancellation made on the store's side, and those deserve different responses.

BILLING_ISSUE is the same idea from the other direction. The customer hasn't decided to leave, a card failed. The subscription is still counted in MRR while the store retries; mark it as at risk, and let EXPIRATION remove it if the retries fail.

How should you check the Authorization header?

In the RevenueCat webhook settings you can set an authorization header value, and RevenueCat sends it with every request. That value is your shared secret. Treat it like an API key:

  • Generate it randomly, long enough that guessing is pointless, and store it as a secret, not in your repo.
  • Compare it in constant time, so response timing doesn't leak how much of a guess was right.
  • Return 401 when it doesn't match and do nothing else. Don't parse the body first.
  • Don't log the header, and don't log the full event either: subscriber attributes can hold emails and names.

A per-environment secret, or one URL per app if you run several, lets you rotate one without touching the others.

How do you make the handler idempotent?

RevenueCat retries deliveries that fail or time out, so the same event can reach you twice. A retry of a RENEWAL that you process twice adds a period twice. The fix is cheap: every event has an id, so store it with a unique constraint and skip events you have already stored.

Two details matter:

  1. Store the ID and apply the effect in the same transaction. If you store the ID, then crash before updating the subscription, the retry is skipped and the update is lost. If you update first and store the ID after, a crash in between applies it twice on retry.
  2. Respond quickly. Do the database write and return. If something heavier depends on the event, such as recomputing a month of metrics, queue it. A slow handler causes timeouts, timeouts cause retries, and retries arrive while the first attempt is still running.

Events can also arrive out of order. Each one carries event_timestamp_ms; keep the timestamp of the last event applied to each subscription and ignore anything older. And reconcile with RevenueCat's REST API on a schedule. Webhooks keep you current; the API corrects whatever a missed or misordered event left behind.

A TypeScript handler sketch

This assumes a web-standard Request (Next.js route handlers, Hono, Bun and others) and a data layer of your own, shown here as db. It keys subscriptions by user and product, which is enough for most apps with one subscription group.

import { timingSafeEqual } from "node:crypto";

type RcEvent = {
  id: string;
  type: string;
  app_user_id: string;
  product_id?: string;
  period_type?: string; // NORMAL, TRIAL, INTRO, ...
  purchased_at_ms?: number;
  expiration_at_ms?: number;
  event_timestamp_ms: number;
  environment: string; // PRODUCTION or SANDBOX
  price_in_purchased_currency?: number;
  currency?: string;
};

const SECRET = process.env.REVENUECAT_WEBHOOK_SECRET ?? "";

function authorized(header: string | null): boolean {
  const expected = Buffer.from(`Bearer ${SECRET}`);
  const received = Buffer.from(header ?? "");
  return SECRET.length > 0 && received.length === expected.length && timingSafeEqual(received, expected);
}

// Snap the period to a billing interval so a 28-day February doesn't inflate MRR.
function monthsCovered(days: number): number {
  if (days < 25) return Math.max(1, Math.round(days / 7)) * (12 / 52);
  return Math.max(1, Math.round(days / 30.44));
}

function monthlyAmount(e: RcEvent): number {
  if (e.period_type !== "NORMAL") return 0; // trials are zero; decide on INTRO yourself
  const { purchased_at_ms: start, expiration_at_ms: end, price_in_purchased_currency: price } = e;
  if (!start || !end || end <= start || !price) return 0;
  return price / monthsCovered((end - start) / 86_400_000);
}

export async function POST(req: Request): Promise<Response> {
  if (!authorized(req.headers.get("authorization"))) {
    return new Response("Unauthorized", { status: 401 });
  }
  const { event } = (await req.json()) as { event: RcEvent };
  if (event.type === "TEST" || event.environment === "SANDBOX") {
    return Response.json({ ok: true });
  }

  await db.transaction(async (tx) => {
    // The event ID has a unique constraint: a retry inserts nothing and stops here.
    const isNew = await tx.insertEventIfNew(event.id, event.type, event.event_timestamp_ms);
    if (!isNew || !event.product_id) return;

    const key = { userId: event.app_user_id, productId: event.product_id };
    const asOf = event.event_timestamp_ms; // updates older than the stored asOf are ignored

    switch (event.type) {
      case "INITIAL_PURCHASE":
      case "RENEWAL":
        await tx.updateSubscription(key, asOf, {
          status: "active",
          mrr: monthlyAmount(event),
          currency: event.currency,
          periodEnd: event.expiration_at_ms,
          canceledAt: null,
          pastDue: false,
          pendingProductId: null,
        });
        break;
      case "CANCELLATION":
        await tx.updateSubscription(key, asOf, { canceledAt: asOf }); // still counted in MRR
        break;
      case "UNCANCELLATION":
        await tx.updateSubscription(key, asOf, { canceledAt: null });
        break;
      case "BILLING_ISSUE":
        await tx.updateSubscription(key, asOf, { pastDue: true }); // at risk, still counted
        break;
      case "PRODUCT_CHANGE":
        await tx.updateSubscription(key, asOf, { pendingProductId: event.product_id });
        break;
      case "EXPIRATION":
        await tx.updateSubscription(key, asOf, { status: "expired", mrr: 0, pastDue: false });
        break;
      // NON_RENEWING_PURCHASE, TRANSFER and others: stored above, no MRR effect here.
    }
  });

  return Response.json({ ok: true });
}

What the sketch leaves out, on purpose: tax (in some storefronts the price includes it, and RevenueCat sends tax and commission percentages you can use to remove it), currency conversion to one reporting currency, and anonymous users. If your app lets people buy before logging in, app_user_id can be an anonymous RevenueCat ID, and the aliases field lists the other IDs the same person has had. Map those to your own user ID before keying anything on it.

If you also sell through Stripe, the same subscription can be visible in both systems. Combined MRR for Stripe and RevenueCat covers how to count it once.

Joinsight's RevenueCat integration is this handler plus a daily API v2 sync of customers and subscriptions: one webhook URL per workspace, the Authorization header checked in constant time, each event stored once by ID, and subscriptions RevenueCat also sees in Stripe deduplicated on the stored Stripe subscription ID.

See what makes you money.

Connect Stripe or RevenueCat and Search Console, and you see combined MRR and 16 months of search history on day one. Page and keyword revenue follow as the snippet collects first touches.

No card required. The trial clock starts when your first revenue source connects.