StoreKit 2 affiliate tracking for iOS subscriptions
Native subscription attribution without fingerprinting.
Published · Last reviewed
Written by Attribloom Engineering · Reviewed by Attribloom Product · Last reviewed
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, 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
/v1/app-store/bindBody { signedClickId } (clipboard handoff) or { refCode } (typed code). Returns { appAccountToken, offerCode? }./v1/app-store/bind/deferredBody { surfaceId }. Deferred IP match, no user action. Returns the same shape./v1/app-store/assn/:surfaceIdBody { 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.
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 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. 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.
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.
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.
Ready to attribute App Store purchases to your affiliates. Register your app, then run a test with the iOS testing guide.
Continue your iOS attribution implementation
Further reading