iOS SDK contract
Scope: the CLIENT-tier surface the Swift SDK (OptoLink module, SPM) codes
against — Universal Links ingestion, deferred matching, device session, identity,
events. Android's twin surface is Android; the Flutter wrapper is
Flutter.
Source-checked against optolink-backend @ 29f8589519ac85445ed0598c1b9cfc125a9dd6f2, 2026-10-07 (
src/resolution/match.controller.ts,match-request.dto.ts,resolution.controller.ts,src/identity/sdk.controller.ts,sdk.dto.ts,src/app-event/sdk-events.controller.ts,src/api-key/api-key.service.ts,src/rate-limiting/rate-limit.decorator.ts,src/resolution/well-known.controller.ts) and optolink-ios @ 9482175 (Sources/OptoLink/MatchClient.swift,ShortCodeResolver.swift,SDKVersion.swift).
Identity
| Distribution | SPM from the public releases-only binary mirror github.com/Optomatica/optolink-ios-sdk, .binaryTarget xcframework, tag v0.1.0 — the git repo IS the registry (source repo optolink-ios/ stays private) |
| Runtime | Swift 5.9 tools, iOS 15 floor, zero third-party dependencies (URLSession) |
| Repo | optolink-ios/ (source) + optolink-ios/optolink-ios-sdk/ (mirror checkout) |
| Compat line | "Compatible with OptoLink backend v2.1.0+" (README/CHANGELOG) |
Auth
Authorization: Bearer opl_sdk_… (CLIENT tier) on /sdk/* — SERVER key gets
403 API key tier 'SERVER' is not permitted on this endpoint (requires 'CLIENT').
/match and GET …/data are unauthenticated: X-API-Key is audit decoration
only (never validated). Audit header: X-OptoLink-SDK: ios/<version>
(fixture-verbatim value; trace only).
Endpoints
Identical shape to Android (member-for-member parity): POST /match → 201
hit or miss; GET /{orgKey}/{shortCode}/data?deviceId=… → 200
{linkId, path, params} (404 Link not found; 410 inactive/expired link or
suspended org); POST /sdk/session → 200 {ok: true, deviceId, firstSeenAt, lastSeenAt}; POST /sdk/identity → 200 {endUserId, rebound};
POST /sdk/identity/clear → 200 {cleared: true}; POST /sdk/events → 200
{ok: true}. Full tables + body validation limits: Android — the
two platforms share every wire shape.
The iOS /match deltas
Body contract is Android's with two deliberate platform deltas, each mirroring its browser:
timezoneis omitted (key absent, never null). Safari never sends client hints, so the click side always stores tz''— a body that sends a timezone forfeitsFINGERPRINT_EXACTand falls to scored. Omitting hits exact. (Android sends IANA tz because Chrome answers client hints.)- No
installReferrer— no App Store referrer API exists; theINSTALL_REFERRERenum value stays wire-verbatim but is never emitted. Attribution rides clipboard + fingerprint + IP only.
Response shape is identical: hit keys matched, matchMethod, matchConfidence, matchScore?, linkId, path, params (nothing else), miss exactly
{"matched":false,"matchMethod":"NONE"}, matchConfidence lowercase,
matchScore only on FINGERPRINT_SCORED. Clipboard payload on the redirect
page is the link URL with ?opl_click=<token> (iOS-conditional render); the
reader accepts both that and a bare opl_ token (older cached pages). Ladder
mechanics + first-click limitation:
Device identity & match attribution.
Error envelope + rate limits
Same envelope and messages as Android: details[] on 400 only,
429 with no header — regex Retry after (\d+) second(s) from the message.
Spec delta: the hand-off spec claimed requestId was "present on all error
bodies on the current build" — the filter only echoes a client-sent
x-request-id; treat it as optional (the SDK already does). Same rate-limit
table (/match 30/min per IP · /data 500/s per IP · /sdk/* session/identity
60/min shared per key · events 600/min per key) and CLIENT-key 24 h rotation
grace. Retry matrix matches Android's (reads retry 5xx, events never; session
fire-and-forget).
Universal Links prerequisites (AASA)
/.well-known/apple-app-site-association is served per Host from the org's IOS
AppConfig (bundleId, teamId): custom VERIFIED/GRACE host → single-org
applinks.details entry (appIDs: ["TEAMID.bundleId"], components scoped
/{orgKey}/*) plus webcredentials; platform default host → aggregated across
ACTIVE orgs. An org without teamId is skipped rather than publishing an
invalid appID; all-orgs-empty → 404 No iOS app configuration. Headers
Content-Type: application/json, Cache-Control: public, max-age=3600.
Integrator side: associated-domains entitlement applinks:<link-domain>.
Delivery/DNS mechanics: Custom domains.
Privacy surface (what leaves the device)
PrivacyInfo.xcprivacy ships with the SDK: NSPrivacyTracking: false, no
tracking domains, UserDefaults reason CA92.1, and four collected-data types
(device ID, coarse location (match signals), other user content (clipboard
payload), product interaction (events)). No IDFA, no AdServices, no ATT prompt —
first-party attribution by design.
Source-checked against optolink-backend @ 29f8589, 2026-10-07. Status-code and body-shape claims are code-read only — no live curl pass ran for this harvest (B-018); the gated live e2e + device-pass runbooks in
optolink-ios/docs/are the runtime probes.