---
title: "Superwall Affiliate Tracking: Paywall Events to Payouts"
description: "Superwall affiliate tracking with webhooks: the join key, which of the nine event types move money, refunds by sign, and the RevenueCat double count."
canonical: https://www.myappaffiliate.com/blog/superwall-affiliate-tracking
published: 2026-09-15
author: "Cuma Ali Kesici"
category: "RevenueCat"
tags: [Superwall, iOS, RevenueCat, Android]
source: MyAppAffiliate
---

# Superwall Affiliate Tracking: Paywall Events to Payouts

Superwall shows the paywall, records the purchase and posts a webhook. It has no concept of an affiliate, no field for a creator, no event called refund. Superwall affiliate tracking is therefore one join and one mapping: match `originalAppUserId` to the user your attribution layer stored against a click, then decide which of the nine event types change a balance. Three create money. Two close a subscription. Four are noise.

Below: the payload, the four ways the join key breaks, the Svix signature, the refund that arrives as a minus sign, and the failure that costs most, which is running Superwall next to RevenueCat. Every event and field name was copied from Superwall's docs and verified on 2026-09-14.

## Key takeaways

- Three of Superwall's nine event types create a commission. Refunds have no event type at all.
- `originalAppUserId` is the join key, and it silently falls back to a `$superwallAlias` string.
- Superwall signs through Svix, so a plain body HMAC never verifies.
- Read the sign of `price` before the event name, or a refund credits the creator a second time.
- Superwall plus RevenueCat on one app double-counts. Run one revenue source per app.

## What Superwall affiliate tracking needs out of the webhook payload

A Superwall webhook is a small envelope around a fat `data` object. The envelope carries `object`, `type`, `projectId`, `applicationId` and `timestamp`; everything a commission depends on sits one level down, inside `data`. Superwall's [webhook reference](https://superwall.com/docs/integrations/webhooks) documents the full list; nine fields do real work in a ledger, the rest are analytics.

| Field | What Superwall says it holds | Why a ledger reads it |
| --- | --- | --- |
| `data.id` | Unique identifier for this event | The idempotency key. Redeliveries repeat it |
| `data.originalAppUserId` | Original app user ID, requires SDK v4.5.2+ | The only join to a click. Null and the event is unpayable |
| `data.productId` | Product identifier | Per-product commission rates, and half of the planned dedupe key |
| `data.price` | Transaction price in USD (negative for refunds) | The gross basis, and the refund signal |
| `data.proceeds` | Net proceeds in USD after taxes and fees | The net basis, for a deal written after the store's cut |
| `data.currencyCode` | ISO currency code for priceInPurchasedCurrency | Display only. It does not describe `price` |
| `data.periodType` | TRIAL, INTRO or NORMAL | Separates a free trial from a paid first period |
| `data.environment` | PRODUCTION or SANDBOX | The line between a real payout and a TestFlight purchase |
| `data.ts` | Event timestamp (milliseconds) | When the money moved, which is what an attribution window compares |

Two things an affiliate system wants are absent: a refund event type, and any identifier shared with another billing vendor. The second is the subject of the double-count section below. The missing affiliate field bothers us not at all, because the user id route is the one we would pick anyway.

Two timestamps look interchangeable and are not. Superwall's notes are explicit: root `timestamp` is "When the webhook was created", `ts` inside `data` is "When the actual event occurred". Windows compare against the event, not the delivery, so store `ts`. Our normalizer reads `data.ts`, then `data.purchasedAt`, then root `timestamp`, all epoch milliseconds. Superwall also carries `transactionId` and `originalTransactionId`; we read neither.

## `originalAppUserId` is the join key, and four things break it

`originalAppUserId` is the first app user id Superwall saw for a subscription, and the only string connecting a paywall purchase to a creator's click. When it is missing or wrong, the purchase still bills, the webhook still arrives, and nobody gets paid. Four causes produce that outcome.

**An SDK that is too old.** Superwall's field reference says the value "is only set correctly for events generated by users on SDK v4.5.2+". Their [troubleshooting page for this symptom](https://superwall.com/docs/support/troubleshooting/5131096404-why-is-my-webhook-s-originalappuserid-different-from-the-user-id-i-set) gives a higher number: iOS 4.6.0, expo-superwall 0.2.7. Both were open on 2026-09-14 and they disagree. Take the higher number.

**Identifying after the purchase.** The same page states the timing rule without hedging: call `identify()` before any transactions are made, because after a purchase has occurred the webhook still contains the original alias id. Superwall keeps only the first user id it saw for a subscription, so this is permanent for that subscriber.

**A user id that is not a UUID.** Quiet and expensive. Superwall's [`identify()` reference](https://superwall.com/docs/ios/sdk-reference/identify) warns that `appAccountToken` must be a UUID for StoreKit to accept it, and that the SDK falls back to the anonymous alias UUID otherwise. An app identifying with `user_48213` gets a populated `originalAppUserId` that matches nothing.

**Android without the option set.** The troubleshooting page tells Android apps to configure `passIdentifiersToPlayStore` in `SuperwallOptions` on top of a current SDK.

All four leave the same fingerprint. Superwall's sample payload shows `"originalAppUserId": "$SuperwallAlias:7152E89E-60A6-4B2E-9C67-D7ED8F5BE372"`, while the troubleshooting page writes the prefix as `$superwallAlias:`. The case differs between two pages of one documentation set, so match it case-insensitively.

> **Note:** Our normalizer accepts any non-empty `originalAppUserId`, alias included. The event lands, the join fails, and the dashboard calls it unattributed rather than unidentified. Different problems, different fixes, and today we cannot tell them apart.

One rule covers all of it: call `Superwall.shared.identify(userId:)` with a UUIDv4 at login, before any paywall, passing the same string you give our SDK's `identify(userId)`. That is the integration, and it is the shape [Adapty's `customer_user_id`](/blog/adapty-affiliate-tracking) takes too. Late identity is also a window problem, covered in [how attribution windows work](/blog/how-attribution-windows-work).

## A Superwall webhook is Svix-signed, so a body HMAC never verifies

Superwall does not sign the body itself. It delivers through [Svix](https://docs.svix.com/receiving/verifying-payloads/how-manual): three headers, a signed string built from two of them plus the raw body, and a secret you split before using. Code written for an Authorization-header scheme fails every delivery.

Superwall's [verification page](https://superwall.com/docs/integrations/webhooks/verify) documents both routes: the Svix library, or a manual HMAC in four rules.

1. Read `svix-id`, `svix-timestamp` and `svix-signature`. Svix documents the timestamp as seconds since epoch and the signature header as a Base64 encoded list, space delimited.
2. Build the signed content as `{svix-id}.{svix-timestamp}.{raw body}`, joined with full stops.
3. Decode the base64 portion of the secret, the part after the `whsec_` prefix, and HMAC-SHA256 the signed content with those bytes. Compare base64.
4. Check every entry in the header, not the first. Entries look like `v1,<signature>`, and Svix notes there could be any number of them, which is what a rotation looks like from the receiving end.

The core of it, out of our normalizer:

```typescript
const expected = createHmac("sha256", secretKey(secret))
  .update(`${id}.${ts}.${body}`, "utf8")
  .digest();
```

`secretKey` strips `whsec_` and base64-decodes the rest; `body` is the raw request body, never a re-serialized object. Superwall says the same in bold, with a wrong-way example that parses the JSON and stringifies it back.

Two details in Superwall's sample are worth knowing before copying it. Their timestamp check is one-sided, so a delivery stamped an hour in the future passes; ours compares the absolute difference against the same 300 second tolerance. Their comparison calls `crypto.timingSafeEqual` on buffers of possibly different length, and Node throws a `RangeError` rather than returning false, so a `v2` entry in a rotated header aborts the check instead of failing it. We length-check first.

Svix retries on the schedule "Immediately, 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, 10 hours", counts only a 2xx as delivered, and expects an answer inside 15 seconds. A handler that returns 401 on a bad signature sees that same delivery eight times. Store the raw body first, answer fast, work after.

## Which of Superwall's nine event types move a creator balance

Nine event types exist. Three create a commission, two close the subscription, four should never touch a balance. Superwall's own note on price checks the split: `billing_issue`, `cancellation`, `expiration`, `uncancellation`, `product_change` and `subscription_paused` commonly carry `price = 0`.

| Event type | What happened | Commissionable | Effect on the ledger | Gotcha |
| --- | --- | --- | --- | --- |
| `initial_purchase` | First-time subscription or purchase | Yes, when `price` is positive | Create a pending commission | With `periodType: TRIAL` this is a free trial and `price` is 0 |
| `renewal` | Subscription renewal | Yes | Create a pending commission | Also the trial payday. Check `isTrialConversion` |
| `non_renewing_purchase` | One-time purchase | Yes | Create a pending commission | Not sent by Stripe, per Superwall's store matrix |
| `cancellation` | Subscription cancelled | No | Stop expecting the next renewal | Auto-renew is off, access continues. Past commissions stand |
| `expiration` | Subscription expired | No | Close the recurrence | The end of future commission, never of past commission |
| `uncancellation` | Subscription reactivated | No | Re-arm the next renewal | No charge happened, so there is nothing to pay |
| `billing_issue` | Payment processing failed | No | Flag the renewal as at risk | The recovery arrives later as a `renewal`. Paying on both pays twice |
| `product_change` | User changed subscription tier | No | Update the expected price | `newProductId` holds the destination. App Store and Play only |
| `subscription_paused` | Subscription temporarily paused | No | Suspend the expected renewal | Play Store only. The subscriber has not churned |

Two of Superwall's detection snippets contradict the intuition carried from other vendors. A trial start is `periodType === "TRIAL" && name === "initial_purchase"`. A trial conversion is a `renewal` with `isTrialConversion` true, and Superwall states the rule plainly: `isTrialConversion` should only be true for `renewal` events. The money from a converted trial therefore arrives under the same event name as the fourth month's renewal, and paying only on `initial_purchase` earns a creator nothing on a trial-first funnel.

We map `initial_purchase` and `non_renewing_purchase` to a `purchase`, `renewal` to a `renewal`, `cancellation` to a `cancel` and `expiration` to an `expiration`, as our [webhook reference](https://docs.myappaffiliate.com/webhooks) publishes. We do not read `periodType`, so a Superwall trial start lands as a $0 `purchase`: harmless arithmetically, wrong in the funnel. The economics of paying on trials are in [commission on trials and renewals](/blog/affiliate-commission-free-trials-renewals).

### Superwall has no refund event, so the sign is the signal

There is nothing named `refund` in the list of nine. Superwall's notes say it in one line: "Negative values in `price`, `proceeds`, or `priceInPurchasedCurrency` indicate refunds", and their detection snippet is `if (event.data.price < 0)`. Which event type carries a refund is undocumented, which is the point. Key on the sign, never the name.

![Two Superwall payloads side by side, one with a positive price and one with a negative price routed to a reversal](https://www.myappaffiliate.com/blog-images/superwall-affiliate-tracking/superwall-affiliate-tracking-refund-sign.webp "The same event type produces a credit or a reversal depending only on the sign of price.")

Read the sign first, then the type:

```typescript
const priceUsd = data.price ?? 0;
const isRefund = priceUsd < 0 || (data.proceeds ?? 0) < 0;
const type = isRefund ? "refund" : TYPE_MAP[body.type];
```

We had this backwards once. Taking the magnitude first, then looking up the type, turned a refunded purchase into a positive `purchase`: not a missed reversal but a second credit against the same subscriber. A missed reversal costs one commission. A second credit costs two and has to be explained to the creator.

Two limits on our version. We read the sign of `price` and `proceeds` but not of `priceInPurchasedCurrency`, Superwall's documented third signal. And Superwall's refunds come from the stores, where Apple also sends `REFUND_REVERSED` when it "reversed a previously granted refund due to a dispute that the customer raised". Superwall has no equivalent, so a reversed refund is invisible to a Superwall-only ledger.

### `price` or `proceeds`, and the field that mislabels both

Superwall is the easiest mobile vendor on money, because it hands you USD without an FX step. `price` and `proceeds` are always USD. `priceInPurchasedCurrency` is the local amount, `currencyCode` its currency, `exchangeRate` the rate used.

| Field | Superwall's definition | Use it for commission? |
| --- | --- | --- |
| `price` | Transaction price in USD, negative for refunds | Yes, for a gross deal. This is what we read |
| `proceeds` | Net proceeds in USD after taxes and fees | Yes, for a net deal |
| `priceInPurchasedCurrency` | Price in original currency | No. Needs `currencyCode` and an FX rate to mean anything |
| `commissionPercentage` | Store commission percentage | No, but store it to explain the gap between the two above |
| `takehomePercentage` | Your percentage after commission | No. Derived from the same split |

The gap between the two usable fields is the store's cut, documented per store: 30% on the App Store, 15% under the Small Business Program, 11.8% to 15% on Play, 0% to roughly 7.2% on Stripe. On a $9.99 plan at a 20% creator rate, gross pays 999 × 2000 / 10000, or 199.8 cents. Net at the standard App Store rate pays 699 × 2000 / 10000, or 139.8 cents. [Net versus gross on the app stores](/blog/affiliate-commission-net-vs-gross-app-store) works through both sides.

One field is a trap. `currencyCode` describes `priceInPurchasedCurrency` and nothing else. Pair it with `price` and you have labelled a USD figure with the buyer's currency: a display bug on a good day, a payout bug when someone sums by currency.

## Superwall plus RevenueCat on one app pays the creator twice

Most apps running Superwall paywalls also run RevenueCat for entitlements. One purchase then produces two webhooks from two vendors, and every affiliate platform that dedupes per source, ours included, creates two commissions. Neither vendor is at fault. It is the seam between them.

![One purchase fanning out to a Superwall webhook and a RevenueCat webhook, both reaching the same ledger](https://www.myappaffiliate.com/blog-images/superwall-affiliate-tracking/superwall-affiliate-tracking-double-count.webp "One purchase, two vendors, two event ids that never collide.")

Idempotency misses it for a boring reason. Our uniqueness constraint is `(source, sourceEventId)`. Superwall's event id looks like `42fc6339-dc28-470b-a0fa-0d13c92d8b61:renewal`; RevenueCat mints its own in its own format. The two rows differ in both columns, so the constraint is satisfied and a second commission is created. User id, product and amount all match, and nothing is looking at those three together.

Our position, and the interim rule in our own integration plan: **run one revenue source per app**. Cross-source dedupe on `(customerUserId, productId, occurredAt within a few seconds)` is planned and not started, so it is ours to fix rather than a setting you can switch on. Until it lands, connecting both endpoints for one app pays creators double.

Which one should own the money? If RevenueCat is in your stack at all, let RevenueCat own revenue and Superwall own paywall analytics.

| Question | Superwall | RevenueCat |
| --- | --- | --- |
| Join field | `originalAppUserId` | `app_user_id` |
| How it is set | `Superwall.shared.identify(userId:)`, must be a UUID on iOS | `Purchases.logIn(userId)` |
| Fails silently as | `$superwallAlias:<uuid>`, which looks populated | An anonymous id, which also looks populated |
| Currency of the amount | USD in `price` and `proceeds` | The purchased currency, so an FX step is yours |
| Refund arrives as | Any event with a negative `price` | `CANCELLATION` with `cancel_reason: CUSTOMER_SUPPORT` |
| Trial start is | `initial_purchase` with `periodType: TRIAL` | `INITIAL_PURCHASE` flagged as a trial |
| Sandbox marker | `environment` on every event | An environment field on every event |

The verdict rests on the identity row, not the money rows. RevenueCat's join key is whatever string you pass to `logIn`, with no UUID condition attached, so it degrades to a missing match rather than a plausible wrong one. Superwall is better on currency and worse on identity, and identity decides whether a commission exists at all. If Superwall is your only billing layer, the argument reverses. The mapping normalizes either way, which is the point of [one attribution engine across mobile and web](/blog/one-attribution-engine-mobile-and-web).

## Wiring Superwall to a commission ledger, in ten steps

Nothing here needs a paywall change: an SDK version, one call in the right place, one endpoint, one dashboard screen. Do the identity work first, because a webhook arriving before identity is correct is a purchase nobody can pay for.

1. Upgrade the Superwall SDK to at least 4.6.0 on iOS, the number the troubleshooting page gives, or expo-superwall 0.2.7 on Expo.
2. Call `Superwall.shared.identify(userId:)` after login, before any paywall is presented.
3. Pass a UUIDv4, so StoreKit accepts it for `appAccountToken` and the SDK does not fall back to the alias.
4. Set `passIdentifiersToPlayStore` in `SuperwallOptions` if you ship Android.
5. Identify the same string to your attribution SDK, or both halves look healthy and never meet.
6. Open Integrations then Webhooks in the Superwall dashboard, add your endpoint, and use Copy Secret to take the `whsec_` signing secret.
7. Verify every delivery against the raw body with the Svix library or the manual HMAC above.
8. Store the raw payload before parsing, dedupe on `data.id`, and return a 2xx inside 15 seconds.
9. Reject or quarantine anything with `environment: "SANDBOX"` before it reaches a payable balance.
10. Make one real sandbox purchase and confirm the delivery, signature and ledger row.

Step 9 is the one people skip, because `environment` sits near the end of a long `data` object. Sandbox purchases are free and repeatable: a creator with a TestFlight build can manufacture a balance out of nothing.

## A Superwall ledger across a trial, two renewals and a refund

One subscriber on a $9.99 monthly plan at a 20% creator rate produces seven Superwall events in four months. Three credit, one reverses. With the default 14 day hold the creator finishes with $4.00.

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` | `periodType` is TRIAL, `price` 0 | None | $0.00 | $0.00 | $0.00 |
| 8 Mar | `renewal` | `isTrialConversion` 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` | `price` 9.99 | +$2.00 | $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` | `price` 9.99 | +$2.00 | $2.00 | $4.00 | $6.00 |
| 20 May | Refund | `price` -9.99 | -$2.00 | $0.00 | $4.00 | $4.00 |
| 4 Jun | `cancellation` | `price` 0 | None | $0.00 | $4.00 | $4.00 |
| 8 Jun | `expiration` | `price` 0 | None | $0.00 | $4.00 | $4.00 |

Three rows carry the argument. On 8 March the payday is a `renewal`, not an `initial_purchase`, so a mapping built on first money equals first event pays this creator nothing. On 20 May the reversal comes from a minus sign, and it lands 12 days after the commission it reverses, still inside the hold, so it is a status change rather than a clawback. On 4 June the cancellation moves nothing: the subscriber keeps access until 8 June, and reversing there is how you lose a creator.

Now connect RevenueCat to the same app. Every money row fires twice, the two event ids never collide, and the creator finishes at $8.00 against $4.00 of real revenue share. Nothing in the ledger looks wrong from the inside, which is why this is worth designing around before the first payout.

## Where MyAppAffiliate fits, and the two bugs writing this post found

Superwall is a shipped source in our ingest: Svix verification over the raw body, a normalizer, and an endpoint at `/webhooks/superwall/<appId>`, listed on our public [webhook reference](https://docs.myappaffiliate.com/webhooks) beside RevenueCat, Adapty, Stripe and Paddle. Every source normalizes to one event vocabulary, so a commission behaves identically whoever bills your customers. We charge a flat monthly fee and no percentage of tracked revenue, the argument on [pricing](/pricing).

Fact-checking this post against Superwall's live docs turned up two bugs in our own code, so they belong here rather than in a changelog. Our normalizer set `currency` from `currencyCode` while taking the amount from `price`, which labelled a USD figure with the buyer's currency on any non-USD purchase and printed the pair side by side in the payout CSV. And nothing in the ingest read `environment`, so a TestFlight sandbox purchase normalized like a real one, on the same day our own go-live guide tells every new customer to make exactly that purchase. Both are fixed as this publishes, the second across all five sources rather than just Superwall's. The cross-source dedupe above is still planned.

## Make one TestFlight sandbox purchase before you trust a number

Do this next, in order. Upgrade the SDK, move `identify()` above the paywall, register the endpoint and paste the `whsec_` secret. Then buy something through TestFlight with a sandbox Apple ID and watch one delivery land, because Superwall does not support arbitrary test webhooks and StoreKit Configuration files never fire one.

Send it twice and confirm your database holds one row for that `data.id`. If RevenueCat also bills this app, disconnect one before any payout, and wire the survivor from [the full RevenueCat attribution setup](/blog/revenuecat-affiliate-tracking) and [the RevenueCat event mapping](/blog/revenuecat-webhooks-affiliate-commissions).

## FAQ

### Does Superwall have a built-in affiliate program?

No. Superwall's documentation covers paywalls, placements, webhooks and integrations, and none of it computes what a creator is owed. What Superwall gives you is a webhook carrying `originalAppUserId`, the product, the price and the store's cut, which is the input a commission ledger needs.

### Which Superwall event should create an affiliate commission?

Three: `initial_purchase`, `renewal` and `non_renewing_purchase`, and only when `price` is positive. `cancellation` and `expiration` close the subscription without moving money. `billing_issue`, `product_change`, `subscription_paused` and `uncancellation` should never touch a balance.

### Why is my `originalAppUserId` a `$superwallAlias` string?

Because Superwall stored an alias before you identified the user, and it keeps only the first id it saw for a subscription. StoreKit also rejects a non-UUID user id for `appAccountToken`, and the SDK falls back to the anonymous alias. Identify before the paywall, with a UUID.

### How do I stop Superwall and RevenueCat double counting one purchase?

Run one revenue source per app. Idempotency keyed on the source event id cannot see the collision, because the two ids come from different vendors and never match. Cross-source dedupe on `customerUserId`, `productId` and a few seconds of clock skew is planned, not shipped.

### Should a Superwall commission be based on `price` or `proceeds`?

`price` for a gross deal, `proceeds` for a net one. Both are already USD. `priceInPurchasedCurrency` holds the local amount, and `currencyCode` describes that field alone, so pairing `currencyCode` with `price` labels a USD figure with the wrong currency.

### How do I test a Superwall webhook?

With a real sandbox transaction. Superwall does not support arbitrary test webhooks, and iOS StoreKit Configuration files never fire one. Use TestFlight with a sandbox Apple ID, a Play license test account, or Stripe Test Mode, then filter on `environment` before anything pays out.
