WebSocket protocol
In-person verification uses a WebSocket relay architecture. Your verifier app (kiosk, handheld scanner, etc.) connects to the Verify API over WebSocket and acts as a transport bridge between the API and the holder’s physical device. The backend drives all protocol logic; your app handles the physical transport (NFC tap or Bluetooth connection).
This is a separate integration path from the browser SDK — there is no iframe, no Digital Credentials API, and no QR code displayed on your side. Instead, your app directly relays NFC APDUs and BLE events over the WebSocket.
Connection
Section titled “Connection”First, create a session using your API key:
POST https://api.verify.spruceid.com/sessionsAuthorization: Bearer {api_key}The response contains a session id and a short-lived client_secret (along with other fields used by the browser SDK that you can ignore):
{ "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "client_secret": "eyJ...", "iframe_url": "..."}Then open the WebSocket:
wss://api.verify.spruceid.com/sessions/{id}/mdoc-proximity/ws?token={client_secret}Browser clients must use the ?token= query parameter — the browser WebSocket API does not support custom headers. Native clients may alternatively pass an Authorization: Bearer {client_secret} request header.
Sessions expire 10 minutes after creation — the clock starts when you call POST /sessions, not when the WebSocket connects. If engagement (QR scan, NFC tap) takes more than a few minutes, create the session closer to that step. Connecting to an expired or unknown session returns HTTP 404. A second concurrent connection to the same session returns HTTP 409.
Message format
Section titled “Message format”All messages are UTF-8 JSON text frames.
Client → backend (your app sends):
{ "type": "step_result", "op": "<op>", "status": "ok", ... }Backend → client (your app receives):
{ "type": "step_request", "op": "<op>", "params": { ... } }{ "type": "complete", "outcome": { ... } }{ "type": "error", "message": "..." }All binary data (NFC APDUs, BLE payloads) is transmitted as standard base64 (RFC 4648 with = padding). Fields you send must be base64-encoded; fields you receive must be base64-decoded before use.
Engagement
Section titled “Engagement”Start the protocol by sending a single engage message that declares the requested namespaces. The transport (QR or NFC) is not declared up front — it’s determined by whichever follow-up event you send next:
- Scanned a QR code? Send
qr_scannedwith the payload. - NFC card connected? Send
nfc_card_present.
This means a client may listen on both transports concurrently — for example, a kiosk with both a webcam and a contactless reader can simply send engage and let the holder pick whichever is more convenient. The backend commits to a transport when the first follow-up event arrives.
{ "type": "step_result", "op": "engage", "status": "ok", "namespaces": { "org.iso.18013.5.1": ["family_name", "given_name", "birth_date"] }}QR engagement
Section titled “QR engagement”The holder displays a QR code in their wallet app. Send engage, scan the QR code, then deliver the payload with qr_scanned:
sequenceDiagram
participant W as wallet
participant C as client
participant B as backend
C->>B: engage + namespaces
W->>C: QR code (optical scan)
C->>B: qr_scanned + data
B->>C: ble_exchange
{ "type": "step_result", "op": "qr_scanned", "status": "ok", "data": "mdoc:owBjMS4wAYIB..." }data is the raw QR string as scanned — pass it through unmodified, including the mdoc: URI prefix.
The backend replies with ble_exchange after qr_scanned. See BLE exchange.
NFC engagement
Section titled “NFC engagement”The holder taps their physical card or device. Send engage, then signal card readiness with nfc_card_present once you’ve connected to the card. The backend drives APDUs only after nfc_card_present:
sequenceDiagram
participant W as wallet
participant R as NFC reader
participant C as client
participant B as backend
C->>B: engage + namespaces
W->>R: tap (RF contact)
R->>C: NFC card detected
C->>B: nfc_card_present
loop NFC handover
B->>C: nfc_apdu
C->>R: transmit APDU
R-->>W: APDU command (RF)
W-->>R: APDU response (RF)
R->>C: APDU response
C->>B: nfc_rapdu
end
B->>C: ble_exchange
Once a card is connected, send:
{ "type": "step_result", "op": "nfc_card_present", "status": "ok" }For each nfc_apdu instruction from the backend, base64-decode params.data to get the raw APDU bytes, send them to the NFC card, then base64-encode the response and send it back as nfc_rapdu:
{ "type": "step_result", "op": "nfc_rapdu", "status": "ok", "data": "<base64 RAPDU>" }If the NFC transmit fails, report the underlying PC/SC error in reason using the canonical PC/SC error variant name (e.g. "RemovedCard", "NotTransacted"):
{ "type": "step_result", "op": "nfc_rapdu", "status": "error", "reason": "RemovedCard" }Handling transient card removal
Section titled “Handling transient card removal”PC/SC error codes are standardized, so the backend — not your app — decides whether a transmit error is recoverable. Always forward the canonical PC/SC variant name verbatim and let the backend pick the response:
- Recoverable (e.g.
RemovedCard,ResetCard,UnpoweredCard,UnresponsiveCard,NotTransacted,CommError): backend replies withnfc_await_card. Wait for the user to re-tap, reconnect to the card with a fresh handle, and sendnfc_card_present. The backend resends the APDU that failed on the new card. - Fatal (anything else): backend closes the session with an
errormessage.
Some Android devices (notably Samsung phones with multiple wallets installed) briefly drop the NFC link to show a wallet picker; this typically surfaces as RemovedCard or NotTransacted and is the main scenario the recoverable path is designed for.
sequenceDiagram
participant C as client
participant B as backend
B->>C: nfc_apdu
Note over C: transmit fails (e.g. RemovedCard)
C->>B: nfc_rapdu error + reason="RemovedCard"
B->>C: nfc_await_card
Note over C: wait for re-tap, reconnect
C->>B: nfc_card_present
B->>C: nfc_apdu (resend)
When the NFC handover completes, the backend transitions to BLE and sends ble_exchange.
Requesting fields
Section titled “Requesting fields”Both engagement messages include a namespaces object that tells the backend which mDL fields to ask the holder for. Keys are ISO 18013-5 namespaces (e.g. "org.iso.18013.5.1"); values are arrays of element identifiers within that namespace.
"namespaces": { "org.iso.18013.5.1": ["family_name", "given_name", "birth_date"]}The document type is implicitly org.iso.18013.5.1.mDL. The backend always sets intent_to_retain to false on the underlying DeviceRequest.
BLE exchange
Section titled “BLE exchange”After engagement, the backend sends a ble_exchange message describing how to connect to the holder’s device:
{ "type": "step_request", "op": "ble_exchange", "params": { "modes": [ { "role": "central", "service_uuid": "a1b2c3d4-...", "characteristics": { "state": { "uuid": "00000001-...", "operations": ["write_without_response", "notify"], "permissions": ["writeable", "readable"] }, "c2s": { "uuid": "00000002-...", "operations": ["write_without_response"], "permissions": ["writeable"] }, "s2c": { "uuid": "00000003-...", "operations": ["notify"], "permissions": ["readable"] } } }, { "role": "peripheral", "service_uuid": "e5f6a7b8-...", "characteristics": { "state": { "uuid": "00000005-...", "operations": ["write_without_response", "notify"], "permissions": ["writeable", "readable"] }, "c2s": { "uuid": "00000006-...", "operations": ["write_without_response"], "permissions": ["writeable"] }, "s2c": { "uuid": "00000007-...", "operations": ["notify"], "permissions": ["readable"] }, "ident": { "uuid": "00000008-...", "operations": ["read"], "permissions": ["readable"] } } } ] }}Modes are listed in preference order, with central first because it works with Web Bluetooth. Pick the first mode your app can support, complete BLE setup, then reply with ble_connected.
service_uuid is a per-session value assigned by the holder’s wallet and changes between connections. Each characteristic carries its UUID along with the GATT operations and attribute permissions it requires — drive your BLE stack from those fields rather than hardcoding ISO 18013-5 values. The characteristic UUIDs themselves are fixed constants from the spec and the same for every session; UUID comparisons are case-insensitive. Possible operations values are read, write_without_response, and notify; possible permissions values are readable and writeable.
Central mode (your app connects to the holder)
Section titled “Central mode (your app connects to the holder)”role: "central" — the holder’s wallet advertises a GATT peripheral; your app connects to it.
sequenceDiagram
participant W as wallet
participant O as OS (BLE)
participant C as client
participant B as backend
B->>C: ble_exchange
C->>O: scan for service_uuid
O-->>W: BLE scan
W-->>O: advertisement
O->>C: device found
C->>O: connect + discover + subscribe s2c
O-->>W: GATT setup
C->>B: ble_connected
loop ble_write instructions
B->>C: ble_write
C->>O: write characteristic
O-->>W: GATT write
end
loop data transfer
W-->>O: GATT notification (s2c)
O->>C: notification event
C->>B: ble_notified
end
B->>C: complete
Steps:
- Scan for a device advertising
service_uuid. - Connect and discover services and characteristics.
- Subscribe to notifications on the
s2ccharacteristic. - Send
ble_connectedwithrole: "central". - Execute each
ble_writeinstruction from the backend in order. Decodeparams.datafrom base64, then write the raw bytes to the characteristic identified byparams.char(match by UUID, case-insensitive). - Forward each
s2cnotification — encode the raw bytes as base64 and send asble_notified. - When
completearrives, close the WebSocket.
Web Bluetooth (navigator.bluetooth) supports central mode.
Peripheral mode (holder connects to your app)
Section titled “Peripheral mode (holder connects to your app)”role: "peripheral" — your app advertises a GATT service; the holder’s wallet connects to it. This requires a native BLE stack; Web Bluetooth does not support peripheral mode.
sequenceDiagram
participant W as wallet
participant O as OS (BLE)
participant C as client
participant B as backend
B->>C: ble_exchange
C->>O: advertise service_uuid
O-->>W: advertisement
W-->>O: connect
O->>C: wallet connected
C->>B: ble_connected
par reads, writes, and backend instructions
W-->>O: read characteristic
O->>C: read event
C->>B: ble_read_request
B->>C: ble_read_response
C->>O: respond with data
O-->>W: characteristic value
and
W-->>O: write characteristic
O->>C: write event
C->>B: ble_written
and
B->>C: ble_write
C->>O: notify s2c
O-->>W: GATT notification
end
B->>C: complete
- Advertise a GATT service with
service_uuidcontaining the four characteristics frommode.characteristics(state,c2s,s2c,ident). For each, use theuuid,operations, andpermissionsfields straight from the message to configure the characteristic on your BLE stack — theble_exchangemessage is the source of truth, so you don’t need to hardcode ISO 18013-5 property tables. - Wait for the wallet to connect (the first non-power BLE event on the service — a subscription update, characteristic read, or write), then send
ble_connectedwithrole: "peripheral". - For each read from the holder: send
ble_read_requestand wait for theble_read_response. Decodeparams.datafrom base64 and return the bytes to the holder. The backend replies immediately — no other WS messages arrive in between. Onlyidentreads carry meaningful data; all other reads return empty bytes. - For each write from the holder: forward it immediately as
ble_written(base64-encode the written bytes). - Execute each
ble_writeinstruction from the backend in order (decodeparams.datafrom base64 before writing to the characteristic). - When
completearrives, close the WebSocket.
Message reference
Section titled “Message reference”Client → backend
Section titled “Client → backend”engage
Section titled “engage”Direction: Client → backend · First message
Send this immediately after the WebSocket opens. Declares the requested namespaces. Transport is not declared here — it’s determined by the follow-up event you send next (qr_scanned for QR or nfc_card_present for NFC). A client may legitimately listen for both at once and let the holder pick.
{ "type": "step_result", "op": "engage", "status": "ok", "namespaces": { "org.iso.18013.5.1": ["family_name", "given_name", "birth_date"] }}| Field | Type | Description |
|---|---|---|
namespaces | object | Fields to request from the holder. See Requesting fields. |
qr_scanned
Section titled “qr_scanned”Direction: Client → backend · QR flow only
Send after scanning the holder’s QR code. data is the raw QR string exactly as scanned — pass it through unmodified, including the mdoc: URI prefix. The backend replies with ble_exchange.
{ "type": "step_result", "op": "qr_scanned", "status": "ok", "data": "mdoc:owBjMS4wAYIB..." }| Field | Type | Description |
|---|---|---|
data | string | Raw QR string including mdoc: prefix. |
nfc_card_present
Section titled “nfc_card_present”Direction: Client → backend · NFC flow only
Send when an NFC card is connected and ready to receive APDUs. Sent in two situations: immediately after engage once you’ve connected to the holder’s tap, and after each nfc_await_card instruction once you’ve reconnected to a re-presented card. On the first occurrence the backend creates the handover driver and replies with the first nfc_apdu instruction; on subsequent occurrences it resends the APDU that failed.
{ "type": "step_result", "op": "nfc_card_present", "status": "ok" }nfc_rapdu
Section titled “nfc_rapdu”Direction: Client → backend · Reply to each nfc_apdu instruction
Send after transmitting the APDU to the NFC card. The r is for “response” — the client returns the card’s R-APDU (response APDU) for each C-APDU (command APDU) the backend sent.
On success, base64-encode the card’s raw response bytes and include them as data:
{ "type": "step_result", "op": "nfc_rapdu", "status": "ok", "data": "<base64 RAPDU>" }On any transmit failure, set status: "error" and put the canonical PC/SC error variant name in reason (e.g. "RemovedCard", "NotTransacted" — for Rust kiosks, format!("{err:?}") over a pcsc::Error). The backend classifies the error: recoverable codes (transient card loss, see Handling transient card removal) prompt an nfc_await_card instruction; anything else aborts the session.
{ "type": "step_result", "op": "nfc_rapdu", "status": "error", "reason": "RemovedCard" }ble_notified
Section titled “ble_notified”Direction: Client → backend · Central mode only
Forward each GATT notification received on the s2c characteristic. Send one message per notification event; do not buffer or combine them.
{ "type": "step_result", "op": "ble_notified", "char": "00000003-A123-48CE-896B-4C76973373E6", "data": "<base64 raw bytes>"}| Field | Type | Description |
|---|---|---|
char | string | UUID of the characteristic the notification arrived on (case-insensitive). |
data | string | Raw notification bytes, base64-encoded. |
ble_written
Section titled “ble_written”Direction: Client → backend · Peripheral mode only
Forward each GATT write received from the holder’s wallet. Send one message per write event.
{ "type": "step_result", "op": "ble_written", "char": "00000006-A123-48CE-896B-4C76973373E6", "data": "<base64 raw bytes>"}| Field | Type | Description |
|---|---|---|
char | string | UUID of the characteristic that was written (case-insensitive). |
data | string | Written bytes, base64-encoded. |
ble_read_request
Section titled “ble_read_request”Direction: Client → backend · Peripheral mode only
Send when the holder’s wallet reads a characteristic. After sending, do not process any further WebSocket messages until you receive ble_read_response — the backend replies immediately and no other messages arrive in between.
{ "type": "step_result", "op": "ble_read_request", "char": "00000008-A123-48CE-896B-4C76973373E6"}ble_connected
Section titled “ble_connected”Direction: Client → backend · Reply to ble_exchange
Send once the wallet is engaged — after scan + connect + discover + subscribe for central mode, or after advertising and seeing the wallet connect for peripheral mode (e.g. a subscription update, read, or write on one of the service’s characteristics). role must match the mode chosen from the ble_exchange modes list. The backend gates all downstream BLE messages on this — no ble_write instructions in central mode, no ble_written / ble_read_request acceptance in peripheral mode — until ble_connected arrives.
On success:
{ "type": "step_result", "op": "ble_connected", "status": "ok", "role": "central" }On failure (BLE setup could not complete):
{ "type": "step_result", "op": "ble_connected", "status": "error", "reason": "BLE scan timed out" }| Field | Type | Description |
|---|---|---|
role | "central" | "peripheral" | The mode chosen from the ble_exchange modes list. |
reason | string | Human-readable failure description. Required when status is "error". |
Backend → client
Section titled “Backend → client”All params.data fields arrive as standard base64 and must be decoded to raw bytes before use with NFC or BLE.
nfc_apdu
Section titled “nfc_apdu”Direction: Backend → client · NFC flow only
Instructs the client to transmit an APDU (C-APDU) to the NFC card. Decode params.data from base64 to get the raw command bytes, send them to the card, then reply with nfc_rapdu.
{ "type": "step_request", "op": "nfc_apdu", "params": { "data": "<base64 C-APDU bytes>" }}nfc_await_card
Section titled “nfc_await_card”Direction: Backend → client · NFC flow only
Sent in response to a recoverable nfc_rapdu error (e.g. RemovedCard). Wait for the holder to re-tap, reconnect to the card with a fresh PC/SC handle, then reply with nfc_card_present. The backend will resend the APDU that failed.
{ "type": "step_request", "op": "nfc_await_card", "params": {} }ble_exchange
Section titled “ble_exchange”Direction: Backend → client · Sent once after engagement
Lists all BLE modes the holder’s device supports. Modes are ordered by preference; pick the first your app can support. Reply with ble_connected once setup is complete.
{ "type": "step_request", "op": "ble_exchange", "params": { "modes": [ { "role": "central", "service_uuid": "a1b2c3d4-...", "characteristics": { "state": { "uuid": "00000001-...", "operations": ["write_without_response", "notify"], "permissions": ["writeable", "readable"] }, "c2s": { "uuid": "00000002-...", "operations": ["write_without_response"], "permissions": ["writeable"] }, "s2c": { "uuid": "00000003-...", "operations": ["notify"], "permissions": ["readable"] } } }, { "role": "peripheral", "service_uuid": "e5f6a7b8-...", "characteristics": { "state": { "uuid": "00000005-...", "operations": ["write_without_response", "notify"], "permissions": ["writeable", "readable"] }, "c2s": { "uuid": "00000006-...", "operations": ["write_without_response"], "permissions": ["writeable"] }, "s2c": { "uuid": "00000007-...", "operations": ["notify"], "permissions": ["readable"] }, "ident": { "uuid": "00000008-...", "operations": ["read"], "permissions": ["readable"] } } } ] }}| Field | Type | Description |
|---|---|---|
role | "central" | "peripheral" | Which BLE role your app takes for this mode. |
service_uuid | string | Per-session UUID from the holder’s device engagement. Changes every connection. |
characteristics.state | object | Spec for the connection-state characteristic. |
characteristics.c2s | object | Spec for the client-to-server data characteristic. |
characteristics.s2c | object | Spec for the server-to-client characteristic (notifications in central role; data writes in peripheral role). |
characteristics.ident | object? | Spec for the IDENT read characteristic. Present only in peripheral role. |
characteristics.<name>.uuid | string | Fixed ISO 18013-5 UUID for the characteristic. |
characteristics.<name>.operations | array | GATT operations the characteristic must support. Values: "read", "write_without_response", "notify". |
characteristics.<name>.permissions | array | Attribute permissions to expose. Values: "readable", "writeable". |
ble_write
Section titled “ble_write”Direction: Backend → client · Central and peripheral mode
Instructs the client to write bytes to a BLE characteristic. Decode params.data from base64 and write the raw bytes to the characteristic identified by params.char (UUID, case-insensitive match against discovered characteristics). No reply is needed. Execute writes in the order received.
These messages are sent only after the backend receives ble_connected.
The final ble_write before complete is always a single 0x02 byte on the state characteristic — the ISO 18013-5 §8.3.3 session-end signal that lets the holder know the exchange is over. In peripheral mode this is a notification (since state has both Notify and Write Without Response properties); in central mode it is a write to the holder’s state characteristic. Treat it like any other ble_write.
{ "type": "step_request", "op": "ble_write", "params": { "char": "00000001-A123-48CE-896B-4C76973373E6", "data": "<base64 raw bytes>" }}ble_read_response
Section titled “ble_read_response”Direction: Backend → client · Peripheral mode only
Immediate reply to a pending ble_read_request. Decode params.data from base64 and return the raw bytes to the BLE read operation.
{ "type": "step_request", "op": "ble_read_response", "params": { "data": "<base64 raw bytes>" }}complete
Section titled “complete”Direction: Backend → client · Terminal message
Verification finished. Close the WebSocket. The outcome payload is described in Outcome. To retrieve full session metadata, call GET /sessions/{id} with your API key.
{ "type": "complete", "outcome": { ... }}Direction: Backend → client · Terminal message
Fatal protocol error. Close the WebSocket. The session resets to its initial state and can be retried by reopening the WebSocket with the same id and client_secret.
{ "type": "error", "message": "unknown op: foo" }Errors are sent for: unknown or out-of-sequence message, missing required field, malformed JSON, NFC retries exhausted.
Outcome
Section titled “Outcome”When verification completes, the backend sends:
{ "type": "complete", "outcome": { "response": { "org.iso.18013.5.1": { "family_name": "Doe", "given_name": "Jane", "birth_date": "1990-03-15" } }, "doc_types": ["org.iso.18013.5.1.mDL"], "issuer_authentication": "Valid", "device_authentication": "Valid", "errors": {}, "warnings": {} }}| Field | Type | Description |
|---|---|---|
response | object | Disclosed fields, keyed by namespace then element identifier. For mDL the namespace is "org.iso.18013.5.1". |
doc_types | string[] | Document types present in the response. |
issuer_authentication | "Valid" | "Invalid" | "Unchecked" | Issuer signature check result. |
device_authentication | "Valid" | "Invalid" | "Unchecked" | Device binding check result. |
errors | object | Fatal issues — non-empty means the credential must be rejected. |
warnings | object | Non-fatal issues (e.g. revocation check temporarily unavailable). Non-empty does not mean the credential is invalid. |
After receiving complete, close the WebSocket. To retrieve the full session result (including presentation metadata), call GET /sessions/{id} with your API key.
Common pitfalls
Section titled “Common pitfalls”Decode base64 before writing to BLE or NFC. All binary payloads from the backend arrive as base64 strings. Write the decoded bytes to the BLE characteristic or NFC card — not the base64 string.
Match ble_write.char case-insensitively. UUIDs from the backend may be uppercase or lowercase depending on how isomdl formats them. Match against your discovered characteristics with a case-insensitive comparison.
Do not process WS messages during ble_read_request. When you send ble_read_request, the backend replies with ble_read_response immediately. Read exactly one WS message after sending ble_read_request — it will be the response. Do not dispatch to your normal message loop.
Handle concurrent BLE events and WS messages in peripheral mode. BLE reads and writes from the holder can arrive while you are also waiting for ble_write instructions from the backend. Use a concurrent event loop (e.g. tokio::select! or async event dispatch) rather than alternating between BLE and WS sequentially.
Session TTL starts at creation. The 10-minute expiry clock starts when you call POST /sessions. If engagement takes more than a few minutes (slow QR scan, user delay), create the session immediately before presenting the QR code or starting NFC, not at app startup.
Forward PC/SC errors verbatim — let the backend decide what’s recoverable. PC/SC error codes are standardized, so the “is this recoverable?” classification lives on the backend, not in your client. On any transmit failure, send nfc_rapdu with the canonical PC/SC variant name in reason (e.g. "RemovedCard", "NotTransacted"). The backend either replies with nfc_await_card (recoverable — wait, reconnect, send nfc_card_present) or closes the session (fatal). Some Android devices, notably Samsung phones with multiple wallets installed, surface the wallet-picker disconnect as RemovedCard or NotTransacted — these go through the recoverable path automatically.