Android SDK contract
Scope: the CLIENT-tier surface com.optomatica:optolink-android codes against —
deferred matching, direct-link resolution, device session, identity, events. The
backend write path behind it (DeviceProfile/LinkMatch rows) is
Device identity & match attribution;
the Flutter wrapper over this SDK 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,track-event.dto.ts,src/api-key/api-key.service.ts,src/rate-limiting/rate-limit.decorator.ts,src/resolution/well-known.controller.ts) and optolink-android @ fb529ef.
Identity
| Maven Central | com.optomatica:optolink-android 0.1.1 (0.1.0's Kotlin 2.4.0 metadata is unreadable by KGP ≤ 2.2 — always pin ≥ 0.1.1) |
| Runtime | Kotlin, minSdk 23, one third-party dependency (OkHttp 4.x) |
| Repo | optolink-android/ |
| Compat line | "Compatible with OptoLink backend v2.1.0+" (README) |
Auth
Authorization: Bearer opl_sdk_… (CLIENT tier) on /sdk/* — a SERVER key
authenticates but gets 403 API key tier 'SERVER' is not permitted on this endpoint (requires 'CLIENT'). /match and GET …/data are unauthenticated:
the X-API-Key header the SDK sends is audit decoration only (never validated —
it keys the sdk:<first-8-chars> audit trace). Audit header on every request:
X-OptoLink-SDK: optolink-android/<version> (trace only — never parse it).
Endpoints
| Call | Auth | Success |
|---|---|---|
POST /match | none | 201 hit or miss (accept any 2xx — never pin 200) |
GET /{orgKey}/{shortCode}/data?deviceId=… | none | 200 {linkId, path, params} |
POST /sdk/session | Bearer CLIENT | 200 {ok: true, deviceId, firstSeenAt, lastSeenAt} — upsert; repeat keeps firstSeenAt, bumps lastSeenAt |
POST /sdk/identity {deviceId, externalId} | Bearer CLIENT | 200 {endUserId, rebound} |
POST /sdk/identity/clear {deviceId} | Bearer CLIENT | 200 {cleared: true} (idempotent) |
POST /sdk/events {deviceId, eventName, properties} | Bearer CLIENT | 200 {ok: true} |
/data errors: 404 Link not found for unknown org or code; 410 for
inactive link, expired link, or suspended org (spec tables list only the 404).
deviceId on /data (and in the /match body) upserts the DeviceProfile
fire-and-forget — it never blocks the response.
The /match contract
One bulk POST carrying all collected signals; the backend owns the ladder
(P1 clipboard → P1.5 install referrer → P2 fingerprint exact → P2.5 scored →
P3 IP fuzzy) — the SDK never sequences it. Body fields (all optional strings,
forbidNonWhitelisted rejects unknown keys): clipboardToken (≤100) ·
installReferrer (bare opl_… token parsed out of the referrer, ≤100) ·
deviceId (8–100) · platform · osVersion · deviceModel · language ·
timezone (Android sends IANA tz verbatim — the deliberate iOS delta).
Hit response keys — nothing else on the wire (candidateCount/sessionToken
are server-side only):
{"matched":true,"matchMethod":"FINGERPRINT_SCORED","matchConfidence":"high",
"matchScore":86,"linkId":"…","path":"/promo/flash-24h","params":{"sku":"…"}}
- Miss body is exactly
{"matched":false,"matchMethod":"NONE"}— nomatchConfidence. matchMethodis UPPER_SNAKE (CLIPBOARD,INSTALL_REFERRER,FINGERPRINT_EXACT,FINGERPRINT_SCORED,IP_FUZZY,DIRECT,NONE);matchConfidenceis lowercase (exact|high|medium|low);matchScore(int) appears only onFINGERPRINT_SCOREDand surfaces asOptoLinkData.score;paramsis flat string→string ornull.- Runs once per install (first-launch flag completes regardless of outcome);
resolveDeferredLink()re-runs it unconditionally. Ladder mechanics, first-click timezone limitation, and persistence: Device identity & match attribution.
Error envelope + rate limits
Same envelope everywhere: {statusCode, error, message, details?, timestamp, path, requestId?} — details[] on 400 validation only; requestId echoes a
client-sent x-request-id (the SDK sends none); 429 has no header — regex
Retry after (\d+) second(s) from the message. Messages asserted by the SDK's
error tests: 401 Missing Authorization header / Invalid Authorization format / Invalid API key; 403 tier mismatch; 404 Unknown deviceId <id> — call /sdk/session first (identity + events, session-first enforced); 400
properties exceeds 10240 bytes when serialized — reduce the payload size;
400 deviceId must be longer than or equal to 8 characters.
| Surface | Limit | Key |
|---|---|---|
POST /match | 30/min | per IP |
GET …/data | 500/s | per IP |
/sdk/session + identity + clear (shared bucket) | 60/min | per key |
POST /sdk/events | 600/min | per key |
CLIENT-key rotation: the superseded key keeps authenticating 24 h (expiresAt
grace, SDK_GRACE_MS) with its own rate-limit bucket; SERVER keys revoke
instantly. Retries (SDK-side): /match + /data retry connection/429/5xx
(repeat-safe); identity/events retry connection + 429, never 5xx (double-count);
session is fire-and-forget, never retried. Backoff min(0.5·2ⁿ, 5) s.
App Links prerequisites (what verification consumes)
/.well-known/assetlinks.json is served by the backend per Host from the org's
ANDROID AppConfig (bundleId, sha256CertFingerprints[]): custom VERIFIED/GRACE
host → single-org statement array; platform default host → aggregated statements
for every ACTIVE org with fingerprints; unknown host 404 Domain not configured;
incomplete config (empty fingerprints) 404 No Android app configuration.
Headers Content-Type: application/json, Cache-Control: public, max-age=3600.
Integrator side: the android:autoVerify intent filter + the associated domain.
Domain lifecycle: Custom domains.
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 in
optolink-android/docs/test.mdis the runtime probe.