RevenueCat webhook event names sorted into commissionable and non commissionable columns

RevenueCat Webhooks for Affiliate Commissions Explained

Affiliate commissions on a subscription app are computed from RevenueCat webhooks, and RevenueCat's event reference lists more than twenty event types. Four should change what a creator is owed: INITIAL_PURCHASE, RENEWAL, NON_RENEWING_PURCHASE, and the CANCELLATION carrying a cancel_reason of CUSTOMER_SUPPORT. The rest are funnel signal and entitlement bookkeeping, to be stored and left alone.

Below: every event mapped to a ledger effect, the payload fields worth reading, and a six month worked example for one $9.99 subscriber on a 20 percent deal. Written for engineers wiring RevenueCat into their own commission code.

#What a RevenueCat webhook is, and why commissions depend on it

A RevenueCat webhook is an HTTP POST that RevenueCat sends to your server when a subscription changes state, carrying a single JSON event object and an api_version string. It is the only server side record of the money. An affiliate ledger built on client side purchase callbacks drifts the first time a renewal fires while the app is closed.

RevenueCat's webhook documentation sets the rules of engagement. Your endpoint returns a 200 status code; anything else is treated as a failure, and RevenueCat "will retry later (up to 5 times) with an increasing delay (5, 10, 20, 40, and 80 minutes)". Most events arrive within 5 to 60 seconds of the underlying transaction. Cancellations are slower, and the docs allow up to two hours. If your handler has not responded in 60 seconds, RevenueCat disconnects.

Authorization is a header you configure in the dashboard, and RevenueCat also signs the raw body with HMAC-SHA256 in an X-RevenueCat-Webhook-Signature header. Enforce one of them on every request. An unauthenticated RENEWAL endpoint lets anyone mint commissions in your database.

The payload is wide. Our normalizer declares eleven fields and ignores the rest, because parsing fields you never use is how a parser throws on an event carrying money. If you read three fields, read id, type and period_type; the rest refine amount and identity.

FieldTypeWhy a commission engine reads it
`id`StringThe dedupe key. A retry reuses the same value
`type`StringSelects the ledger effect
`app_user_id`StringJoins the event to the user you attributed
`original_app_user_id`StringFirst App User ID the subscriber ever had
`aliases`ArrayEvery App User ID the subscriber has used. Your join key may be here and not in `app_user_id`
`period_type`String`TRIAL`, `INTRO`, `NORMAL`, `PROMOTIONAL`, `PREPAID`. Separates free from paid
`price`DoubleTransaction price converted to USD
`price_in_purchased_currency`DoublePrice in the currency the customer actually paid
`currency`StringISO 4217 code for the purchased currency
`commission_percentage`DoubleThe store's cut. `0.3` in RevenueCat's own sample event
`takehome_percentage`DoubleWhat reaches you after store commission and tax. `0.7` in the same sample
`tax_percentage`DoubleEstimated tax share of the transaction
`environment`String`SANDBOX` or `PRODUCTION`. The payout gate
`store`String`APP_STORE`, `PLAY_STORE`, `STRIPE`, `TEST_STORE` and others
`purchased_at_ms`IntegerWhen the transaction happened
`event_timestamp_ms`IntegerWhen RevenueCat generated the event. Use it for ordering
`expiration_at_ms`IntegerWhen the entitlement lapses
`renewal_number`IntegerRenewals completed, starting at 1. Caps a 12 month recurring deal
`is_trial_conversion`Boolean`RENEWAL` only. True when the previous period was a free trial
`cancel_reason`String`CANCELLATION` only. Separates a refund from an unsubscribe
`expiration_reason`String`EXPIRATION` only
`is_family_share`BooleanA Family Sharing seat pays you nothing
`transaction_id`, `original_transaction_id`StringStore level identity for reconciliation against App Store Connect
`subscriber_attributes`ObjectWhere an affiliate id set on the client arrives, if you set one

Field names and types verified against RevenueCat's event types and fields reference on 2026-09-12.

#Every RevenueCat event, and whether it is commissionable

Four RevenueCat events move an affiliate balance in normal operation: INITIAL_PURCHASE with a paid period_type, RENEWAL, NON_RENEWING_PURCHASE, and CANCELLATION carrying cancel_reason of CUSTOMER_SUPPORT, which is how a refund arrives. A fifth, REFUND_REVERSED, moves it back. Everything else in RevenueCat's reference is funnel data or entitlement state.

Two rules are built into the table below. An event that grants or revokes access is not an event that moves cash, and an event reporting an intention (CANCELLATION, PRICE_INCREASE_CONSENT_REQUIRED, INVOICE_ISSUANCE) is never the one you pay on. Pay on charges, reverse on refunds, record everything else as state.

EventWhat happenedCommissionableEffect on the ledgerGotcha
`TEST`RevenueCat issued a test event from the dashboardNoNothing. Log itThe only way to check your authorization header without a real purchase
`INITIAL_PURCHASE` (`period_type` `NORMAL`, `INTRO`, `PREPAID`)A new subscription was purchased and paid forYesCreate a pending commission on the paid amountAn `INTRO` price is below list price. Commission what was charged, not the sticker
`INITIAL_PURCHASE` (`period_type` `TRIAL`)A subscription started inside a free trialNoRecord a trial start at $0Price is 0. Paying here funds trial abuse
`INITIAL_PURCHASE` (`period_type` `PROMOTIONAL`)You granted the entitlement; nobody bought itNoNothing payablePromotional grants carry no store revenue
`RENEWAL`An existing subscription renewed, or a lapsed user resubscribedYesCreate a pending commission on the renewal amountAlso fires on trial conversion (`is_trial_conversion` is true) and after billing recovery. Cap recurrence with `renewal_number`
`NON_RENEWING_PURCHASE`A purchase that will not auto renewYesCreate a pending commissionConsumables can repeat quickly. Cap per subscriber if the creator deal is per subscriber
`CANCELLATION` (`cancel_reason` `UNSUBSCRIBE`, `PRICE_INCREASE`, `DEVELOPER_INITIATED`, `UNKNOWN`)Auto renew was turned off. Access continues to the end of the periodNoNothingThe most misread event in the set. No money has moved either way
`CANCELLATION` (`cancel_reason` `CUSTOMER_SUPPORT`)The latest subscription period was refundedYes, negativelyReverse the commission for that periodThis is the refund event. Reverse the commission you recorded, do not recompute from this payload
`CANCELLATION` (`cancel_reason` `BILLING_ERROR`)Renewal failed and the subscription lapsedNoNothingPairs with `BILLING_ISSUE`. Not a refund, so nothing to reverse
`UNCANCELLATION`A cancelled but unexpired subscription was re-enabledNoNothingNo charge happens here. The next `RENEWAL` is the money
`EXPIRATION`The subscription expired and entitlements were revokedNoClose the recurrence. Reverse nothingThe end of future commission, never of past commission
`BILLING_ISSUE`An attempt to charge the subscriber failedNoNothingIf a grace period recovers it, a `RENEWAL` follows. Paying on both is the classic double count
`SUBSCRIPTION_PAUSED`The subscription is scheduled to pause at period endNoSuspend the expected next renewalA Play Store feature. The subscriber has not churned
`PRODUCT_CHANGE`The subscriber changed the product of their subscriptionNoNothing on its ownIt arrives alongside a `RENEWAL` on App Store, or an `INITIAL_PURCHASE` on Play Store. Commission the paid event
`SUBSCRIPTION_EXTENDED`An existing subscription was extendedNoPush the expected renewal date outFree time granted. No revenue attached
`REFUND_REVERSED`A refund was reversedYes, positivelyRestore the commission you reversedApp Store only. Rare, and the easiest one to leave unimplemented
`INVOICE_ISSUANCE`A new, unpaid invoice was issuedNoNothingIssued is not paid. Wait for the `RENEWAL`
`TRANSFER`Transactions and entitlements moved between App User IDsNoRe-check attribution using `transferred_from` and `transferred_to`Can hand future revenue to a user you never attributed
`SUBSCRIBER_ALIAS`A new App User ID was registered for an existing subscriberNoMerge the identities before matchingDeprecated. New projects do not receive it
`TEMPORARY_ENTITLEMENT_GRANT`RevenueCat issued a temporary outage grantNoNothingAn availability measure, not a sale
`VIRTUAL_CURRENCY_TRANSACTION`An in-app currency transaction occurredOnly if the deal says soNothing by defaultDecide deliberately. Most creator deals cover subscriptions only
`EXPERIMENT_ENROLLMENT`A customer was enrolled in an experimentNoNothingUseful later for explaining why two creators convert differently
`PURCHASE_REDEEMED`A Paddle, RevenueCat Billing or Stripe purchase was redeemedNoNothing on its ownRedemption is not the charge
`PRICE_INCREASE_CONSENT_REQUIRED`A price increase needs consent before renewalNoFlag the next renewal as at riskSilence here turns into an `EXPIRATION` weeks later
`PRICE_INCREASE_CONSENT_APPROVED`The customer consented to a pending price increaseNoExpect a larger `RENEWAL` amountDo not reuse the old snapshotted price for the next commission

Event names and descriptions checked against RevenueCat's event types and fields reference on 2026-09-12.

Our normalizer returns null for every type it does not act on, and stores the raw payload anyway: an unmapped event should be inert and inspectable, never guessed at. The row worth auditing in any mapping table, ours included, is the refund row. RevenueCat's reference has no type named REFUND, so a table listing one describes a delivery that never arrives while the real refund lands in the CANCELLATION branch and is ignored. We found exactly that in our own normalizer while writing this, and the audit that followed turned up two more billing integrations with the same shape of mistake. A mapping key nobody sends fails silently, and the test written from the same assumption passes.

#INITIAL_PURCHASE and the trial trap

INITIAL_PURCHASE fires on the first purchase of a subscription, whether or not money changed hands. The period_type field decides which happened. TRIAL means a free trial opened at a price of 0. NORMAL, INTRO and PREPAID mean the store charged the customer. PROMOTIONAL means you granted the entitlement yourself.

The shape RevenueCat sends, trimmed to the deciding fields. Full version on their sample events page.

{
  "event": {
    "type": "INITIAL_PURCHASE",
    "id": "12345678-1234-1234-1234-123456789012",
    "app_user_id": "1234567890",
    "product_id": "com.subscription.weekly",
    "period_type": "TRIAL",
    "price": 0,
    "price_in_purchased_currency": 0,
    "currency": "USD",
    "environment": "PRODUCTION",
    "store": "APP_STORE",
    "purchased_at_ms": 1658726374000,
    "expiration_at_ms": 1659331174000
  },
  "api_version": "1.0"
}

That payload should produce a funnel row, not a payable one. Our normalizer branches on those two fields and downgrades the type before the amount is computed:

typescript
let type = TYPE_MAP[e.type];
if (e.type === "INITIAL_PURCHASE" && e.period_type === "TRIAL") {
  type = "trial_start";
}

The amount is then forced to zero rather than trusted from the payload, because a store reporting a non-zero price on a trial should not be able to create a payable balance. A unit test pins both halves: type trial_start, amount 0.

The conversion is a separate delivery. RevenueCat's common webhook flows page ends the trial path with a RENEWAL carrying is_trial_conversion set to true. Pay on that one.

Commissioning trial starts is a mistake in almost every creator deal, and the arithmetic is why. 400 trial starts converting at 35 percent produce 140 paid subscriptions worth $2.00 each, so $280. A $0.50 per trial bounty on the same traffic costs $200 before a single renewal, and pays the same at a 4 percent conversion rate as at 35. Pay per trial only against a cap and a conversion floor.

#RENEWAL is where recurring commission actually accrues

RENEWAL fires when an existing subscription renews or a lapsed customer resubscribes, and on a recurring creator deal it produces most of the money. It also fires on trial conversion and after a billing failure recovers inside a grace period, which makes it the busiest path in a commission engine.

Two price fields arrive on the same payload and they are not interchangeable. RevenueCat's reference defines price as the transaction price converted to USD and price_in_purchased_currency as the price in the currency the customer paid, with currency holding the ISO 4217 code. Our normalizer prefers the second and falls back to the first:

typescript
const priceUnits = e.price_in_purchased_currency ?? e.price ?? 0;
const amountUsdCents = Math.max(0, Math.round(priceUnits * 100));

That keeps your numbers reconcilable against store reports, which are denominated in what the customer paid. It also means the integer you store is not always USD, so the currency code has to travel with the amount and conversion happens once, at payout, at a recorded rate. An amount field named for a currency it does not always hold reconciles fine for a year and then does not.

The commission itself is arithmetic on cents. Our webhook reference gives the formula as amount_usd_cents × rate_bps / 10000, rate snapshotted at the event. On a $9.99 plan at 20 percent that is 999 × 2000 / 10000, or 199.8 cents. Pick a rounding rule and write it down: rounding to 200 rather than truncating to 199 costs 0.8 cents per renewal, 9.6 cents across a 12 month recurrence, per subscriber.

Cap recurrence on renewal_number, documented as renewals completed starting at 1. A 12 month deal on a $9.99 plan tops out at 12 payable events and $24.00. Snapshot the rate on the first event; reading the current rate on every renewal means changing an affiliate's rate silently rewrites last year's economics.

#CANCELLATION is not the end of the money

A RevenueCat CANCELLATION means auto renew was switched off. It does not mean access ended, and with one exception it does not mean money came back. RevenueCat's flows page is explicit: "At the end of the billing cycle, an EXPIRATION webhook is sent and entitlements are revoked." Until then the subscriber is still paying.

The cancel_reason field carries the distinction, and it is the field most commission code forgets to read.

`cancel_reason`What happenedLedger effect
`UNSUBSCRIBE`The customer turned off auto renewNone. Past commissions stand
`BILLING_ERROR`Renewal failed and the subscription lapsedNone. Nothing was charged to reverse
`DEVELOPER_INITIATED`You cancelled itNone
`PRICE_INCREASE`The customer declined a price increaseNone
`CUSTOMER_SUPPORT`The latest period was refundedReverse that period's commission
`UNKNOWN`Reason not reported by the storeNone, and worth an alert

Values verified against RevenueCat's event types and fields reference on 2026-09-12. Only CUSTOMER_SUPPORT touches the balance.

Our normalizer maps CANCELLATION to a cancel event and EXPIRATION to an expiration event, and neither is commissionable. What both do is stop the recurrence clock: after EXPIRATION there is no future RENEWAL to pay on, so a creator report should show the recurrence closed rather than the earnings withdrawn.

A subscriber on the $9.99 plan at 20 percent who bought on 8 March and cancelled on 1 June after three paid periods earned the creator $6.00. The CANCELLATION reverses none of it, and neither does the 8 June EXPIRATION. A smaller number on the dashboard is a reporting bug, and the one most likely to end a creator relationship: they watch that balance more carefully than you watch your handler.

#Refunds arrive as CANCELLATION, not as a refund event

RevenueCat's event reference contains no type named REFUND. A refunded subscription arrives as a CANCELLATION carrying cancel_reason of CUSTOMER_SUPPORT, which the docs describe as covering Apple support refunds, Google Play refunds through RevenueCat, Amazon support refunds and web billing refunds. REFUND_REVERSED is the separate App Store event for a refund that is later undone.

The scope is narrow, and the reference says so: "In the case of subscription refunds, this event fires only when the latest subscription period is refunded; refunds for earlier periods do not trigger it." A customer refunded three months through Apple support may generate one webhook, not three. Catching the rest needs store report reconciliation, and we know of no way to do it from webhooks alone.

Six rules for the reversal path:

  1. Match the refund to a period, not a subscriber: reverse the commission created by the INITIAL_PURCHASE or RENEWAL whose expiration_at_ms covers it.
  2. Reverse at the snapshotted rate. Recomputing from the cancellation payload gives you whatever price that event carries.
  3. Hold new commissions before they become payable. Our engine writes them pending and matures them after the hold.
  4. Reverse a pending commission by cancelling it. Nothing left your account, so nothing is owed back.
  5. Reverse a paid commission as a negative on the next statement. Invoicing a creator for money you already sent is a support ticket, not a recovery.
  6. Implement REFUND_REVERSED, which is rare enough to skip and rare enough that nobody notices the gap until a creator does.

The hold length is the trade off, and it is arithmetic. Under a 30 day hold, a refund on 20 May against a commission created on 8 May finds it 12 days old and still pending, so the reversal is a status change. The same refund on 20 June finds a commission that matured on 7 June and shipped in the June payout. We default to 30 days: it covers most store refund activity while still paying creators inside a month, and a 7 day hold pushes more reversals into the clawback path, which costs more goodwill than waiting does.

#SUBSCRIBER_ALIAS and TRANSFER can move revenue off the attributed user

SUBSCRIBER_ALIAS and TRANSFER are RevenueCat's identity events, and both can detach revenue from the user you attributed. SUBSCRIBER_ALIAS reports a new App User ID registered for an existing subscriber; the reference marks it deprecated and says new projects do not receive it. TRANSFER reports transactions and entitlements moving between App User IDs.

The harder problem starts before either event fires. RevenueCat's user ids documentation says "the RevenueCat SDK will generate anonymous App User IDs for customers" by default, and one customer may be referenced by several ids, called aliases. Their sample CANCELLATION payload shows it on the wire:

{
  "app_user_id": "$RCAnonymousID:12345678-1234-1234-1234-123456789123",
  "aliases": [
    "$RCAnonymousID:12345678-1234-ABCD-1234-123456789123",
    "user_1234"
  ],
  "original_app_user_id": "$RCAnonymousID:12345678-1234-ABCD-1234-123456789123"
}

The identified id, user_1234, sits in aliases while app_user_id is still anonymous. Code that joins on app_user_id alone finds nothing and pays nobody. It is the most common attribution miss we see behind a working RevenueCat integration and an empty commission table.

Match on the whole identity set instead:

  1. Collect app_user_id, original_app_user_id and every entry in aliases into one candidate set.
  2. Match an attribution record on any candidate, newest first, inside the window.
  3. On TRANSFER, read transferred_from and transferred_to and decide whether attribution follows the entitlement.
  4. Re-run the match on the next money event instead of caching a failure. The alias that fixes it may arrive later.

A second route sidesteps the alias graph entirely. Every event carries a subscriber_attributes object, so an affiliate id written on the client before the purchase arrives attached to the money. The mechanics are in getting the affiliate id into the payload.

Our normalizer reads app_user_id and nothing else, and returns null when it is absent. The boundary is deliberate: normalization stays a pure mapping, and identity resolution lives one layer up, where it can see the alias graph and the click history. An identity change can then be replayed against stored events without re-ingesting anything.

We would let attribution follow the transfer, and we hold that loosely. A subscription moving from user_a to user_b keeps firing RENEWAL at $9.99, and the creator who earned it stops receiving $2.00 a month while the revenue continues. The counter argument is that a transfer can be a handover to someone the creator never reached. Pick one, put it in the affiliate terms, and log every TRANSFER.

#Keeping sandbox and TestFlight purchases out of payouts

The environment field is the gate. RevenueCat sets it to SANDBOX or PRODUCTION on every event, and the sandbox documentation says RevenueCat "automatically detects the environment (production vs. sandbox) in which a purchase occurs". There is nothing to configure and no excuse for missing it. Gate at ingest, before attribution runs.

Three signals separate real money from test money, and a commission engine should check all of them.

SignalValueRule
`environment``SANDBOX`Store the event, mark it non-payable
`environment``PRODUCTION`Eligible for commission
`store``TEST_STORE`Store the event, mark it non-payable
`type``TEST`Answer 200, log it, create nothing

A missing environment check is worth real money to anyone who notices it. Sandbox subscriptions renew on a compressed schedule, so 52 RENEWAL events are an afternoon's work for one tester. At 20 percent of a $9.99 plan that is 52 × $2.00, or $104.00 of fictional commission in an affiliate's balance, created by your own QA.

TestFlight adds a second problem. RevenueCat's sandbox page notes that store APIs "often do not return accurate prices across regions, including in TestFlight on iOS", so the amounts are not trustworthy either. A build reporting a plausible price is more dangerous than one reporting zero, because plausible numbers survive review.

Store sandbox events rather than dropping them, marked non-payable. The first question anyone asks after wiring a webhook is why their test purchase did not appear, and a dropped event cannot answer it. For the wiring itself our webhook docs point at a smoke command (pnpm --filter @maa/api smoke) that rehearses the loop against the live database and leaves nothing to exclude later.

#Making ingest safe: idempotency, persist before process, retries

Idempotent ingest means a webhook delivered twice produces one stored event and one commission. RevenueCat's webhook documentation warns that "in some rare situations, your application may receive a webhook for the same event more than once", and a retry reuses the same id. A unique constraint on source plus event id is most of the defence.

RevenueCat retries. Your ledger must not double count.

Seven rules, in the order the request hits them:

  1. Compare the credential in constant time. A byte by byte early return leaks the secret's prefix to anyone patient enough to time it.
  2. Store the raw body before parsing, keyed by source and event id. A parser exception then costs a retry, not a financial event.
  3. Return 200 as soon as the payload is durable. RevenueCat disconnects after 60 seconds and treats anything but 200 as a failure.
  4. Index the pair, not the event id alone. Two billing sources can issue the same identifier.
  5. Normalize in a separate step reading from storage, so a fixed parser can be replayed over yesterday's payloads.
  6. Key the commission write on the same pair, so a replay cannot create a second balance entry.
  7. Order by event_timestamp_ms, not arrival. RevenueCat's flows page notes a recovery RENEWAL can appear before the BILLING_ISSUE that preceded it.
sql
create unique index events_source_event_id_key
  on events (source, source_event_id);

This is the mapping MyAppAffiliate's ingest implements. The raw payload is persisted before parsing, so a parser bug never loses a financial event, and a re-fired webhook with the same event id produces no second commission, enforced by a unique (source, sourceEventId). The Authorization header is compared in constant time against the SHA-256 stored for your app, and a bad token is recorded and rejected with a 401. Commission is amount_usd_cents × rate_bps / 10000 at a snapshotted rate, attributed to the most recent referral inside the window, created pending and matured after the hold. Endpoint shape: the webhook reference.

We return 200 on a payload we have stored but not yet understood, and we would argue with anyone who does the opposite. A 500 on an event you parsed but failed to save buys five retries and then permanent silence.

#A six month ledger for one subscriber

One subscriber on a $9.99 monthly plan, a 20 percent creator rate, a 12 month recurrence cap and a 30 day hold produces nine RevenueCat webhook events across six months. Four change the affiliate balance. The creator finishes with $4.00 against a maximum of $24.00, and every step below is arithmetic you can recompute.

The balance only changes on four of the nine events shown.

Each commission is 999 × 2000 / 10000, or 199.8 cents, rounded to $2.00. Three credits and one reversal leave $4.00.

DateEventDeciding fieldCommissionPendingMaturedBalance
1 Mar`INITIAL_PURCHASE``period_type` is `TRIAL`, price 0None$0.00$0.00$0.00
8 Mar`RENEWAL``is_trial_conversion` is true, price 9.99+$2.00$2.00$0.00$2.00
7 AprHold expires (no webhook)30 days after 8 MarStatus only$0.00$2.00$2.00
8 Apr`RENEWAL``renewal_number` 2, price 9.99+$2.00$2.00$2.00$4.00
2 May`BILLING_ISSUE`Charge attempt failedNone$2.00$2.00$4.00
2 May`CANCELLATION``cancel_reason` is `BILLING_ERROR`None$2.00$2.00$4.00
8 MayHold expires (no webhook)30 days after 8 AprStatus only$0.00$4.00$4.00
8 May`RENEWAL`Grace period recovered, `renewal_number` 3+$2.00$2.00$4.00$6.00
20 May`CANCELLATION``cancel_reason` is `CUSTOMER_SUPPORT`-$2.00$0.00$4.00$4.00
1 Jun`CANCELLATION``cancel_reason` is `UNSUBSCRIBE`None$0.00$4.00$4.00
8 Jun`EXPIRATION`Entitlement revokedNone$0.00$4.00$4.00

Two rows break implementations. On 2 May the billing failure arrives as a pair, BILLING_ISSUE and a CANCELLATION with cancel_reason of BILLING_ERROR, and the recovery is the 8 May RENEWAL; code that pays on both books $4.00 for one charge. On 20 May the refund reverses a commission twelve days old, still inside the hold, so nothing is clawed back from a creator already paid.

The 1 June CANCELLATION and the 8 June EXPIRATION are the quiet test. Neither touches the money. They end the recurrence at renewal_number 3 of a possible 12, so forward looking earnings on this subscriber drop from $18.00 to zero while the earned balance stays at $4.00. Two different numbers, and a dashboard showing only one of them gets argued with.

We show pending and matured as separate columns rather than one balance, because the most common creator question is why a number went down, and the honest answer is usually that it never went up.

#Wire the four money events before anything else

A correct commission ledger needs four handlers and one index. Handle INITIAL_PURCHASE with a paid period_type, RENEWAL, NON_RENEWING_PURCHASE, and CANCELLATION carrying cancel_reason of CUSTOMER_SUPPORT. Store every other event raw and payable to nobody. Add the unique index on source and event id before the first delivery, not after the first duplicate.

Do this next. Open Integrations → Webhooks in the RevenueCat dashboard, send a TEST event at your endpoint, then send it again and check that your database holds exactly one row for that id. Until a duplicate has bounced off a unique constraint in your own environment, you have a plan rather than idempotency. The attribution half of the pipeline, joining a click to an app_user_id in the first place, is in the full RevenueCat attribution setup.

Which RevenueCat event should create an affiliate commission?

Three events create one: `INITIAL_PURCHASE` with a `period_type` of `NORMAL`, `INTRO` or `PREPAID`, plus `RENEWAL` and `NON_RENEWING_PURCHASE`. A fourth, `CANCELLATION` with `cancel_reason` of `CUSTOMER_SUPPORT`, reverses one. Every other type in RevenueCat's reference describes access or intent rather than a charge, so none should touch a payable balance.

Does `CANCELLATION` mean I should reverse the commission?

Only when `cancel_reason` is `CUSTOMER_SUPPORT`, which is how RevenueCat reports a refund. A `CANCELLATION` carrying `UNSUBSCRIBE`, `BILLING_ERROR`, `DEVELOPER_INITIATED`, `PRICE_INCREASE` or `UNKNOWN` means auto renew stopped and the subscriber keeps access until `EXPIRATION`. Past commissions stand in all of those cases, and reversing them is a reporting bug.

How do I avoid paying commission on free trials?

Read `period_type` on `INITIAL_PURCHASE`. A value of `TRIAL` means the price is zero, so record a trial start worth $0 and pay nothing on it. The conversion arrives later as a separate `RENEWAL` with `is_trial_conversion` set to true, and that renewal is the event worth a commission.

What stops a retried webhook from paying twice?

A unique database constraint on the pair of source and event id, enforced when the raw payload is stored and again when the commission is written. RevenueCat retries a failed delivery up to five times and reuses the same `id`, so deduplication belongs in your schema rather than in application logic.

Which price field should the commission be calculated on?

Use `price_in_purchased_currency` when present and fall back to `price`. RevenueCat defines `price` as the USD converted amount and `price_in_purchased_currency` as what the customer actually paid, so the second reconciles against store reports. Carry `currency` alongside the amount and convert once, at payout, at a rate you record.

How do I keep sandbox purchases out of payouts?

Gate on `environment` at ingest and accept only `PRODUCTION`. Exclude a `store` of `TEST_STORE` and the `TEST` event type as well. Store the rejected deliveries instead of dropping them, marked non payable, so you can still answer the question of where somebody's test purchase went.