What does the RevenueDot webhook event INITIAL_PURCHASE mean?

The RevenueDot webhook event INITIAL_PURCHASE means a customer bought a subscription for the first time, including the start of a free trial.

Quick facts#

Event INITIAL_PURCHASE
Where RevenueDot webhook (event.type), RevenueCat's webhook format
Sent to Every enabled webhook whose filters match
Fields that are specific to it none beyond the common subscription fields
What it means The first purchase of a subscription, including a free trial start.

When it is sent#

  • A subscription chain that RevenueDot has not seen before is recorded, from the SDK, a store notification (when new purchases are tracked), an import or a web checkout.
  • period_type is TRIAL for a free trial and INTRO for a paid introductory offer; NORMAL otherwise.
  • It is sent once per chain. A lapsed customer who comes back gets RENEWAL, not a second INITIAL_PURCHASE.

What your server should do#

  1. Grant the entitlements in entitlement_ids until expiration_at_ms.
  2. Record original_transaction_id as the key for the whole subscription.
  3. In a trial, do not count revenue yet: price is the money that moved, and is 0 for a free trial.
  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":"INITIAL_PURCHASE","app_user_id":"user_1","product_id":"pro_monthly","period_type":"NORMAL","entitlement_ids":["pro"]} });
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 !== "INITIAL_PURCHASE") return "200 ignored";
  return "grant " + event.product_id + " until expiration_at_ms, record the customer";
}

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

Run-checked: npm run check:snippets runs this snippet with Node and compares its output with grant pro_monthly (checked 2026-10-03).

How RevenueDot produces it#

When a store change arrives for a chain RevenueDot has no stored purchase for, the diff in diffSubscription returns INITIAL_PURCHASE first. A refunded or already cancelled first purchase also gets a CANCELLATION right after it, and an outstanding price increase gets its consent event.

Source#

Checked: 2026-10-03

Edit this page on GitHub ↗ View as Markdown Last updated