Skip to main content

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 Centralcom.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)
RuntimeKotlin, minSdk 23, one third-party dependency (OkHttp 4.x)
Repooptolink-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​

CallAuthSuccess
POST /matchnone201 hit or miss (accept any 2xx — never pin 200)
GET /{orgKey}/{shortCode}/data?deviceId=…none200 {linkId, path, params}
POST /sdk/sessionBearer CLIENT200 {ok: true, deviceId, firstSeenAt, lastSeenAt} — upsert; repeat keeps firstSeenAt, bumps lastSeenAt
POST /sdk/identity {deviceId, externalId}Bearer CLIENT200 {endUserId, rebound}
POST /sdk/identity/clear {deviceId}Bearer CLIENT200 {cleared: true} (idempotent)
POST /sdk/events {deviceId, eventName, properties}Bearer CLIENT200 {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"} — no matchConfidence.
  • matchMethod is UPPER_SNAKE (CLIPBOARD, INSTALL_REFERRER, FINGERPRINT_EXACT, FINGERPRINT_SCORED, IP_FUZZY, DIRECT, NONE); matchConfidence is lowercase (exact|high|medium|low); matchScore (int) appears only on FINGERPRINT_SCORED and surfaces as OptoLinkData.score; params is flat string→string or null.
  • 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.

SurfaceLimitKey
POST /match30/minper IP
GET …/data500/sper IP
/sdk/session + identity + clear (shared bucket)60/minper key
POST /sdk/events600/minper 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.

/.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.md is the runtime probe.