Verification

Bridge protocol

The transport-agnostic flow → host event contract and the bidirectional native-capability channel. Protocol version 1.

Every ThirdFactor client SDK (web, iOS, Android, Flutter) is a thin shell around the same hosted verification flow (/verify/<session_token>). The shell opens the flow and listens for lifecycle events over a small, transport-agnostic bridge. This page is the single source of truth for that bridge — read it if you're writing a host for a platform we don't ship, or debugging an existing SDK. The reference implementation lives in the flow's frontend/components/verify/sdk-bridge.ts (flow side) and sdks/web (host side).

1. Turning on embed mode

The SDK appends query params when it opens the flow URL:

https://<domain>/verify/<token>?embed=1&sdk=<platform>
  • embed=1 — required. Puts the flow in embed mode: it reports lifecycle to the host instead of redirecting the browser to the session's callback_url.
  • sdk=<platform> — advisory: web | ios | android | flutter | rn. Any non-empty sdk value alone also enables embed mode.

2. Message envelope

Every event the flow emits is a JSON object with this envelope:

{
  "source": "thirdfactor",   // constant discriminator — filter on this
  "version": 1,              // protocol version
  "sdk": "ios",              // echo of the ?sdk= platform (may be null)
  "type": "...",             // see event types below
  // ...event-specific fields
}

Warning

Hosts must ignore any message where source !== "thirdfactor". Other scripts on the page (analytics, extensions, frameworks) also use postMessage; the source tag is your only reliable discriminator.

3. Event types (flow → host)

typeFieldsMeaningHost action
readyFlow mounted and the session resolved OK.Reveal the surface / hide your spinner.
completedstatus, sessionId?Terminal decision reached.Resolve the result; dismiss (unless autoClose off).
errorcode, message?Unrecoverable: invalid or expired token, load failure.Resolve an error result; dismiss.
closeUser acknowledged the result screen (tapped Done).Dismiss. If a completed already arrived, that is the result.
  • status on completed is the raw server status: approved · declined · in_review · abandoned.
  • code on error is a stable machine code: invalid_session · expired (extendable). Branch on code, never on message.

The exact wire payload for each event, as emitted by the flow:

{
  "source": "thirdfactor",
  "version": 1,
  "sdk": "flutter",
  "type": "ready"
}
{
  "source": "thirdfactor",
  "version": 1,
  "sdk": "flutter",
  "type": "completed",
  "status": "approved",
  "sessionId": "6f0e9c1a-2b3d-4e5f-8a9b-0c1d2e3f4a5b"
}
{
  "source": "thirdfactor",
  "version": 1,
  "sdk": "flutter",
  "type": "error",
  "code": "expired",
  "message": "This verification link has expired."
}
{
  "source": "thirdfactor",
  "version": 1,
  "sdk": "flutter",
  "type": "close"
}

Note

sessionId on completed is optional — the flow includes it when it has resolved the session id, which callers echo into the normalized result. message on error is human/log copy only; two hosts should make identical decisions from code alone.

4. Transports

The flow writes every event to all channels at once (fire-and-forget); whichever host is listening picks it up. An SDK only needs to wire the transport for its platform:

PlatformTransportHow the flow emitsHow the SDK receives
Web (iframe)postMessagewindow.parent.postMessage(msg, "*")window.addEventListener("message", …)
Web (popup)postMessagewindow.opener.postMessage(msg, "*")listener on the opener window
AndroidJS interfacewindow.ThirdFactorAndroid.postMessage(json)webView.addJavascriptInterface(obj, "ThirdFactorAndroid"), @JavascriptInterface fun postMessage(String)
iOSWKScriptMessageHandlerwindow.webkit.messageHandlers.thirdfactor.postMessage(msg)userContentController.add(self, name: "thirdfactor")
FlutterJS channelwindow.ThirdFactorFlutter.postMessage(json)controller.addJavaScriptChannel("ThirdFactorFlutter", …) — one name, both platforms
React Nativereact-native-webviewwindow.ReactNativeWebView.postMessage(json)onMessage prop

Warning

The exact JS-interface / handler names above are load-bearing — they are hard-coded in the flow's sdk-bridge.ts. Do not rename them in a host without changing the flow.

Android and React Native receive a JSON string — parse it. iOS receives a structured object (a [String: Any] dictionary). Web receives a structured clone (an object). Flutter receives a JSON string on the channel's onMessageReceived and decodes it.

5. Normalized result (host → app)

Each SDK maps the raw status/code onto one identical, small result type:

SDK statusFrom
approvedcompleted status approved
declinedcompleted status declined
in_reviewcompleted status in_review / review / manual_review / pending, or any unknown terminal status (conservative)
cancelledcompleted status abandoned; or the user dismissed (back button, ✕, Esc, closed tab) before a terminal event
erroran error event, or a transport/load failure. Carries { code, message }.

The result also carries sessionId (when present) and rawStatus (the original server string) so callers can distinguish e.g. abandoned from an explicit cancel.

6. Cancellation

If the user dismisses the surface (Android back, iOS swipe/close, web ✕ / Esc / closed popup) before a completed/error arrives, the SDK resolves cancelled. A close event received after a completed is just the dismiss signal — the earlier completed remains the result.

7. Security

  • postMessage is sent with targetOrigin: "*" because the flow cannot know the parent origin. The host validates the sender: web SDKs check event.origin === <flow origin>; native WebViews only load the trusted flow origin and refuse to navigate elsewhere.
  • Events carry no secrets — only status, a public sessionId, and error codes. The applicant's documents and selfie never cross the bridge.
  • The client result is advisory. Authoritative decisions come from the HMAC-signed webhook or GET /v3/session/<id>/decision/.

Warning

Grant access or move money only after your backend confirms the decision from the signed webhook or GET /v3/session/<id>/decision/. The bridge result is a UX signal — a client can be closed or tampered with; the webhook cannot.

8. Native capability channel (bidirectional)

Some verification steps need native hardware the WebView cannot reach — reading an ePassport / eID chip over NFC is the canonical case (NFC_VERIFICATION). The flow delegates these to the native SDK over a small request/response extension of the bridge. Web iframe/popup hosts advertise no capabilities, so the flow transparently keeps its server-side fallback (NFC → manual review).

8.1 Handshake (host → flow)

Before the flow loads — or, for WebViews, on first page-finish — the native SDK injects a global describing itself:

window.__thirdfactorHost = {
  platform: "flutter",      // ios | android | flutter | rn
  sdkVersion: "1.1.0",
  capabilities: ["nfc"],    // capability ids the SDK can fulfil
};

The flow reads it via hostCapabilities() / hostHasCapability("nfc"). A host that cannot fulfil a capability (NFC off, no radio, web) simply advertises an empty capabilities array — the flow then keeps its server-side fallback for any step that would have needed it.

8.2 Command (flow → host)

When the flow reaches a step it wants the host to fulfil, it sends a command envelope over the same transports as lifecycle events:

{
  "source": "thirdfactor", "version": 1, "sdk": "flutter",
  "type": "command",
  "command": "nfc.scan",
  "requestId": "cmd_1",
  "params": { /* command-specific */ }
}

requestId is opaque — the host must echo it back verbatim so the flow can pair the reply to the request.

8.3 Result (host → flow)

The SDK runs the capability natively, then evaluates JS in the WebView to resolve the matching request:

window.ThirdFactorNative.resolveCommand("cmd_1", {
  ok: true,
  data: { /* command-specific */ }
});
// or: { ok: false, error: { code: "user_cancelled", message: "…" } }

Hosts may pass the second argument either as an object or as a JSON string (the Flutter SDK double-encodes to escape the payload safely); resolveCommand accepts and parses both. The flow pairs the result to its command by requestId, with a default 180 s timeout — a request that isn't resolved in time is auto-failed with { ok:false, error:{ code:"timeout" } }.

8.4 nfc.scan contract

params (BAC/PACE access is derived from the MRZ the flow already OCR'd):

fieldreqmeaning
documentNumberyes*passport / ID number from the MRZ
dateOfBirthyes*YYMMDD
dateOfExpiryyes*YYMMDD
mrzKeyprecomputed BAC key (alternative to the three fields above)
canNumber6-digit Card Access Number for PACE-only eIDs
dataGroupswhich DGs to read; default ["DG1","DG2","SOD"]

* Either the three MRZ fields or mrzKey (or canNumber for PACE). Not every SDK supports mrzKey — the Flutter and Android SDKs derive the key from the raw MRZ fields and reject a mrzKey-only command with unsupported.

The full request and both reply shapes on the wire:

{
  "source": "thirdfactor", "version": 1, "sdk": "flutter",
  "type": "command",
  "command": "nfc.scan",
  "requestId": "cmd_1",
  "params": {
    "documentNumber": "X1234567",
    "dateOfBirth": "900101",
    "dateOfExpiry": "300101",
    "dataGroups": ["DG1", "DG2", "SOD"]
  }
}
{
  "ok": true,
  "data": {
    "sod_b64": "d0hQ…",
    "dg1_b64": "YVpr…",
    "dg2_b64": "/9j/4AAQ…",
    "com_b64": "YHca…",
    "aa":      { "performed": true,  "success": true  },
    "chipAuth":{ "performed": false, "success": false }
  }
}
{
  "ok": false,
  "error": {
    "code": "wrong_key",
    "message": "Access key rejected by the chip (check MRZ/CAN)."
  }
}

result data — raw chip bytes, base64. The SDK does not do passive auth; it reads and forwards, matching how the platform's engine validates server-side (parse SOD, hash DGs, verify the signer chain against the CSCA masterlist, compare DG1↔OCR and DG2↔selfie):

fieldmeaning
sod_b64Document Security Object (EF.SOD) — the signed hash set
dg1_b64DG1 (MRZ)
dg2_b64DG2 (face image)
dg11_b64, dg12_b64, …optional extra DGs when requested
com_b64EF.COM (present DG list)
aa{ performed, success } — Active Authentication result if the chip supports it
chipAuth{ performed, success } — Chip Authentication (EAC) if performed

The flow uploads data to POST /api/verify/<token>/nfc/, which runs passive authentication and resolves the NFC_VERIFICATION module. Error codes: user_cancelled, tag_lost, wrong_key (MRZ/CAN mismatch), unsupported, nfc_disabled, timeout. See the NFC deep-dive for the full architecture.

9. End-to-end sequence

The full lifecycle of an embedded session, including the optional NFC delegation:

 App / Backend        Native SDK (host)         Hosted flow (WebView)        Backend API
      │                     │                          │                          │
      │  POST /v3/session/  │                          │                          │
      ├────────────────────────────────────────────────────────────────────────►│
      │            { id, url }                          │                         │
      │◄────────────────────────────────────────────────────────────────────────┤
      │  verify(url)        │                           │                         │
      ├────────────────────►│  load url?embed=1&sdk=…   │                         │
      │                     ├──────────────────────────►│                         │
      │                     │  inject __thirdfactorHost │                         │
      │                     ├──────────────────────────►│  (§8.1 handshake)       │
      │                     │◄──── ready ───────────────┤                         │
      │                     │        (capture, liveness, face match run in flow)  │
      │                     │◄──── command nfc.scan ────┤  (§8.2, if capability)  │
      │      [read chip]    │                           │                         │
      │                     ├──── resolveCommand ──────►│  (§8.3)                 │
      │                     │                           │  POST /verify/<t>/nfc/  │
      │                     │                           ├────────────────────────►│
      │                     │◄──── completed(status) ───┤                         │
      │◄─── result ─────────┤                           │                         │
      │                     │◄──── close (Done) ────────┤  (dismiss)              │
      │                     │                           │                         │
      │  CONFIRM server-side: webhook  identity.kyc.session.*                     │
      │                  or  GET /v3/session/<id>/decision/                       │
      ├────────────────────────────────────────────────────────────────────────►│

The nfc.scan exchange is skipped entirely when the host advertises no nfc capability — the flow resolves that step server-side (manual review) instead.

10. Versioning

version is currently 1. New event types, commands, capabilities, or fields are additive — hosts must ignore unknown type/command/source values and unknown fields. A breaking change bumps version and is negotiated via the ?sdk= / embed params.

Rolling your own host

If there's no packaged SDK for your platform, hosting the flow yourself is a small job: open the WebView, wire the transport for §4, filter on source === "thirdfactor", map the statuses per §5, and treat cancellation per §6. The complete contract is on this page. For NFC support, add the §8 capability channel; without it, NFC steps fall through to the server-side review path automatically.

FAQ