Skip to main content

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​

DistributionSPM 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)
RuntimeSwift 5.9 tools, iOS 15 floor, zero third-party dependencies (URLSession)
Repooptolink-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:

  1. timezone is omitted (key absent, never null). Safari never sends client hints, so the click side always stores tz '' — a body that sends a timezone forfeits FINGERPRINT_EXACT and falls to scored. Omitting hits exact. (Android sends IANA tz because Chrome answers client hints.)
  2. No installReferrer — no App Store referrer API exists; the INSTALL_REFERRER enum 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).

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