# Add commission-only affiliate attribution to an iOS subscription app

> Paste this into Claude Code, Cursor, or Codex to add privacy-preserving affiliate attribution with StoreKit 2 appAccountToken and App Store Server Notifications v2. No fingerprinting.

Canonical: https://attribloom.com/agents/ios-affiliate-attribution

Attribloom attributes affiliate-driven App Store subscriptions using StoreKit 2 appAccountToken and App Store Server Notifications v2. It does not fingerprint devices. This page is safe to give an AI coding agent before you create an account: it contains architecture and compile-ready integration shapes, not tenant credentials or dashboard actions.

> **For AI coding agents:** This page is a safe, tenant-free integration brief. Implement the app and server changes below. The machine-readable twin is at [/agents/ios-affiliate-attribution.md](/agents/ios-affiliate-attribution.md). Stop at the human boundary: do not create accounts, campaigns, or payouts, and never place a secret in the iOS client.

## What this solves

iOS App Store conversions are invisible to web-checkout affiliate tools, and device fingerprinting for attribution is not permitted post iOS 14.5. Attribloom binds a referred account to an Attribloom-issued `appAccountToken`, then verifies App Store Server Notifications v2 server-side to credit purchases, renewals, and refund clawbacks. No fingerprinting, no IDFA, no cookie surviving the App Store handoff.

**What you get**

- [ ] A first-launch bind that mints and persists an Attribloom `appAccountToken` per referred account.
- [ ] StoreKit 2 purchases that carry that token, so Apple echoes it back signed on every renewal.
- [ ] A server forwarder that relays Apple's signed notifications to Attribloom without altering the bytes.
- [ ] Commission credited or reversed by Attribloom from Apple's verified JWS, not from client claims.

## Agent quickstart

Copy the prompt below into Claude Code, Cursor, or Codex. It points the agent at the Markdown twin of this page as its source of truth and lists the eight integration requirements.

**Paste into your AI coding agent**

```text
Implement Attribloom affiliate attribution in this iOS subscription app.

Use https://attribloom.com/agents/ios-affiliate-attribution.md as the source of truth.

Requirements:
1. Do not use device fingerprinting, IDFA, clipboard matching, or a web checkout pixel.
2. On first launch after a referral, POST signedClickId or refCode to /v1/app-store/bind and persist the returned UUID appAccountToken.
3. Pass that UUID as PurchaseOption.appAccountToken(UUID()) on every StoreKit 2 subscription purchase for the attributed account.
4. Add a server endpoint that forwards Apple's raw signedPayload from App Store Server Notifications v2 to Attribloom's surface-specific ASSN endpoint, signing the exact JSON request bytes with the forwarding secret.
5. Preserve raw bytes and make retries idempotent; do not parse/re-encode Apple's signed JWS before forwarding.
6. Keep all Attribloom secrets server-side. Do not put a forwarding secret in the iOS app.
7. Add tests for token persistence, token reuse across purchases, referral-input errors, and the server forwarder's signature/body invariants.
8. Stop before dashboard/account/campaign/payout operations; list the human setup values I must obtain from Attribloom.
```

## Data-flow diagram

A conversion travels one path. Every hop is server-verifiable; nothing depends on a fingerprint or a surviving cookie.

**Attribution flow**

```text
affiliate link / referral code
  -> first app launch
  -> POST /v1/app-store/bind            (Attribloom mints the appAccountToken)
  -> persist appAccountToken UUID       (per attributed app account)
  -> StoreKit 2 PurchaseOption.appAccountToken(uuid)
  -> Apple App Store Server Notifications v2
  -> your server receives Apple's raw signedPayload
  -> signed forward to POST /v1/app-store/assn/{surfaceId}
  -> Attribloom verifies Apple's JWS and credits or reverses commission
```

## Swift: bind on first launch

Fetch the Attribloom-issued `appAccountToken` once and persist it. Prefer `signedClickId` from the clipboard handoff; fall back to the deferred IP match with your `surfaceId`.

```swift
import Foundation

struct AttribloomBindResponse: Decodable {
    let appAccountToken: UUID
    let offerCode: String?
}

// Call once, on first launch after a referral. Attribloom mints the UUID and ties it
// to the referring affiliate. Never generate this UUID yourself: a token you invent
// attributes to nobody. Use signedClickId (clipboard handoff) when present, otherwise
// fall back to the deferred IP match with your surfaceId.
func attribloomBindOnFirstLaunch(signedClickId: String?, surfaceId: String) async throws -> UUID {
    let base = URL(string: "https://api.attribloom.com")!
    let (path, payload): (String, [String: String]) = signedClickId.map {
        ("/v1/app-store/bind", ["signedClickId": $0])
    } ?? ("/v1/app-store/bind/deferred", ["surfaceId": surfaceId])

    var request = URLRequest(url: base.appendingPathComponent(String(path.dropFirst())))
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "content-type")
    request.httpBody = try JSONSerialization.data(withJSONObject: payload)

    let (data, response) = try await URLSession.shared.data(for: request)
    guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
        throw URLError(.badServerResponse)
    }
    let bind = try JSONDecoder().decode(AttribloomBindResponse.self, from: data)

    // Persist for reuse across every purchase by this attributed account. Swap
    // UserDefaults for the Keychain if your data policy requires it.
    UserDefaults.standard.set(
        bind.appAccountToken.uuidString, forKey: "attribloom.appAccountToken"
    )
    return bind.appAccountToken
}
```

## Swift: use appAccountToken in Purchase

Set the persisted UUID on `product.purchase(options:)` for every subscription purchase by the attributed account. Never generate your own UUID.

```swift
import StoreKit

// Reuse the persisted UUID for every StoreKit 2 subscription purchase by this account.
// Apple echoes the token back, signed, in the transaction and every renewal, which is
// how Attribloom credits or claws back the affiliate.
func attribloomPurchase(_ product: Product) async throws {
    let stored = UserDefaults.standard.string(forKey: "attribloom.appAccountToken")
    guard let stored, let token = UUID(uuidString: stored) else {
        // No attribution on file: purchase normally.
        _ = try await product.purchase()
        return
    }
    let result = try await product.purchase(options: [.appAccountToken(token)])
    if case .success(.verified(let transaction)) = result {
        await transaction.finish()
    }
}
```

## Server: forward ASSN v2

Relay each notification's `signedPayload` verbatim, HMAC-signed. The signature covers `${timestamp}.` plus the exact raw request body. Keep the forwarding secret server-side.

```javascript
import crypto from "node:crypto";

// Forward EVERY App Store Server Notification v2 your server receives to Attribloom,
// off your critical path (a delivery failure must never affect your own unlock logic).
const SURFACE_ID = process.env.ATTRIBLOOM_SURFACE_ID;        // last path segment of your Forwarding URL
const SECRET = process.env.ATTRIBLOOM_FORWARDING_SECRET;     // server-side only, never in the app

export async function forwardToAttribloom(signedPayload) {
  const ts = Math.floor(Date.now() / 1000).toString();       // unix SECONDS, not ms
  const body = JSON.stringify({ signedPayload });            // Apple's JWS, verbatim, unparsed
  const sig = "sha256=" + crypto
    .createHmac("sha256", SECRET)
    .update(`${ts}.`)   // timestamp + "."
    .update(body)        // then the exact bytes you POST
    .digest("hex");

  // Retries reuse these identical bytes; Attribloom deduplicates by notification UUID.
  return fetch(`https://api.attribloom.com/v1/app-store/assn/${SURFACE_ID}`, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "x-ea-timestamp": ts,   // unix seconds string
      "x-ea-signature": sig,  // "sha256=" + hex
    },
    body,
  });
}
```

## Human setup required

> **Human boundary:** An agent may edit the app and your server. A human must create or select the Attribloom surface, obtain the surface ID and forwarding secret, configure the App Store Connect notification URL, and approve production deployment. Never expose the forwarding secret or any dashboard session to the iOS client.

**Values a human must obtain from Attribloom**

- [ ] Create or select the Attribloom surface for this app.
- [ ] Obtain the surface ID (last path segment of the Forwarding URL) and the forwarding secret.
- [ ] Set the App Store Server Notifications v2 URL in App Store Connect to your server.
- [ ] Set `ATTRIBLOOM_SURFACE_ID` and `ATTRIBLOOM_FORWARDING_SECRET` in the server environment.
- [ ] Approve the production build and deployment.

## Test matrix

| Test | Assert |
| --- | --- |
| Token persistence | First-launch bind returns `200` with a non-empty `appAccountToken`; the UUID is written to secure storage. |
| Token reuse across purchases | Every purchase for the attributed account sets the same persisted UUID as `PurchaseOption.appAccountToken`. |
| Referral-input errors | A missing or malformed `signedClickId` / `refCode` surfaces a handled error and the app still purchases without attribution. |
| Forwarder signature | The forward signs `${timestamp}.` plus the exact raw body with the forwarding secret; a tampered body or stale timestamp is rejected `401`. |
| Forwarder body invariants | Apple's `signedPayload` is forwarded byte-for-byte, never re-encoded; retries reuse identical bytes and stay idempotent. |

## Failure modes

| Symptom | Cause and fix |
| --- | --- |
| assn returns `401` | Signature or timestamp skew. Sign `${ts}.` plus the raw body with the forwarding secret; send unix seconds within 300s. |
| assn returns `400` | The `signedPayload` was re-encoded or pretty-printed. Forward Apple's JWS string verbatim. |
| assn returns `200 ignored_non_production` | A Sandbox purchase hit a Production surface. That is the environment gate working, not a bug. Use a Sandbox-registered surface for sandbox tests. |
| 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. |
| Network loss forwarding a notification | Forward off the critical path and retry with the same raw bytes; Attribloom deduplicates by notification UUID. |

## API contract

- `POST /v1/app-store/bind` Body `{ signedClickId }` (clipboard handoff) or `{ refCode }` (typed code). Returns `{ appAccountToken, offerCode? }`.
- `POST /v1/app-store/bind/deferred` Body `{ surfaceId }`. Deferred IP match, no user action. Returns the same shape.
- `POST /v1/app-store/assn/:surfaceId` Body `{ signedPayload }` (Apple's JWS, verbatim). HMAC headers `x-ea-timestamp` and `x-ea-signature`. Returns `{ status }`.

The full runtime contract is the [OpenAPI 3.1 spec](https://api.attribloom.com/openapi.json). The bind endpoints do not require HMAC; the ASSN forward does.

## What not to do

- [ ] Do not use device fingerprinting, IDFA, clipboard matching for identity, or a web checkout pixel.
- [ ] Do not generate your own `appAccountToken` UUID. Always use the one `/v1/app-store/bind` returns.
- [ ] Do not parse, reformat, or re-encode Apple's `signedPayload` before forwarding.
- [ ] Do not put the forwarding secret or any dashboard session in the iOS client.
- [ ] Do not perform dashboard, account, campaign, or payout operations from the agent.

## Related Markdown sources

- [ ] [This page as Markdown](/agents/ios-affiliate-attribution.md) · the machine-readable source of truth.
- [ ] [iOS App Store attribution guide](/guides/ios) · [Markdown](/guides/ios.md).
- [ ] [Test iOS attribution](/guides/ios-testing) · [Markdown](/guides/ios-testing.md).
- [ ] [iOS attribution error codes](/guides/ios-errors) · [Markdown](/guides/ios-errors.md).
- [ ] [Agent onboarding for merchants](/agent-onboarding) · [Markdown](/agent-onboarding.md).
- [ ] [OpenAPI 3.1 runtime contract](https://api.attribloom.com/openapi.json).
