Skip to content

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.

First, create a session using your API key:

POST https://api.verify.spruceid.com/sessions
Authorization: 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.

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.

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:

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"]
}
}

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.

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" }

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 with nfc_await_card. Wait for the user to re-tap, reconnect to the card with a fresh handle, and send nfc_card_present. The backend resends the APDU that failed on the new card.
  • Fatal (anything else): backend closes the session with an error message.

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.

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.

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:

  1. Scan for a device advertising service_uuid.
  2. Connect and discover services and characteristics.
  3. Subscribe to notifications on the s2c characteristic.
  4. Send ble_connected with role: "central".
  5. Execute each ble_write instruction from the backend in order. Decode params.data from base64, then write the raw bytes to the characteristic identified by params.char (match by UUID, case-insensitive).
  6. Forward each s2c notification — encode the raw bytes as base64 and send as ble_notified.
  7. When complete arrives, 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
  1. Advertise a GATT service with service_uuid containing the four characteristics from mode.characteristics (state, c2s, s2c, ident). For each, use the uuid, operations, and permissions fields straight from the message to configure the characteristic on your BLE stack — the ble_exchange message is the source of truth, so you don’t need to hardcode ISO 18013-5 property tables.
  2. 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_connected with role: "peripheral".
  3. For each read from the holder: send ble_read_request and wait for the ble_read_response. Decode params.data from base64 and return the bytes to the holder. The backend replies immediately — no other WS messages arrive in between. Only ident reads carry meaningful data; all other reads return empty bytes.
  4. For each write from the holder: forward it immediately as ble_written (base64-encode the written bytes).
  5. Execute each ble_write instruction from the backend in order (decode params.data from base64 before writing to the characteristic).
  6. When complete arrives, close the WebSocket.

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"]
}
}
FieldTypeDescription
namespacesobjectFields to request from the holder. See Requesting fields.

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..." }
FieldTypeDescription
datastringRaw QR string including mdoc: prefix.

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" }

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" }

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>"
}
FieldTypeDescription
charstringUUID of the characteristic the notification arrived on (case-insensitive).
datastringRaw notification bytes, base64-encoded.

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>"
}
FieldTypeDescription
charstringUUID of the characteristic that was written (case-insensitive).
datastringWritten bytes, base64-encoded.

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"
}

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" }
FieldTypeDescription
role"central" | "peripheral"The mode chosen from the ble_exchange modes list.
reasonstringHuman-readable failure description. Required when status is "error".

All params.data fields arrive as standard base64 and must be decoded to raw bytes before use with NFC or BLE.

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>"
}
}

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": {} }

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"]
}
}
}
]
}
}
FieldTypeDescription
role"central" | "peripheral"Which BLE role your app takes for this mode.
service_uuidstringPer-session UUID from the holder’s device engagement. Changes every connection.
characteristics.stateobjectSpec for the connection-state characteristic.
characteristics.c2sobjectSpec for the client-to-server data characteristic.
characteristics.s2cobjectSpec for the server-to-client characteristic (notifications in central role; data writes in peripheral role).
characteristics.identobject?Spec for the IDENT read characteristic. Present only in peripheral role.
characteristics.<name>.uuidstringFixed ISO 18013-5 UUID for the characteristic.
characteristics.<name>.operationsarrayGATT operations the characteristic must support. Values: "read", "write_without_response", "notify".
characteristics.<name>.permissionsarrayAttribute permissions to expose. Values: "readable", "writeable".

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>"
}
}

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>"
}
}

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.

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": {}
}
}
FieldTypeDescription
responseobjectDisclosed fields, keyed by namespace then element identifier. For mDL the namespace is "org.iso.18013.5.1".
doc_typesstring[]Document types present in the response.
issuer_authentication"Valid" | "Invalid" | "Unchecked"Issuer signature check result.
device_authentication"Valid" | "Invalid" | "Unchecked"Device binding check result.
errorsobjectFatal issues — non-empty means the credential must be rejected.
warningsobjectNon-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.

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.