Superwall affiliate tracking flow from a paywall purchase through a Svix signed webhook to a creator ledger

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.

#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 documents the full list; nine fields do real work in a ledger, the rest are analytics.

FieldWhat Superwall says it holdsWhy a ledger reads it
`data.id`Unique identifier for this eventThe 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 identifierPer-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 feesThe net basis, for a deal written after the store's cut
`data.currencyCode`ISO currency code for priceInPurchasedCurrencyDisplay only. It does not describe `price`
`data.periodType`TRIAL, INTRO or NORMALSeparates a free trial from a paid first period
`data.environment`PRODUCTION or SANDBOXThe 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 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 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.

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 takes too. Late identity is also a window problem, covered in 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: 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 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 typeWhat happenedCommissionableEffect on the ledgerGotcha
`initial_purchase`First-time subscription or purchaseYes, when `price` is positiveCreate a pending commissionWith `periodType: TRIAL` this is a free trial and `price` is 0
`renewal`Subscription renewalYesCreate a pending commissionAlso the trial payday. Check `isTrialConversion`
`non_renewing_purchase`One-time purchaseYesCreate a pending commissionNot sent by Stripe, per Superwall's store matrix
`cancellation`Subscription cancelledNoStop expecting the next renewalAuto-renew is off, access continues. Past commissions stand
`expiration`Subscription expiredNoClose the recurrenceThe end of future commission, never of past commission
`uncancellation`Subscription reactivatedNoRe-arm the next renewalNo charge happened, so there is nothing to pay
`billing_issue`Payment processing failedNoFlag the renewal as at riskThe recovery arrives later as a `renewal`. Paying on both pays twice
`product_change`User changed subscription tierNoUpdate the expected price`newProductId` holds the destination. App Store and Play only
`subscription_paused`Subscription temporarily pausedNoSuspend the expected renewalPlay 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 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.

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

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.

FieldSuperwall's definitionUse it for commission?
`price`Transaction price in USD, negative for refundsYes, for a gross deal. This is what we read
`proceeds`Net proceeds in USD after taxes and feesYes, for a net deal
`priceInPurchasedCurrency`Price in original currencyNo. Needs `currencyCode` and an FX rate to mean anything
`commissionPercentage`Store commission percentageNo, but store it to explain the gap between the two above
`takehomePercentage`Your percentage after commissionNo. 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 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, 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.

QuestionSuperwallRevenueCat
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 populatedAn anonymous id, which also looks populated
Currency of the amountUSD in `price` and `proceeds`The purchased currency, so an FX step is yours
Refund arrives asAny 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 eventAn 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.

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

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

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 and the RevenueCat event mapping.

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.