# Test iOS attribution

> Prove the pipeline in sandbox, then confirm one real production sale, safely.

## What you are testing

A conversion travels: affiliate link, then a click, then install, then first-launch `bind` (Attribloom mints the `appAccountToken`), then the StoreKit purchase carries that token, then Apple sends a signed notification, then your server forwards it, then Attribloom verifies it and accrues the commission. This guide proves that whole chain.

> **For AI agents:** If you are an agent wiring this up: read [the iOS integration guide](/guides/ios) first for the endpoints and the code, then run Path A (sandbox) below. Every step lists the exact request and the exact response to assert on. Do not mark a step done until the observed response matches.

> **The environment gate is real:** An Attribloom app is registered as either **Sandbox** or **Production** (`appleEnvironment`). A Production app **rejects** Sandbox notifications with `200 ignored_non_production` and writes nothing. A Sandbox app **accepts** them and accrues as a **test** conversion (flagged, excluded from real payouts). Pick the environment that matches how you are buying.

## Before you start

**Prerequisites**

- [ ] Your app is registered in Attribloom and you have its **surface id** (the last path segment of the Forwarding URL).
- [ ] Your server forwards App Store Server Notifications v2 to `/v1/app-store/assn/:surfaceId`, signed, with `ATTRIBLOOM_FORWARDING_SECRET` set to the same value as Attribloom.
- [ ] Your app calls `/v1/app-store/bind` on first launch and sets the returned token on `product.purchase(options:)`.
- [ ] You have at least one affiliate link (`https://attribloom.com/r/<code>`) or a referral code to enter.

## Path A. Sandbox or TestFlight (proves the full pipeline, safely)

Register the app in Attribloom with environment **Sandbox**. StoreKit sandbox subscriptions renew on an accelerated clock (minutes, not months), so you can watch a renewal and a refund-driven clawback in one sitting. Nothing here touches real money.

1. **Open an affiliate link.** Open `https://attribloom.com/r/<code>` so a click is recorded. Confirm the click lands (it returns the App Store interstitial).
2. **Install and launch from TestFlight.** On first launch your app should call `/v1/app-store/bind`. Assert the response is `200` with a non-empty `appAccountToken`, and that you persisted it to the Keychain.
3. **Make a StoreKit sandbox purchase.** Buy the subscription with a Sandbox Apple Account (Settings, Developer, Sandbox Apple Account). Confirm `transaction.appAccountToken` equals the token from bind.
4. **Forward the notification.** Apple sends a `SUBSCRIBED` notification to your server. Your forwarder relays the `signedPayload` to `/v1/app-store/assn/:surfaceId`. Assert the response is `200`.
5. **Check the accrued conversion.** Because the app is Sandbox, the status is `ok` (or `trial` for an introductory free trial) and a **test** conversion appears on the app's Attribution view. Real payouts exclude it.
6. **Force a refund and watch the clawback.** Refund the sandbox transaction (or let it expire). Apple sends `REFUND` / `EXPIRED`; the forwarded notification reverses the commission on the ledger. Assert the balance returns to zero.

> **Testing against a Production app instead?:** If your Attribloom app is registered **Production** and you send a Sandbox purchase, the assn returns `200 ignored_non_production` and nothing accrues. That is the environment gate doing its job, not a bug. Register a Sandbox app to see accruals from sandbox purchases.

## Path B. Production (confirms one real sale)

A real Production accrual only exists after a real App Store purchase on a live build. TestFlight and every sandbox mechanism emit `environment: Sandbox`, so there is no way to produce a Production notification ahead of shipping. Once the instrumented build is live, buy your own subscription through a referred link.

> **The hold makes a self-test safe:** A commission matures for 30 days before it is payable (see the [commission hold period](/wiki/commission-hold-period)). A test purchase cannot pay out in that window, so refund it after you confirm attribution and the [clawback](/wiki/commission-clawback) reverses the accrual. No money leaves.

1. **Register the app as Production.** Set environment to Production and complete verification (Attribloom asks Apple to send a signed test notification with your App Store Server API key and flips the app to Active when it arrives).
2. **Buy through a referred link.** Open your affiliate link, install the live build, and purchase the subscription for real. Confirm the token bound on first launch.
3. **Confirm the real accrual.** The forwarded `SUBSCRIBED` notification returns status `ok` and a real conversion appears, attributed to the affiliate, with commission computed on net proceeds (excluding trial, refund, and tax).
4. **Refund to close the loop.** Refund the purchase from App Store Connect. The `REFUND` notification claws the commission back before the hold elapses, so nothing is ever paid out.

## Responses to assert on

| status | HTTP | Meaning |
| --- | --- | --- |
| `ok` | 200 | Verified, matched an affiliate, commission accrued (real for a Production app, test for a Sandbox app). |
| `trial` | 200 | Verified and recognized, but a free trial, so nothing accrues yet. Accrues when it converts to paid. |
| `duplicate` | 200 | Already processed. Delivery is idempotent, so retries and Apple re-sends are safe. |
| `unattributed` | 200 | Verified, but no affiliate token on the transaction. Nothing to credit. |
| `ignored` | 200 | Not a relevant notification type (for example a `TEST` notification, which carries no transaction). |
| `ignored_non_production` | 200 | A Sandbox notification reached a **Production**-registered app. Dropped, zero ledger. Expected when testing sandbox purchases against a prod app. |

## Troubleshooting

| Symptom | Cause and fix |
| --- | --- |
| assn returns `401` | HMAC mismatch or clock skew over 300s. Confirm `ATTRIBLOOM_FORWARDING_SECRET` matches byte for byte, timestamp is unix **seconds**, and the signature is `sha256=` + hex over `"${ts}." + rawBody`. |
| assn returns `400` | The `signedPayload` was re-encoded or pretty-printed. Forward Apple's JWS string verbatim. |
| status `ignored_non_production` | You sent a Sandbox purchase to a Production app. Register a Sandbox app, or buy through the App Store for real. |
| status `unattributed` | No `appAccountToken` on the transaction. The bind did not run, or the token was not set on `product.purchase(options:)`. Check first-launch bind and the purchase call site. |
| No notification arrives | Your app's App Store Server Notifications v2 URL is not set, or your forwarder is not relaying. Use App Store Connect, Request a Test Notification, and confirm your forwarder gets a `200 ignored` (not `401`/`400`). |
