# StoreKit 2 affiliate tracking for iOS subscriptions

> Native subscription attribution without fingerprinting.

Attribloom is commission-only affiliate software with native StoreKit 2 attribution for iOS subscriptions. It binds a referred account to an Attribloom-issued appAccountToken, then verifies App Store Server Notifications v2 to credit purchases, renewals, and refund clawbacks server-side. It does not use device fingerprinting or depend on a browser cookie surviving the App Store handoff.

## How it works

Attribloom uses StoreKit 2 `appAccountToken`, confirmed server-side by App Store Server Notifications v2, to connect a referred account to a purchase without device fingerprinting.

An affiliate link sends the prospect to your App Store page. On first launch your app asks Attribloom which affiliate referred them and receives an `appAccountToken`. Your app sets that token on the StoreKit 2 purchase. Apple echoes it back, signed, in the transaction and in every renewal notification, so Attribloom credits or claws back the affiliate on the subscription and all renewals.

> **For AI agents:** This is the integration reference. To actually run a sandbox or production test, follow [the iOS testing guide](/guides/ios-testing), which has the exact steps, expected responses, and verification checks.

## What it does not do

This integration attributes in-app purchases and subscriptions to affiliates. It is not install-ad measurement, does not fingerprint a device, and does not replace your app's entitlement or transaction-finish logic.

## The endpoints

- `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). Your server forwards each notification here.

For machine-readable request, response, auth, and retry details, use the [OpenAPI 3.1 contract](https://api.attribloom.com/openapi.json).

> **Attribloom issues the token:** Always fetch the `appAccountToken` from `/v1/app-store/bind`. Your app must never generate its own UUID. Attribloom ties the token to the affiliate at mint time; a token you invent attributes to nobody.

## Primary Apple sources

Apple documents that [appAccountToken](https://developer.apple.com/documentation/appstoreservernotifications/appaccounttoken) is a UUID associated with a customer account and is returned in transaction information. Apple also documents how to [enable App Store Server Notifications v2](https://developer.apple.com/documentation/storekit/enabling-app-store-server-notifications). Sources checked 2026-07-14.

## 1. Bind the affiliate and set the token

**StoreKit 2 purchase with the Attribloom token**

Set the Attribloom-issued token on the purchase. Apple returns it in the signed transaction and in App Store Server Notifications v2. No receipt-validation SDK, no fingerprinting.

```swift
import Foundation
import StoreKit

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

enum AttribloomAPIError: Error {
    case invalidResponse
    case rejected(status: Int, body: String)
}

struct AttribloomAPI {
    let baseURL = URL(string: "https://api.attribloom.com")!

    func bind(signedClickId: String? = nil, refCode: String? = nil) async throws
        -> AttribloomBindResponse {
        struct Body: Encodable { let signedClickId: String?; let refCode: String? }
        return try await post("/v1/app-store/bind", Body(
            signedClickId: signedClickId, refCode: refCode
        ))
    }

    func bindDeferred(surfaceId: String) async throws -> AttribloomBindResponse {
        struct Body: Encodable { let surfaceId: String }
        return try await post("/v1/app-store/bind/deferred", Body(surfaceId: surfaceId))
    }

    private func post<Body: Encodable, Output: Decodable>(
        _ path: String, _ body: Body
    ) async throws -> Output {
        let endpoint = baseURL.appendingPathComponent(String(path.dropFirst()))
        var request = URLRequest(url: endpoint)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "content-type")
        request.httpBody = try JSONEncoder().encode(body)
        let (data, response) = try await URLSession.shared.data(for: request)
        guard let http = response as? HTTPURLResponse else {
            throw AttribloomAPIError.invalidResponse
        }
        guard (200..<300).contains(http.statusCode) else {
            throw AttribloomAPIError.rejected(
                status: http.statusCode,
                body: String(decoding: data, as: UTF8.self)
            )
        }
        return try JSONDecoder().decode(Output.self, from: data)
    }
}

func purchaseWithAttribution(
    product: Product,
    clipboardSignedClickId: String?,
    attribloomSurfaceId: String
) async throws {
    let api = AttribloomAPI()
    let binding: AttribloomBindResponse
    if let signedClickId = clipboardSignedClickId {
        binding = try await api.bind(signedClickId: signedClickId)
    } else {
        binding = try await api.bindDeferred(surfaceId: attribloomSurfaceId)
    }

    // Persist and reuse this UUID for the app install. Replace UserDefaults with
    // your existing secure app storage if your data policy requires it.
    UserDefaults.standard.set(
        binding.appAccountToken.uuidString,
        forKey: "attribloom.appAccountToken"
    )

    let result = try await product.purchase(options: [
        .appAccountToken(binding.appAccountToken),
    ])
    if case .success(.verified(let transaction)) = result {
        await transaction.finish()
    }
}
```

The `/bind` response also carries an optional `offerCode` (a subscription offer configured per campaign in Attribloom). Show it as the referred-user discount. Keep it Apple 3.2.2 safe: the reward attaches to subscribing, never to installing.

## 2. Forward Apple's notifications

Point your app's App Store Server Notifications v2 URL at your own server (or forward from it), then relay each notification's `signedPayload` to Attribloom, HMAC-signed. Attribloom independently re-verifies Apple's JWS chain to the Apple Root CA G3; the forwarding HMAC only proves the forward came from you.

**Forward each notification, HMAC-signed**

Timestamp is unix seconds. Signature is `sha256=` + hex of HMAC-SHA256 over `"${timestamp}." + rawBody`. The skew window is 300 seconds, so forward promptly.

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

// Forward EVERY App Store Server Notification v2 your server receives to Attribloom,
// off the critical path (a delivery failure must never affect your own unlock logic).
const SURFACE_ID = "<your-surface-id>";          // last path segment of your Forwarding URL
const SECRET     = process.env.ATTRIBLOOM_FORWARDING_SECRET; // same value as Attribloom

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
  const sig  = "sha256=" + crypto
    .createHmac("sha256", SECRET)
    .update(`${ts}.`)      // timestamp + "."
    .update(body)           // then the exact bytes you POST
    .digest("hex");

  await 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,
  });
}
```

## Responses

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

The `status` field on a 200 response tells you what happened.

| HTTP | Forwarder action |
| --- | --- |
| `200` | Accepted (see the status field). Do not retry. |
| `401` | Bad HMAC: secret mismatch or timestamp skew over 300s. Fix the secret, retry with a fresh timestamp. |
| `400` | Unverifiable Apple JWS (bad payload). Log it, do not infinite-retry. |
| `5xx` | Transient Attribloom error. Retry with backoff (Attribloom is idempotent; Apple also re-delivers). |

> **Verified end to end:** The server side (`/bind` and `/assn`) is live on `https://api.attribloom.com`, re-verifies every Apple JWS, posts on an integer-minor-unit double-entry ledger, and deduplicates by notification UUID.
