Skip to main content

Native iOS SDK — Backend Contracts & Flutter SDK Study Notes

Research for building the native iOS SDK (optolink-ios/, placeholder today). Sources: optolink-backend/src/ (controllers/DTOs read verbatim), optolink-flutter/ (full lib/ + ios/), optolink-node/src/ (conventions skim). Frozen snapshot — re-check controllers before coding.


1. SDK-Facing Backend Endpoints​

Base URL = the org's resolution domain (e.g. https://links.myapp.com); every endpoint below lives on that host, not on a separate API origin. The catch-all @Controller(':orgKey') (ResolutionModule) is registered last in app.module.ts; literal prefixes (match, sdk, links, .well-known, webhooks/*, health, dev) are safe from shadowing.

#RouteMethodAuthPurpose
1/:orgKey/:shortCodeGETnone (public)Redirect HTML page (browser click entry)
2/:orgKey/:shortCode/dataGETnone (public, optional ?deviceId=)JSON link data for SDK direct opens (Universal Link)
3/matchPOSTnone required; optional X-API-Key headerDeferred deep link matching after install
4/sdk/sessionPOSTAuthorization: Bearer opl_sdk_… (CLIENT tier)DeviceProfile upsert (init hook)
5/sdk/identityPOSTBearer opl_sdk_…Bind device → end user (setIdentity)
6/sdk/identity/clearPOSTBearer opl_sdk_…Unbind device (clearIdentity)
7/sdk/eventsPOSTBearer opl_sdk_…Custom app event (trackEvent)
8/links, /links/:id, /links/:id/qr.svg, /links/:id/qr.pngGET/POST/PATCH/DELETEBearer opl_api_… (SERVER tier)Link CRUD + QR — server SDK only, not for iOS SDK
9/.well-known/apple-app-site-associationGETnoneAASA — Universal Links enablement (served per Host)
10/dev/deferred/simulatePOSTnone (NODE_ENV !== 'production' only)Deferred-flow simulator for dev

1.1 GET /:orgKey/:shortCode/data (direct open JSON)​

  • orgKey: exactly 4 alphanumeric chars (ParseOrgKeyPipe: /^[A-Za-z0-9]\{4}$/), else 400.
  • Query: deviceId (optional) — fire-and-forget DeviceProfile upsert.
  • Response 200: \{ "linkId": string, "path": string, "params": object } (params = link→template merge, per-link wins).
  • Side effect: full click-track (fire-and-forget) — the SDK must NOT additionally report the click.
  • Rate limit: 500/min per IP (rl:resolve).

1.2 POST /match (deferred matching)​

  • Headers: Content-Type: application/json. Auth NOT enforced by the guard; SDK MAY send X-API-Key: <key> so that no-match drop-offs are still attributed to the org (audit records sdk:<first8>). Flutter sends X-API-Key here (and X-OptoLink-SDK version header — backend never reads it, but useful for ops).
  • Rate limit: 30/min per IP (anti brute-force token enumeration). Audited via AuditInterceptor.
  • Request body (MatchRequestDto, all fields optional):
    FieldTypeConstraintsNotes
    clipboardTokenstring≤100opl_… token written by the redirect page (P1)
    installReferrerstring≤100Parsed Play referrer click token (Android-only; iOS omits)
    deviceIdstring8–100SDK-generated stable UUID; upserts DeviceProfile on every attempt
    platformstring—"iOS" (must match ua-parser's platform label)
    osVersionstring—e.g. "17.4.1"
    deviceModelstring—e.g. "iPhone16,1"
    languagestring—e.g. "en-US" (hyphen format)
    timezonestring—IANA, e.g. "America/New_York"
    Unknown fields are rejected (whitelist validation) — send only these.
  • Response 200, matched: \{ "matched": true, "matchMethod": "CLIPBOARD"|"INSTALL_REFERRER"|"FINGERPRINT_EXACT"|"FINGERPRINT_SCORED"|"IP_FUZZY", "matchConfidence": "exact"|"high"|"medium"|"low", "matchScore"?: number (only when scored), "linkId": string, "path": string, "params": object }
  • Response 200, no match: \{ "matched": false, "matchMethod": "NONE" }
  • 4xx never retried; 5xx/network retried with exponential backoff (Flutter: 1s, 2s, 4s; maxRetries=2; timeout 10s).

1.3 /sdk/* (identity + telemetry)​

All: Authorization: Bearer opl_sdk_… (CLIENT tier — a SERVER opl_api_ key gets 403; wrong/missing key → 401; unknown deviceId → 404). Rate limits: session/identity 60/min (rl:sdk), events 600/min (rl:sdk-events). Bodies are whitelist-validated; only documented fields accepted.

  • POST /sdk/session — body \{ deviceId (required, 8–100), platform?, osVersion?, deviceModel? } → 200 \{ "ok": true, "deviceId", "firstSeenAt", "lastSeenAt" }. Call unconditionally on init when no /match fired this launch.
  • POST /sdk/identity — body \{ deviceId, externalId (1–255), platform?, osVersion?, deviceModel? } → binding state. 404 if device never had a session.
  • POST /sdk/identity/clear — body \{ deviceId } → binding closed.
  • POST /sdk/events — body \{ deviceId, eventName (1–100), properties? (JSON object, ≤ 10 240 bytes serialized — **rejected**, never truncated) } → 200 recorded. Explicit instrumentation only; SDK never auto-fires.

1.4 Error envelope (all guarded endpoints)​

NestJS global filter shape — matches optolink-node's ErrorEnvelope: \{ "message": string, "details"?: [\{ "message", "field"? }], "requestId"? } (400 validation has details; 429 body message parses as Retry after N second(s)).


2. Match/Fingerprint Contract (what the backend actually scores)​

Token storage (browser click → Redis MatchRecord), match-store.service.ts:

  • matchWindow TTL = link.matchWindow ?? template.matchWindow ?? 24 hours (1–720 allowed). Records are keyed by hashed IP; per-IP caps: 50 fingerprint candidates, 20 fuzzy candidates.
  • Clipboard token format: opl_ + 22 base64url chars (randomBytes(16)). After a token match the key is kept alive GRACE_SECONDS = 30 (SDK retry tolerance), then gone.

Priority chain (first hit wins): P1 clipboard → P1.5 install referrer → P2 decomposed-fingerprint exact (no raw UA: ip + platform + osVersion + deviceModel all equal) → P2.5 weighted scoring → P3 IP-only fuzzy.

P2.5 scoring (SCORE_WEIGHTS / threshold 65, max 30+10+15+8+10+10+12+9 ≈ 104):

ComponentPoints
IP exact30
platform match10
osVersion exact15 (major-only: 8)
language match10
timezone match10
deviceModel match12
recency bonus<5min:+9, <30min:+7, <60min:+5, <6h:+3, <12h:+1
  • Score ≥ 65 = match; confidence: ≥85 high, ≥70 medium, else low. P2 exact → high; P3 → low.
  • Anti-false-positive: if > 3 candidates on the same IP score ≥ 65 → reject all (shared-Network guard).
  • Max 30 match attempts/min/IP; clipboard token match is "exact" confidence; matched records get a 30s grace TTL so duplicate/retried calls within 30s still return the match.
  • Implication for iOS: language, timezone, osVersion, deviceModel must be collected in browser-equivalent formats (hyphen locale, IANA tz, systemVersion, machine) or P2/P2.5 degrade to IP-only.

GET /.well-known/apple-app-site-association (per Host header): \{ applinks: \{ apps: [], details: [\{ appIDs: ["&lt;teamId>.&lt;bundleId>"], components: [\{ "/": "/&lt;orgKey>/*" }] }] }, webcredentials: \{ apps: [...] } }. Aggregated across all ACTIVE orgs on the platform default domain; single-org on custom domains; orgs without teamId are skipped (never publish invalid appIDs); Cache-Control: max-age=3600. iOS SDK must handle /\{orgKey}/\{shortCode} opens landing in-app → call endpoint 1.2 (/data) for JSON, never the HTML redirect.


4. Flutter SDK — Dart API & iOS side, file by file​

pubspec.yaml v1.2.0. Deps: app_links ≥6.4 (Universal/App Links + schemes), http, shared_preferences, device_info_plus, flutter_timezone. Pure-Dart plugin; no platform channel for iOS logic.

FileRoleiOS-relevant mechanics
lib/src/optolink.dartOptoLink.initialize(config) singleton; onLink broadcast stream (replays deferred result to first subscriber); getInitialLink(); resolveDeferredLink(); setIdentity/clearIdentity/trackEvent; _registerSession() fallback when no match firedRegex ^/([a-z0-9]\{4})/([^/]+)/?$ extracts shortCode only when path orgKey == configured orgKey; then GETs /data?deviceId=… and wraps result with matchMethod: DIRECT
lib/src/config.dartOptoLinkConfig(apiKey, orgKey, baseUrl, timeout=10s, maxRetries=2, clipboardEnabled=true, logger)clipboardEnabled=false avoids the iOS 16+ paste banner
lib/src/match_client.dartAll HTTP: POST /match (X-API-Key header), /sdk/* (Authorization: Bearer — deliberate difference), GET /\{orgKey}/\{code}/data; retries w/ backoff on 5xx/network only; X-OptoLink-SDK: flutter/1.0.0 headerErrors never throw to caller — null/false + logger callback (warning/error levels)
lib/src/deferred_link_handler.dartFirst-launch-only orchestration: parallel clipboard+referrer read → fingerprint → /match → mark done (regardless of result) → clear clipboardThe whole deferred flow lives here, in Dart
lib/src/clipboard_reader.dartReads opl_-prefixed token (≤100 chars) via Clipboard.getData; clears after useUses Flutter services → UIPasteboard under the hood; paste banner applies
lib/src/device_id_store.dartUUIDv4 in SharedPreferences (iOS UserDefaults), key optolink_device_id; lost on reinstall → re-triggers deferred flow
lib/src/fingerprint_collector.dart\{platform: "iOS", osVersion: systemVersion, deviceModel: utsname.machine, language: localeName ('_'→'-'), timezone: flutter_timezone} — screen size deliberately excluded (CSS-vs-physical px mismatch)device_info_plus IosDeviceInfo, flutter_timezone
lib/src/first_launch_detector.dartBool flag in SharedPreferences optolink_first_launch_done
lib/src/install_referrer_reader.dartMethod-channel to Android InstallReferrerClient; returns null on iOS by design
lib/src/deep_link_handler.dartapp_links package: getInitialLink() (cold) + uriLinkStream (warm)Covers iOS Universal Links + custom schemes via the package
lib/src/link_data.dartOptoLinkData \{ linkId?, path, params?, matchType, confidence(enum exact/high/medium/low), isDeferred }; parses /match + /data responses

The iOS folder — what the "native" side actually contains​

  • ios/Classes/OptolinkFlutterPlugin.swift — 19 lines, a stub. Registers method channel com.optomatica.optolink_flutter/channel, handle() returns FlutterMethodNotImplemented for everything. Comment: Dart guards referrer behind Platform.isAndroid; iOS never calls native methods; notImplemented surfaces accidental calls loudly.
  • ios/optolink_flutter.podspec — iOS 13.0+, Swift 5, no third-party pods, no privacy manifest (commented-out PrivacyInfo.xcprivacy placeholder), excludes i386 simulator slice.

Conclusion: the Flutter plugin implements ZERO iOS functionality natively. On iOS everything is done from Dart through pub packages: Universal Links (app_links → UIApplication/scene delegate callbacks), clipboard (UIPasteboard via flutter services), device info (Sysctl/utsname via device_info_plus), timezone (NSTimeZone via flutter_timezone), persistence (UserDefaults via shared_preferences).

iOS-relevant gaps inherited from that architecture​

  1. No native Universal-Link integration code — app_links owns scene-connection/swizzle; a native SDK must implement NSUserActivity/UIScene continuationUserActivity (or SwiftUI .onOpenURL) itself, plus 冷-start handoff.
  2. iOS paste banner (iOS 16+) fires on first-launch clipboard read; only mitigation today is a config flag. No Support-PPL /copy-link detection, no UIPasteboard detection APIs (iOS 16.1 detectPatterns) used.
  3. No install-referrer equivalent is possible on iOS — P1.5 is Android-only; iOS matching relies on clipboard (P1) + fingerprint (P2/P2.5) + IP (P3) only.
  4. Flutter's X-OptoLink-SDK header is a hard-coded flutter/1.0.0 string — a native SDK should send a truthful ios/&lt;version> value.
  5. shared_preferences-stored deviceId is invisible to a native SDK — if both SDKs ever run in one app (Flutter host + native module), they would mint different deviceIds; a shared Keychain/app-group location would fix that (not needed for standalone native SDK).

5. Auth Model (how an app is identified)​

  • Keys are org-scoped, fixed-purpose, prefix-indexed (auth/services/api-key-hash.service.ts):
    • opl_sdk_… → CLIENT tier → /sdk/* endpoints (and optionally /match attribution).
    • opl_api_… → SERVER tier → /links CRUD. Server keys on client endpoints = 403 (and vice versa).
  • Guard: ApiKeyAuthGuard — Authorization: Bearer &lt;raw>; lookup by key prefix, hash-verify (hashedKey stored, never the raw key), org must be ACTIVE, key not revoked/expired. Superseded keys keep working for a 24h grace window after regeneration.
  • /match has no guard (public, rate-limited 30/min/IP) — the X-API-Key header there is optional, only used to attribute no-match drop-offs (audit id sdk:&lt;first8>).
  • /sdk/* + /links use Authorization: Bearer; Flutter sends X-API-Key only on /match and /data — replicate this exact split in the iOS SDK.
  • Consequence: an iOS app embeds its CLIENT key (opl_sdk_…) in the binary — it's a public-by-design identifier; rate limits + tier + org status are the controls. Never ship an opl_api_ key in an app.

6. Conventions worth carrying into the native iOS SDK​

From the Flutter SDK (behavioral parity):

  • Naming: initialize(config) (async, once, singleton), onLink stream, getInitialLink(), resolveDeferredLink(), setIdentity(externalId), clearIdentity(), trackEvent(name, properties), data type named OptoLinkData with linkId/path/params/matchType/confidence/isDeferred.
  • Config: apiKey, orgKey, baseUrl, timeout (10s default), maxRetries (2), clipboardEnabled, logger callback with warning/error levels.
  • Retry policy: exponential backoff 1s/2s/4s on network + 5xx only; never retry 4xx; false/nil returns instead of thrown errors; failures surfaced via logger.
  • Send X-OptoLink-SDK: ios/&lt;x.y.z> header; verify orgKey in incoming URLs before hitting /data; deviceId = UUIDv4 persisted across launches, wiped on reinstall (do NOT persist in Keychain unless intentionally surviving reinstall — that would break deferred re-match); first-launch flag semantics identical to Flutter (mark done even when match fails).
  • Fingerprint values: platform "iOS", osVersion = UIDevice.systemVersion, deviceModel = utsname.machine (e.g. iPhone16,1), language = hyphenated locale, timezone = IANA identifier.

From optolink-node (error/naming conventions):

  • Two error types only: OptoLinkError (status, message, details[], requestId, retryAfterSeconds parsed from 429 body, raw) and connection-error variant (status 0). No per-status subclasses. → Swift equivalent: one OptoLinkError enum/struct with status, requestId, details, retryAfter, plus a .connection case.
  • Client classes grouped as resources (client.links.create/list/update/delete/…); constructor validates apiKey eagerly (TypeError if empty); trailing slash stripped from baseUrl.
  • Parse Retry after N second(s) from 429 message into structured retryAfterSeconds.

Swift-native additions to decide (not settled anywhere yet): Availability (iOS 13 floor like the podspec), privacy manifest (PrivacyInfo.xcprivacy — required reason APIs for UserDefaults; pasteboard usage), Swift concurrency (async/await + AsyncStream for onLink), URLSession with structured concurrency retries, App Groups/Keychain only if Flutter-coexistence matters.

7. Open questions for the native SDK build​

  1. Clipboard on iOS: keep Flutter behavior (read on first launch, accept banner) vs iOS 16.1+ UIPasteboard.detectPatterns (no banner) — needs a product decision.
  2. Deferred flow trigger: Flutter blocks inside initialize(); native Swift should probably make the match call async-delivered via onLink to avoid blocking app startup on a 10s-timeout network call.
  3. matchMethod response uses SCREAMING_SNAKE (FINGERPRINT_SCORED), Flutter exposes it verbatim as matchType while normalizing DIRECT locally — native SDK should map to a Swift enum, documenting the raw-string passthrough.
  4. Whether the iOS SDK should call /sdk/session from application:didFinishLaunching AND scene activation (Flutter only does it when no match fired) — keep parity: once per launch, only when no /match and no /data call happened.