---
title: "RevenueCat Webhooks for Affiliate Commissions Explained"
description: "Which RevenueCat webhook events move money, which are noise, and exactly what each one should do to an affiliate commission ledger. With a worked example."
canonical: https://www.myappaffiliate.com/blog/revenuecat-webhooks-affiliate-commissions
published: 2026-09-12
author: "Cuma Ali Kesici"
category: "RevenueCat"
tags: [RevenueCat, iOS, Android]
source: MyAppAffiliate
---

# 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.

## Key takeaways

- Four events move money. The rest are funnel signal or noise.
- A trial start arrives as `INITIAL_PURCHASE` with `period_type` of `TRIAL` and a zero price.
- `CANCELLATION` changes nothing financially on its own. `EXPIRATION` ends recurrence.
- There is no `REFUND` event type. Refunds arrive as `CANCELLATION` with `cancel_reason` of `CUSTOMER_SUPPORT`.
- RevenueCat documents retries, so ingest has to be idempotent on event id.

## 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](https://www.revenuecat.com/docs/integrations/webhooks) 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.**

| Field | Type | Why a commission engine reads it |
| --- | --- | --- |
| `id` | String | The dedupe key. A retry reuses the same value |
| `type` | String | Selects the ledger effect |
| `app_user_id` | String | Joins the event to the user you attributed |
| `original_app_user_id` | String | First App User ID the subscriber ever had |
| `aliases` | Array | Every 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` | Double | Transaction price converted to USD |
| `price_in_purchased_currency` | Double | Price in the currency the customer actually paid |
| `currency` | String | ISO 4217 code for the purchased currency |
| `commission_percentage` | Double | The store's cut. `0.3` in RevenueCat's own sample event |
| `takehome_percentage` | Double | What reaches you after store commission and tax. `0.7` in the same sample |
| `tax_percentage` | Double | Estimated 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` | Integer | When the transaction happened |
| `event_timestamp_ms` | Integer | When RevenueCat generated the event. Use it for ordering |
| `expiration_at_ms` | Integer | When the entitlement lapses |
| `renewal_number` | Integer | Renewals 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` | Boolean | A Family Sharing seat pays you nothing |
| `transaction_id`, `original_transaction_id` | String | Store level identity for reconciliation against App Store Connect |
| `subscriber_attributes` | Object | Where 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](https://www.revenuecat.com/docs/integrations/webhooks/event-types-and-fields) 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.**

| Event | What happened | Commissionable | Effect on the ledger | Gotcha |
| --- | --- | --- | --- | --- |
| `TEST` | RevenueCat issued a test event from the dashboard | No | Nothing. Log it | The 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 for | Yes | Create a pending commission on the paid amount | An `INTRO` price is below list price. Commission what was charged, not the sticker |
| `INITIAL_PURCHASE` (`period_type` `TRIAL`) | A subscription started inside a free trial | No | Record a trial start at $0 | Price is 0. Paying here funds trial abuse |
| `INITIAL_PURCHASE` (`period_type` `PROMOTIONAL`) | You granted the entitlement; nobody bought it | No | Nothing payable | Promotional grants carry no store revenue |
| `RENEWAL` | An existing subscription renewed, or a lapsed user resubscribed | Yes | Create a pending commission on the renewal amount | Also 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 renew | Yes | Create a pending commission | Consumables 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 period | No | Nothing | The most misread event in the set. No money has moved either way |
| `CANCELLATION` (`cancel_reason` `CUSTOMER_SUPPORT`) | The latest subscription period was refunded | Yes, negatively | Reverse the commission for that period | This 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 lapsed | No | Nothing | Pairs with `BILLING_ISSUE`. Not a refund, so nothing to reverse |
| `UNCANCELLATION` | A cancelled but unexpired subscription was re-enabled | No | Nothing | No charge happens here. The next `RENEWAL` is the money |
| `EXPIRATION` | The subscription expired and entitlements were revoked | No | Close the recurrence. Reverse nothing | The end of future commission, never of past commission |
| `BILLING_ISSUE` | An attempt to charge the subscriber failed | No | Nothing | If 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 end | No | Suspend the expected next renewal | A Play Store feature. The subscriber has not churned |
| `PRODUCT_CHANGE` | The subscriber changed the product of their subscription | No | Nothing on its own | It arrives alongside a `RENEWAL` on App Store, or an `INITIAL_PURCHASE` on Play Store. Commission the paid event |
| `SUBSCRIPTION_EXTENDED` | An existing subscription was extended | No | Push the expected renewal date out | Free time granted. No revenue attached |
| `REFUND_REVERSED` | A refund was reversed | Yes, positively | Restore the commission you reversed | App Store only. Rare, and the easiest one to leave unimplemented |
| `INVOICE_ISSUANCE` | A new, unpaid invoice was issued | No | Nothing | Issued is not paid. Wait for the `RENEWAL` |
| `TRANSFER` | Transactions and entitlements moved between App User IDs | No | Re-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 subscriber | No | Merge the identities before matching | Deprecated. New projects do not receive it |
| `TEMPORARY_ENTITLEMENT_GRANT` | RevenueCat issued a temporary outage grant | No | Nothing | An availability measure, not a sale |
| `VIRTUAL_CURRENCY_TRANSACTION` | An in-app currency transaction occurred | Only if the deal says so | Nothing by default | Decide deliberately. Most creator deals cover subscriptions only |
| `EXPERIMENT_ENROLLMENT` | A customer was enrolled in an experiment | No | Nothing | Useful later for explaining why two creators convert differently |
| `PURCHASE_REDEEMED` | A Paddle, RevenueCat Billing or Stripe purchase was redeemed | No | Nothing on its own | Redemption is not the charge |
| `PRICE_INCREASE_CONSENT_REQUIRED` | A price increase needs consent before renewal | No | Flag the next renewal as at risk | Silence here turns into an `EXPIRATION` weeks later |
| `PRICE_INCREASE_CONSENT_APPROVED` | The customer consented to a pending price increase | No | Expect a larger `RENEWAL` amount | Do not reuse the old snapshotted price for the next commission |

Event names and descriptions checked against RevenueCat's [event types and fields](https://www.revenuecat.com/docs/integrations/webhooks/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](https://www.revenuecat.com/docs/integrations/webhooks/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](https://www.revenuecat.com/docs/integrations/webhooks/event-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](https://docs.myappaffiliate.com/webhooks) 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 happened | Ledger effect |
| --- | --- | --- |
| `UNSUBSCRIBE` | The customer turned off auto renew | None. Past commissions stand |
| `BILLING_ERROR` | Renewal failed and the subscription lapsed | None. Nothing was charged to reverse |
| `DEVELOPER_INITIATED` | You cancelled it | None |
| `PRICE_INCREASE` | The customer declined a price increase | None |
| `CUSTOMER_SUPPORT` | The latest period was refunded | Reverse that period's commission |
| `UNKNOWN` | Reason not reported by the store | None, and worth an alert |

Values verified against RevenueCat's [event types and fields](https://www.revenuecat.com/docs/integrations/webhooks/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.

> **Note:** A trial start is never reversed, because it never created a payable amount. If your reversal code has to check for a trial commission, the trial branch upstream is not doing its job.

## `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](https://www.revenuecat.com/docs/customers/user-ids) 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](/blog/revenuecat-subscriber-attributes-affiliate-id).

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](https://www.revenuecat.com/docs/test-and-launch/sandbox) 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.

| Signal | Value | Rule |
| --- | --- | --- |
| `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](https://docs.myappaffiliate.com/webhooks) 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](https://www.revenuecat.com/docs/integrations/webhooks) 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.

![Duplicate webhook delivery rejected by a unique constraint on source and event id](https://www.myappaffiliate.com/blog-images/revenuecat-webhooks-affiliate-commissions/revenuecat-webhooks-affiliate-commissions-idempotency.webp "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](https://docs.myappaffiliate.com/webhooks).

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.

![Subscription lifecycle timeline with the affiliate commission balance changing at each event](https://www.myappaffiliate.com/blog-images/revenuecat-webhooks-affiliate-commissions/revenuecat-webhooks-affiliate-commissions-lifecycle.webp "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.**

| Date | Event | Deciding field | Commission | Pending | Matured | Balance |
| --- | --- | --- | --- | --- | --- | --- |
| 1 Mar | `INITIAL_PURCHASE` | `period_type` is `TRIAL`, price 0 | None | $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 Apr | Hold expires (no webhook) | 30 days after 8 Mar | Status 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 failed | None | $2.00 | $2.00 | $4.00 |
| 2 May | `CANCELLATION` | `cancel_reason` is `BILLING_ERROR` | None | $2.00 | $2.00 | $4.00 |
| 8 May | Hold expires (no webhook) | 30 days after 8 Apr | Status 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 revoked | None | $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](/blog/revenuecat-affiliate-tracking).

## FAQ

### 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.
