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'scallback_url.sdk=<platform>— advisory:web|ios|android|flutter|rn. Any non-emptysdkvalue 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)
type | Fields | Meaning | Host action |
|---|---|---|---|
ready | — | Flow mounted and the session resolved OK. | Reveal the surface / hide your spinner. |
completed | status, sessionId? | Terminal decision reached. | Resolve the result; dismiss (unless autoClose off). |
error | code, message? | Unrecoverable: invalid or expired token, load failure. | Resolve an error result; dismiss. |
close | — | User acknowledged the result screen (tapped Done). | Dismiss. If a completed already arrived, that is the result. |
statusoncompletedis the raw server status:approved·declined·in_review·abandoned.codeonerroris a stable machine code:invalid_session·expired(extendable). Branch oncode, never onmessage.
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:
| Platform | Transport | How the flow emits | How the SDK receives |
|---|---|---|---|
| Web (iframe) | postMessage | window.parent.postMessage(msg, "*") | window.addEventListener("message", …) |
| Web (popup) | postMessage | window.opener.postMessage(msg, "*") | listener on the opener window |
| Android | JS interface | window.ThirdFactorAndroid.postMessage(json) | webView.addJavascriptInterface(obj, "ThirdFactorAndroid"), @JavascriptInterface fun postMessage(String) |
| iOS | WKScriptMessageHandler | window.webkit.messageHandlers.thirdfactor.postMessage(msg) | userContentController.add(self, name: "thirdfactor") |
| Flutter | JS channel | window.ThirdFactorFlutter.postMessage(json) | controller.addJavaScriptChannel("ThirdFactorFlutter", …) — one name, both platforms |
| React Native | react-native-webview | window.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 status | From |
|---|---|
approved | completed status approved |
declined | completed status declined |
in_review | completed status in_review / review / manual_review / pending, or any unknown terminal status (conservative) |
cancelled | completed status abandoned; or the user dismissed (back button, ✕, Esc, closed tab) before a terminal event |
error | an 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 checkevent.origin === <flow origin>; native WebViews only load the trusted flow origin and refuse to navigate elsewhere. - Events carry no secrets — only
status, a publicsessionId, 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):
| field | req | meaning |
|---|---|---|
documentNumber | yes* | passport / ID number from the MRZ |
dateOfBirth | yes* | YYMMDD |
dateOfExpiry | yes* | YYMMDD |
mrzKey | — | precomputed BAC key (alternative to the three fields above) |
canNumber | — | 6-digit Card Access Number for PACE-only eIDs |
dataGroups | — | which 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):
| field | meaning |
|---|---|
sod_b64 | Document Security Object (EF.SOD) — the signed hash set |
dg1_b64 | DG1 (MRZ) |
dg2_b64 | DG2 (face image) |
dg11_b64, dg12_b64, … | optional extra DGs when requested |
com_b64 | EF.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.