Mobile · engineers · PMs
Store receipts are the source of truth
A client-side Purchase event on buy-button tap is a lead, not revenue. Restores and family sharing will humble you.
Validate then send
Take the receipt or Play purchase token on the server. Verify with Apple or Google. Then S2S the MMP event with the product id, the verified amount, and an ISO 4217 currency. The SDK auto-event is optional once S2S is solid, and harmful if both fire without dedup. If money did not move at Apple or Google, it is not an in-app purchase event.
Adjust, AppsFlyer, and Branch all have purchase-shaped events and receipt fields. Use them. A generic custom event named buy is how refunds never join. Put the store transaction id on the event so a later refund can point at the same row. A client event with value=1 is a placeholder that trains value optimization toward a dollar.
POST https://api3.appsflyer.com/inappevent/id123456789
{
"appsflyer_id": "1234567890123-1234567",
"eventName": "af_purchase",
"eventValue": "{\"af_revenue\":\"129.99\",\"af_currency\":\"USD\",\"af_content_id\":\"sku-99\"}",
"eventTime": "2026-08-21 18:04:00.123",
"eventCurrency": "USD",
"ip": "192.0.2.1"
}
Value and currency
Send the transaction currency from the store, not the headquarters currency, unless you converted and documented it. USD is the code. A dollar sign is not. 19,99 is not a JSON number. Introductory prices and trial-to-paid need a policy: which event is the conversion the ads manager optimizes. Trial start should not send the full annual price unless that is actually charged.
AppsFlyer eventValue may be an empty string when there is no value. That is legal. A purchase with no revenue in eventValue and no eventCurrency is how ROAS goes to zero. Empty is for events that have no value, not for IAP you were too busy to price. Branch event_data.revenue plus currency is the equivalent. Adjust uses the revenue parameters its S2S docs name. Do not copy Meta custom_data into Adjust query params and hope.
Restores and renewals
Restore purchases and subscription renewals are easy double-counts. A restore is not a new purchase. A renewal is a new charge with a new transaction id, or a renewal flag the MMP understands. If you S2S restore as purchase, ROAS is fiction.
Family sharing and refund-then-repurchase will humble a client-only listener. Server notifications exist because the store is the ledger. Wire those into the same S2S client you use for purchases, not a user tap. Retry with the same identity.
Refunds
App Store server notifications exist because users refund. If ads only ever see purchase, ROAS is fiction within a week. Send a refund or cancel event with the original transaction id and a partial value when the refund is partial. A second purchase of zero is not a refund.
A 200 from the MMP still does not prove the revenue dashboard moved. Check the MMP's rejected-event logs. pixellint validate json will tell you if the AppsFlyer body is missing eventName. It will not tell you that you never sent the refund. See refunds and cancellations on the web CAPI side for the same join rule.
POST https://api2.branch.io/v2/event/standard
{
"branch_key": "key_live_example",
"name": "PURCHASE",
"user_data": {
"os": "iOS",
"idfa": "9876F1SS-2983-3855-27RR-2R626772VFNB",
"ip": "192.0.2.1",
"developer_identity": "user-10492"
},
"event_data": {
"currency": "USD",
"revenue": 129.99,
"transaction_id": "1000000123456789"
}
}
Dedup SDK plus S2S
If the SDK auto-sends purchase and the backend S2S sends purchase, you doubled revenue unless the MMP deduped on a transaction id you actually sent. Turn off the auto-event once S2S is solid, or send the same transaction id both times and confirm the MMP treats it as one.
created_at on Adjust is the time of the purchase, ISO 8601, not drain time. eventTime on AppsFlyer is UTC yyyy-mm-dd hh:mm:ss.sss, not Date.now() milliseconds. A retry that refreshes the clock attributes to the drain campaign. Store the store's purchase time and replay it.
https://s2s.adjust.com/event?s2s=1&event_token=f0ob4r&app_token=4w565xzmb54d&idfa=D2CADB5F-410F-4963-AC0C-2A78534BDF1E&created_at=2026-08-21T18:04:00Z&revenue=129.99¤cy=USD
Check the artifact
Paste the pixel URL or JSON body into the
playground. Same engine as
pixellint validate. Nothing leaves the tab.