The device endpoint accepts connections from NFC devices that provide tag data.
Pairing
A device authenticates with its own credential, obtained once by presenting the PIN shown on the kiosk (tray, logs, and the pairing QR).
The QR printed at startup carries where to pair, the agent's key pin and the PIN:
davi-pair://[host]:9470/?spki=sha256%2F47DE…&code=123456&name=Davi%20NFC%20Agent
Read it off the kiosk screen, pin the TLS connection to spki, then:
POST https://[host]:9470/pair?pin=123456
Content-Type: application/json
{"deviceName": "Operator iPhone", "platform": "ios"}
Pairing is served from the agent's port, which serves the certificate spki
covers. Over a cleartext connection it is refused with 426 Upgrade Required
from anything but loopback. Port 9472 is the cleartext bootstrap listener; it
serves the setup page and the certificate authority, and does not pair.
{
"deviceID": "6f1c…",
"deviceToken": "kQ8x…",
"publicKeyPin": "sha256/47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=",
"agentPort": 9470
}
Store all three. deviceToken is presented on every later connection, as
?secret= or Authorization: Bearer. publicKeyPin is how the device
recognizes this agent again. See TLS & Certificates.
The token is shown once. The agent keeps only its hash, so a lost token means pairing again.
Each device holds its own credential, so one can be revoked from the tray under Paired Devices without disturbing the others. The shared API secret still works for devices configured with it, but rotating it locks out every device configured with it, each on its next connection. Per-device tokens avoid that.
Wrong PINs lock pairing after five attempts until the agent restarts.
Requiring pairing
By default a device may also present the shared API secret. It remains so that upgrading strands nothing.
-require-paired-devices (or Require pairing in the tray, or
DAVI_NFC_REQUIRE_PAIRED_DEVICES=1) withdraws it: only a credential issued at
pairing admits a device. Turn it on once the devices you care about have
paired. With none paired, every device connection is refused.
Browser consoles are unaffected: a browser has no way to pair and is gated by the origin allowlist instead. This setting governs the device endpoint only.
The tray toggle takes effect immediately, so the policy can be tried against a real device without restarting.
Connecting
Connect via WebSocket with device mode:
wss://[host]:9470/ws?mode=device
Offer the davi-nfc-device.v1 subprotocol during the upgrade. If the agent
echoes it back, it supports the hello handshake below. If it echoes nothing,
it predates versioning: fall back to Legacy Registration.
const ws = new WebSocket('wss://host:9470/ws?mode=device', ['davi-nfc-device.v1']);
const version = ws.protocol === 'davi-nfc-device.v1' ? 1 : 0;
Device Registration
Send hello as the first frame. It carries the protocol version alongside the
registration fields, so setup costs one round trip:
{
"id": "req_1",
"type": "hello",
"payload": {
"protocolVersion": 1,
"deviceName": "My Device",
"platform": "ios",
"appVersion": "1.0.0",
"capabilities": {
"canRead": true,
"canWrite": false,
"nfcType": "corenfc",
"canTransceive": false,
"canTransceiveRaw": false,
"canLock": false,
"deviceType": "smartphone",
"supportedTagTypes": ["NTAG", "MIFARE Ultralight"]
},
"metadata": {
"userAgent": "..."
}
}
}
Device Capabilities
canRead, canWrite, and nfcType are the original v0 declaration and are
always sent. The rest are v1 additions: omit any that do not apply, and a
device declaring nothing extra sends exactly the v0 object.
The capabilities object itself is optional, and omitting it is not the same as
sending one of all falses. A device that sends the object is taken at its word:
a field it sets to false refuses that operation for every tag the device
holds, since a bridge that cannot carry an operation cannot carry it for any
tag. A device that omits the object has declared nothing about itself, so
requests go out and it answers them.
Per-tag capabilities on tagScanned are read the same way, and take precedence
for the tag they describe. See Tag Capabilities.
| Field | Meaning |
|---|---|
canRead / canWrite | Device can read / write NDEF |
nfcType | Radio technology or library: nfca, isodep, corenfc, webnfc, … |
canTransceive | APDU-level exchange: Android IsoDep.transceive, iOS sendCommand, PN532 InDataExchange |
canTransceiveRaw | Framing-level exchange: Android NfcA.transceive, PN532 InCommunicateThru |
canLock | Device can make a tag read-only |
deviceType | Free-form kind, e.g. smartphone, pn532-serial. Defaults to smartphone |
supportedTagTypes | Tag families this device handles, e.g. ["MIFARE Classic", "NTAG"] |
maxBaudRate | Maximum baud rate in bps, for serial-attached readers |
maxHoldMs | How long a tag stays available for work after being reported. Omit for open-ended |
How long a tag stays available
A reader holding a tag in its field can act on it until it leaves, so it omits
maxHoldMs and the agent may take as long as it likes. A phone need not be so
lucky: CoreNFC connects a tag for roughly twenty seconds and cannot renew that,
so an iOS device declares "maxHoldMs": 20000, and everything the agent does
with the tag must fit inside it.
The deadline for a particular tag is the arrival of its tagScanned plus
maxHoldMs. That sum is optimistic, since the tag was already connected when
the message was sent, so leave margin rather than treating it as exact. A hold
that ends early, because the tag was pulled or the session was invalidated,
arrives as tagRemoved like any other departure.
The field is advisory. A device that declares nothing places no bound. Use it to decide what to attempt; do not refuse a device that omitted it.
Capability is a set rather than a level: a PN532 reader can declare
canTransceive and MIFARE Classic support that an iPhone cannot, while the
iPhone declares NDEF abilities the reader lacks. Declare what is true and let
the agent decide what it can use.
Response:
{
"id": "req_1",
"type": "helloResponse",
"success": true,
"payload": {
"protocolVersion": 1,
"deviceID": "dev_abc123",
"serverInfo": {
"version": "1.0.0",
"supportedNFC": ["ndef", "mifare"]
}
}
}
protocolVersion in the response is what both sides will speak. It is never
higher than the version the device asked for: a device declaring a version newer
than the agent implements is answered at the agent's maximum rather than
refused. Devices should read this field rather than assume their request was
honoured.
platform is a free-form identifier describing the device, such as ios,
android, web, node, or pn532-serial. Nothing in the agent branches on
it; it is reported back in the console and the device list. Omit it and the
agent records unknown.
Legacy Registration (v0)
Devices predating versioning send registerDevice as the first frame and get
registerDeviceResponse back. This exchange is unchanged and remains supported;
the payload is identical to hello minus protocolVersion.
{
"type": "registerDevice",
"payload": {
"deviceName": "My Device",
"platform": "ios",
"appVersion": "1.0.0",
"capabilities": { "canRead": true, "canWrite": false, "nfcType": "corenfc" }
}
}
The first frame's type selects the dialect, so the subprotocol offer is a hint
rather than a commitment: a device that offers nothing but sends hello is
still served at v1.
Messages from Device
Tag Scanned
Send when a tag is detected:
{
"type": "tagScanned",
"payload": {
"deviceID": "dev_abc123",
"uid": "04A1B2C3D4E5F6",
"technology": "ISO14443A",
"type": "MIFARE Classic 1K",
"scannedAt": "2024-10-06T12:34:56Z",
"ndefMessage": {
"records": [
{
"recordType": "text",
"content": "Hello, NFC!",
"language": "en"
}
]
},
"capabilities": {
"memorySize": 1024,
"maxNdefSize": 716,
"tagFamily": "MIFARE Classic",
"supportsNdef": true
}
}
}
capabilities (v1, optional) is what the device determined about this specific
tag. See Tag Capabilities for the field list. Omit it and
the agent infers them from type, which is all a v0 device allows. Declared
values win over inference, except that operations the bridge cannot yet route
(canWrite, canTransceive, canLock) are reported as false whatever the
device claims.
Non-NFC scans (QR and barcodes)
A camera is a device like any other: it decodes a QR or barcode itself and reports the value, exactly as a phone decodes NDEF off an NFC tag and reports records rather than raw RF. The agent never receives images or frames.
The agent does not model optical codes; it carries the scan and stays out of the way. Two things make that work:
- A non-hex UID is carried verbatim. The agent normalizes a hex NFC serial to its canonical colon form, but a UID that is not hex is not an NFC serial, so it is passed through byte-for-byte. A consumer keys on the exact value that was scanned.
- Read-only falls out of the device's own capabilities. A camera registers
canWrite: false(and no lock or transceive), so the agent already refuses those operations. No special-casing is needed.
Report the scan as an ordinary tagScanned frame. Put the decoded value where a
consumer already looks: a card URL as a uri record (davi keys on the
/c/{identifier} path), and any stable non-empty uid. Nothing new is needed
on the wire:
{
"type": "tagScanned",
"payload": {
"deviceID": "dev_cam01",
"uid": "https://davi.social/c/QR-ABC123",
"technology": "qr",
"type": "qr_card",
"ndefMessage": {
"records": [
{ "recordType": "uri", "content": "https://davi.social/c/QR-ABC123" }
]
}
}
}
uid must be non-empty, but its exact value is the device's choice — a consumer
that keys on the URL record uses uid only as a fallback. technology and
type are free-form and reported straight back to clients; the agent branches on
neither. A device that only scans codes registers with deviceType: "camera",
canWrite: false. Send tagRemoved when a code leaves the frame, as for any
tag.
Goodbye
Send before disconnecting deliberately (v1). The agent acknowledges with a normal WebSocket close and records a departure rather than a lost device:
{
"type": "goodbye",
"payload": {
"deviceID": "dev_abc123",
"reason": "user stopped scanning"
}
}
reason is optional and only reaches the agent's logs. Without a goodbye the
agent classifies the disconnect from the close handshake: a normal or
going-away close is still a clean departure. Anything else, an abrupt reset or
a dead radio, is reported as a dropped device.
Tag Removed
Send when a tag leaves the reader:
{
"type": "tagRemoved",
"payload": {
"deviceID": "dev_abc123",
"uid": "04A1B2C3D4E5F6",
"removedAt": "2024-10-06T12:35:00Z"
}
}
Device Heartbeat
Keep connection alive:
{
"type": "deviceHeartbeat",
"payload": {
"deviceID": "dev_abc123",
"timestamp": "2024-10-06T12:35:30Z"
}
}
Write Response
Respond to a write request from the server. Required: the agent holds the client's request open until this arrives, the device disconnects, or 20 seconds pass:
{
"type": "deviceWriteResponse",
"payload": {
"requestID": "req_xyz789",
"success": false,
"error": "tag is read-only",
"errorCode": "READ_ONLY"
}
}
errorCode is optional but preferred: it lets the agent classify the failure
instead of parsing error. Use any code from NFC errors.
Messages to Device
Write Request
The agent asks the device to write the tag it is currently holding. A write is routed to a device when no hardware reader has a card present and that device reported the most recent scan.
{
"type": "deviceWriteRequest",
"payload": {
"requestID": "req_xyz789",
"deviceID": "dev_abc123",
"tagUID": "04:A1:B2:C3",
"lock": false,
"idempotencyKey": "req_xyz789",
"ndefBytes": "0QEOVAJlbkhlbGxvLCBORkMh",
"ndefMessage": {
"records": [
{
"recordType": "text",
"content": "Hello!",
"language": "en"
}
]
}
}
}
| Field | Description |
|---|---|
ndefBytes | The encoded NDEF message, base64 in transit. Authoritative where it and ndefMessage disagree: prefer it if the device can write raw NDEF |
ndefMessage | The same message as records, for APIs like Web NFC that only accept records. Cannot express every record type faithfully |
tagUID | UID the agent expects to be in the field. Report TAG_REMOVED if a different tag is present |
lock | Make the tag permanently read-only after a successful write. Irreversible |
idempotencyKey | Identifies the logical write |
On idempotencyKey: a device that has already applied a given key must
report the previous outcome rather than write again. The same request can arrive
twice: the agent sends a write, the device applies it, and the response is lost
to a dropped connection. Without the check, the retry writes a second time.
Lock-only requests. A client lockRequest arrives as the same frame with
lock: true and no ndefBytes or ndefMessage, since the protocol has one
tag-modifying frame, not two. Lock the tag as it stands and write nothing.
Answer with deviceWriteResponse as for any other write.
Transceive Request
The agent asks the device to exchange raw data with the tag it is holding. Sent
only to devices that declared canTransceive, and only for tags that support
it: the NDEF path handles ordinary reads and writes.
{
"type": "deviceTransceiveRequest",
"payload": {
"requestID": "req_abc",
"deviceID": "dev_abc123",
"tagUID": "04:A1:B2:C3",
"data": "AKQEAA==",
"raw": false,
"timeoutMs": 5000
}
}
| Field | Description |
|---|---|
data | Command bytes, base64 in transit |
raw | false for APDU-level exchange (IsoDep.transceive, iOS sendCommand, PN532 InDataExchange); true for framing-level (NfcA.transceive, PN532 InCommunicateThru) |
tagUID | UID the agent expects in the field. Report TAG_REMOVED if a different tag is present |
timeoutMs | Bound for this single exchange |
Respond with deviceTransceiveResponse:
{
"type": "deviceTransceiveResponse",
"payload": {
"requestID": "req_abc",
"success": true,
"data": "kAA="
}
}
There is no connect/disconnect pair around a transceive: a tag session is
already delimited by tagScanned and tagRemoved, and on phones the OS owns
the session.
This costs one network round trip per command. Reading NDEF off a MIFARE Classic 1K is ~60 exchanges, seconds of tag-in-field time over WiFi, against a single message on the NDEF path. Use the command channel for what genuinely needs it (DESFire, ISO-DEP applets, capability probing), not as a general read path. iOS also enforces its own session timeouts, so long sequences are more likely to fail there.
mDNS Discovery
The agent advertises via mDNS/Bonjour:
- Service Type:
_nfc-device._tcp - Domain:
local.
Devices can discover the agent on the local network without knowing the IP address.