Adapty Affiliate Tracking: Webhook Setup for Creators
Adapty bills the subscription and knows nothing about the creator who sent the user. Closing that gap needs one join key: customer_user_id, the string your app hands to Adapty.identify(). Six of Adapty's eighteen event types change what a creator is owed, and a subscription app sees four.
Below: where the id is captured, which fields to read out of event_properties, the ordering trap, and a six month worked example on a $9.99 plan. Every event and field name was copied from Adapty's docs and verified on 2026-09-14.
#What Adapty affiliate tracking needs before a single commission is correct
Adapty affiliate tracking is the join between a creator's click and an Adapty billing event, made on one string. Your app passes a user id to Adapty.identify(), your attribution layer stores that id against the click, and every webhook Adapty sends afterwards carries it. No affiliate id enters Adapty at all.
Adapty's webhook field reference defines customer_user_id as the user id from your app, and warns about the empty case in the same sentence: "If you don't identify users in the app code or this specific user is anonymous (not logged in), this field is null." Verified on 2026-09-14. A null is a purchase nobody can be paid for.
Our normalizer falls back to profile_id, the UUID Adapty generates for every profile, so an unidentified event still lands on file even though it cannot attribute.
Two failure shapes explain most empty commission tables: the app identifies with a different string than the attribution SDK, so both halves look healthy and never meet, or the purchase happens on an anonymous profile and the id lands an hour later. The second is a window question, covered in how attribution windows work.
#Should the affiliate id live in Adapty custom attributes or in your own user id?
In your own user id. Adapty custom attributes can carry an affiliate id and do reach the webhook, in the top level user_attributes object, but only when Send user attributes is enabled and only if the attribute was written before the event fired. The user id route has neither condition.
| Route | What Adapty calls it | Arrives on the webhook as | Fails when |
|---|---|---|---|
| Your own user id | Customer User ID | `customer_user_id`, top level | The purchase happens on an anonymous profile |
| Adapty custom attribute | Custom user attributes | `user_attributes`, top level | Send user attributes is off, or the attribute was written after the event |
| Adapty's own id | Profile ID | `profile_id`, top level | Your click records do not store Adapty profile ids |
Adapty's setting user attributes page gives the builder call, checked on 2026-09-14. It writes one custom attribute onto the current profile:
do {
builder = try builder.with(customAttribute: "value1", forKey: "key1")
} catch {
// handle key/value validation error
}The attribute is sent with the next updateProfile call and rides along on later events. Adapty caps this: up to 30 custom attributes per user, key names up to 30 characters using alphanumerics plus _, - and ., and a value that is "a string or float with no more than 50 characters". A UUID fits. A signed click token does not.
We read no attribute at all. Our webhook reference says so: an earlier version of those docs described an affiliate_id attribute, nothing consumed it, and it was removed rather than left in as decoration. The same argument applies to RevenueCat subscriber attributes, where the attribute route is more tempting because the alias graph makes the id route harder.
#Which Adapty webhook events change what a creator is owed
Six of Adapty's eighteen documented event types touch a payable balance. subscription_started (also sent as subscription_initial_purchase), trial_converted, subscription_renewed and non_subscription_purchase create commission. subscription_refunded and non_subscription_purchase_refunded reverse it. The twelve others report access, intent or billing trouble.
| Event | What happened | Commissionable | Effect on the ledger | Gotcha |
|---|---|---|---|---|
| `subscription_started` | A paid subscription was activated without a trial period | Yes | Create a pending commission on `price_usd` | On Google Play this can arrive with every monetary field at zero when one subscription replaces another |
| `subscription_initial_purchase` | The same event under its other documented name | Yes | Same as above | Map both names or one of them will be dropped |
| `subscription_renewed` | A subscription renewed and the user was charged | Yes | Create a pending commission | Starts from the second billing. A repurchase after expiry is also a renewal |
| `trial_converted` | A trial ended and the user was billed for the first time | Yes | Create a pending commission | This is the trial payday, not `subscription_renewed` |
| `non_subscription_purchase` | A one-time or consumable purchase | Yes | Create a pending commission | Subscription-only fields are absent. Cap repeats if the deal is per subscriber |
| `subscription_refunded` | A subscription was refunded | Yes, negatively | Reverse that period's commission | Also fires on an immediate upgrade, with `cancellation_reason` of `upgraded` |
| `non_subscription_purchase_refunded` | A one-time purchase was refunded | Yes, negatively | Reverse that commission | Easy to leave unimplemented until a consumable heavy app arrives |
| `trial_started` | A trial subscription was activated | No | Record a trial start at $0 | `price_usd` is null unless Send Trial Price is on |
| `trial_renewal_cancelled` | Auto renewal was turned off during the trial | No | Nothing | Access continues to the end of the trial |
| `trial_renewal_reactivated` | Auto renewal was turned back on during the trial | No | Nothing | Expect a `trial_converted` at the end of the trial, and pay on that |
| `trial_expired` | A trial ended without converting | No | Close the trial in the funnel | No money existed, so there is nothing to reverse |
| `subscription_renewal_cancelled` | Auto renewal was turned off | No | Nothing | The most misread event. Past commissions stand |
| `subscription_renewal_reactivated` | Auto renewal was turned back on | No | Nothing | Carries the prior product id, not the new one |
| `subscription_expired` | The subscription fully ended after being cancelled | No | Close the recurrence | The end of future commission, never of past commission |
| `subscription_paused` | The user paused the subscription (Android only) | No | Suspend the expected next renewal | The subscriber has not churned |
| `subscription_deferred` | A subscription purchase was deferred | No | Push the expected renewal date out | Free time granted through the Google Play Developer API |
| `entered_grace_period` | A payment failed and the grace period started | No | Flag the renewal as at risk | A recovery arrives later as `subscription_renewed` |
| `billing_issue_detected` | A charge attempt failed | No | Nothing | Pays twice if you treat it and the recovery as separate charges |
| `access_level_updated` | A profile's access level changed | No | Nothing | Fires alongside almost every other event. High volume, zero money |
Names and descriptions checked against Adapty's event types and fields reference and its events page on 2026-09-14.
One Adapty behaviour has no RevenueCat equivalent and it can invalidate the table above. The integration screen lets you replace Adapty's default event ids with your own, and Adapty's setup page states the rule flatly: "The event name can be any string." An app that renamed subscription_renewed to renewal three years ago hands a correct mapping table zero matches. Read the Events names section first, and log every type you decline so a rename reads as a spike rather than as silence.
#An attribute written after the purchase never reaches the purchase event
user_attributes is a snapshot of the profile at the moment Adapty builds the event. Write the affiliate id during onboarding and it travels on the purchase. Write it in the purchase completion handler and subscription_started was assembled without it. Nothing backfills it.
Delivery order is the second version of the same problem. Adapty's field reference is explicit that event_datetime is business time, not processing time, and gives the instruction directly: "order events by your own receipt time, and deduplicate them using profile_event_id or the transaction IDs." Verified on 2026-09-14. A subscription_expired can carry an earlier timestamp than a subscription_renewal_cancelled delivered before it.
Identity resets are the third. Adapty's identifying users page warns that after a login the SDK switches to the new user: "If you passed any data to the anonymous user, such as custom attributes or attributions from third-party networks, you should resubmit that data for the identified user." Verified on 2026-09-14. An affiliate id written before login is gone after it.
Our position: a billing provider should carry identity and nothing else, with attribution state in the attribution system. It survives a provider migration, and it is why one attribution engine can serve mobile and web without two sets of rules. The counter argument is real. A join can miss.
#Trials pay nothing, and trial_converted is the paid event
A free trial arrives as trial_started with price_usd null, because nothing was charged. The paid conversion arrives later as trial_converted, which Adapty calls the first purchase, rather than subscription_renewed, which starts from the second billing. Pay on the conversion. A trial start is a funnel row worth $0.
The trap is a dashboard toggle, not a field. Adapty documents a Send Trial Price option that "will include the subscription price in the price_local and price_usd fields for the Trial Started event". Verified on 2026-09-14. Switch it on for analytics and a trial start carries a plausible 9.99, which an engine reading price alone pays out on. Our normalizer forces the amount instead:
amountUsdCents: type === "trial_start" ? 0 : amountUsdCents,A trial reporting a price is still recorded at $0, and a unit test pins both halves. The economics are in commission on trials and renewals: a per trial bounty pays the same at 4 percent conversion as at 35 percent.
Two reactivation cases catch people out, both from Adapty's event flows page, checked on 2026-09-14. A user who lets a subscription expire and later rebuys the same product generates subscription_renewed, because Adapty treats the gap as one transaction chain. An expired trial that later converts generates trial_converted. Code paying only on starts loses both.
#Refunds and upgrades both arrive as subscription_refunded
Adapty sends no event named refund. A refunded subscription arrives as subscription_refunded, and so does the old half of an immediate upgrade: Adapty's event flows page says the old subscription ends, a refund is paid, and the event carries cancellation_reason of upgraded. Read that field before reversing.
| `cancellation_reason` | What happened | Ledger effect |
|---|---|---|
| `refund` | The period was refunded | Reverse that period's commission |
| `upgraded` | An immediate product change refunded the old product | None. Commission the `subscription_started` that follows |
| `new_subscription_replace` | One subscription replaced another | None. Same swap, different wording |
| `voluntarily_cancelled` | The user turned off auto renewal | None. Past commissions stand |
| `billing_error` | A charge failed and the subscription lapsed | None. Nothing was charged to reverse |
| `price_increase` | The user declined a price increase | None |
| `product_was_not_available` | The store could not serve the product | None |
| `cancelled_by_developer` | You cancelled it | None |
| `adapty_revoked` | Adapty revoked the access level | None, and worth an alert |
| `unknown` | The store reported no reason | None, and worth an alert |
Values copied from the cancellation_reason row of Adapty's event types and fields reference on 2026-09-14. Only refund moves the balance down.
Five rules for the reversal path:
- Branch on
cancellation_reasonbefore touching the ledger, not after. - Match the reversal to a period using
transaction_id.original_transaction_idlinks the whole chain and matches every period at once. - Reverse at the rate snapshotted when the commission was created.
- Hold new commissions before they become payable, so a reversal inside the hold is a status change rather than a clawback. We default to 14 days, set per app.
- Reverse a matured commission as a negative line on the next statement. Invoicing a creator for money already sent is a support ticket.
One thing we have not settled: Adapty's reference does not say whether a refund of an earlier period produces an event at all. Until we capture one we assume it does not.
#Which Adapty price field should a commission be calculated on?
price_usd for a gross deal and net_revenue_usd for a net one. Nothing else. Adapty defines price_usd as the amount charged before the Apple or Google cut, already in USD, which removes the currency problem a RevenueCat ledger has to solve at payout.
| Field | What Adapty says it holds | Use it for commission? |
|---|---|---|
| `price_usd` | Amount charged before the Apple/Google cut, in USD | Yes, for a gross deal. Null on free trials |
| `price_local` | The same amount in local currency | No. Needs an FX rate you have to source yourself |
| `original_price_usd` | The standard, non-discounted price | No. Pays the sticker rather than the charge |
| `discount_amount_usd` | Standard price minus the amount charged | No, but store it to explain a small commission |
| `proceeds_usd` | Product price after the Apple/Google cut | Only if the creator deal is written on proceeds |
| `net_revenue_usd` | Income after the Apple/Google cut and taxes | Yes, for a net deal. Present on four event types only |
Field definitions verified on 2026-09-14. Note the caveat in the last row: Adapty documents the tax and revenue fields on subscription_renewed, subscription_initial_purchase, subscription_refunded and non_subscription_purchase only. A net deal that also pays on trial_converted has no net_revenue_usd to read.
The conversion is documented rather than implied. Adapty converts other currencies at the currencylayer.com rate, refreshed every 8 hours, and fixes that rate at the time of the transaction, so later rate moves never rewrite an old event. This is the one place Adapty is plainly easier than RevenueCat, where price and price_in_purchased_currency mean different things and you have to choose. The RevenueCat event mapping works through that choice. Our normalizer reads price_usd and computes amount_usd_cents × rate_bps / 10000 at a snapshotted rate: on a $9.99 plan at 20 percent, 999 × 2000 / 10000, or 199.8 cents.
#Set up Adapty webhook delivery in eight steps
Adapty's webhook integration is one dashboard screen: two endpoint URLs, two Authorization header values, an event list, and four optional payload switches. The Authorization header is optional in Adapty's own wording, which is the first thing to change. Configure sandbox first, production second.
- Call
Adapty.identify("YOUR_USER_ID")with the same string your attribution SDK identifies, before the paywall rather than after the purchase. - Open Integrations then Webhook in the Adapty Dashboard and turn the integration on.
- Fill the Sandbox endpoint URL and set the Authorization header value for sandbox endpoint to a long random secret. Adapty calls that header "not mandatory", and an endpoint that mints commissions should not accept anonymous POSTs.
- Answer the verification request, sent the moment you click Save, with a 2xx and a JSON body. An empty
{}satisfies it. - Select the events. Six matter for money; the other twelve cost only volume.
- Check the Events names section for renamed ids before writing mapping code.
- Return a status inside the 200 to 404 range once the payload is stored. Adapty retries only outside that range, "up to 9 retries spread over 24 hours", and treats a handler silent for 10 seconds as failed.
- Repeat steps 3 to 7 on the Production endpoint URL with a different secret, and gate payouts on
environment, which Adapty sets toSandboxorProduction.
Behaviour quoted from Adapty's set up webhook integration page, verified on 2026-09-14. One limit there shapes the design: "Adapty supports one webhook URL per environment (production and sandbox)." If Adapty already posts to your backend, an affiliate platform cannot be added as a second subscriber. You fan out from your own server.
#MyAppAffiliate ingests Adapty today, and writing this post fixed a bug in it
Adapty is a shipped source in our ingest, not a roadmap item: a static bearer check, a normalizer, and an endpoint at /webhooks/adapty/<appId>, all listed on our public webhook reference beside RevenueCat, Superwall, Stripe and Paddle.
Every source normalizes to the same event vocabulary, so commissions behave identically whoever bills your customers. We charge a flat monthly fee and no percentage of tracked revenue, which is the argument on pricing.
Fact-checking this post found a real bug in that normalizer, so it is worth saying what it was. We read profile_event_id from the top level of the payload. Adapty documents that field inside event_properties. On a delivery shaped the way the reference shows, the mapping returned nothing, the raw body was stored and marked processed, and no commission was created. The endpoint answered 200 throughout, which is the part that makes it expensive: an Adapty app would have booked nothing and seen no error.
It repeats an audit finding from July, when the same normalizer read price_usd from the top level and every Adapty purchase came out at zero cents. That fix moved the transaction fields to a nested read and left the dedupe key behind, and the test fixture kept building the flat shape, so eighteen green tests went on proving a payload Adapty never sends. The fix is a nested read for that key too, plus two tests that fail without it. Both shipped on 2026-09-14, before this post published.
A mapping keyed on a field nobody sends fails silently, and a test written from the same misreading passes. Assert against a captured payload, never one typed from memory.
#A six month Adapty ledger for one subscriber
One subscriber on a $9.99 monthly plan at a 20 percent creator rate produces eight Adapty webhook events across six months. Five change the balance: four credits and one reversal. With the default 14 day hold and a 12 month cap, the creator finishes with $6.00 against a ceiling of $24.00.
Each commission is 999 × 2000 / 10000, or 199.8 cents, rounded to $2.00. Four credits and one reversal leave $6.00.
| Date | Event | Deciding field | Commission | Pending | Matured | Balance |
|---|---|---|---|---|---|---|
| 1 Mar | `trial_started` | `price_usd` is null | None | $0.00 | $0.00 | $0.00 |
| 8 Mar | `trial_converted` | `price_usd` 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 | `subscription_renewed` | `price_usd` 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 | `subscription_renewed` | `price_usd` 9.99 | +$2.00 | $2.00 | $4.00 | $6.00 |
| 20 May | `subscription_refunded` | `cancellation_reason` is `refund` | -$2.00 | $0.00 | $4.00 | $4.00 |
| 8 Jun | `subscription_renewed` | `price_usd` 9.99 | +$2.00 | $2.00 | $4.00 | $6.00 |
| 1 Jul | `subscription_renewal_cancelled` | `will_renew` is false | None | $2.00 | $4.00 | $6.00 |
| 8 Jul | Hold expires (no webhook) | 30 days after 8 Jun | Status only | $0.00 | $6.00 | $6.00 |
| 8 Jul | `subscription_expired` | Access level revoked | None | $0.00 | $6.00 | $6.00 |
Two rows decide whether an implementation is right. On 20 May the refund lands 12 days after the commission it reverses, still inside the hold, so the reversal is a status change and nothing is clawed back from a creator already paid. On 1 July the cancellation touches nothing: the subscriber keeps access until 8 July, and reversing there loses you the creator.
The last row carries two numbers a dashboard should show separately. Earned stays at $6.00. Forward looking earnings drop from $18.00, the nine remaining periods of the cap, to zero. Show only the first and a creator asks why the total stopped growing. Show only the second and they think their balance fell.
#Send one sandbox event before you trust any of this
Do this next, in this order. Point Adapty's Sandbox endpoint URL at your handler, set the sandbox Authorization header, make one sandbox purchase, and check the delivery shows Success in the Last sent events section. Then send it twice and confirm your database holds one row for that profile_event_id.
Until a duplicate has bounced off a unique constraint in your own environment, you have a plan rather than idempotency. If your stack also bills through RevenueCat, wire that side from the full RevenueCat attribution setup and dedupe across both, before one purchase reported twice pays a creator twice.
Does Adapty have a built-in affiliate program?
No. Adapty's documentation covers paywalls, products, A/B tests and integrations, and none of it computes what a creator is owed. What Adapty gives you is a webhook carrying the transaction and the `customer_user_id` you set, which is what a ledger needs as input.
Which Adapty event should create an affiliate commission?
Four create one: `subscription_started` (also sent as `subscription_initial_purchase`), `trial_converted`, `subscription_renewed` and `non_subscription_purchase`. Two reverse one: `subscription_refunded` and `non_subscription_purchase_refunded`. Adapty's other twelve report access, intent or billing trouble, so none should touch a balance.
Do I need Adapty custom attributes to track affiliates?
No, and we would avoid them. A custom attribute reaches the webhook in the top level `user_attributes` object only when Send user attributes is on, and only if it was written before the event fired. Joining on `customer_user_id` has neither condition.
Why is `price_usd` null on my `trial_started` event?
Because nothing was charged. Adapty documents `price_usd` and `price_local` as null for free trials. If yours carries 9.99, the Send Trial Price option is on, which adds the subscription price to a trial event. Force a trial commission to zero rather than trusting it.
How do I stop an Adapty upgrade from reversing a commission?
Read `cancellation_reason` on `subscription_refunded`. An immediate product change ends the old subscription with a real refund, and Adapty sets the reason to `upgraded`, with `new_subscription_replace` for the related case. Reverse only on `refund`, and commission the `subscription_started` that follows.