NFC Agent

Device API

Feed the agent from a phone, a browser or your own hardware over WebSocket.

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.

FieldMeaning
canRead / canWriteDevice can read / write NDEF
nfcTypeRadio technology or library: nfca, isodep, corenfc, webnfc, …
canTransceiveAPDU-level exchange: Android IsoDep.transceive, iOS sendCommand, PN532 InDataExchange
canTransceiveRawFraming-level exchange: Android NfcA.transceive, PN532 InCommunicateThru
canLockDevice can make a tag read-only
deviceTypeFree-form kind, e.g. smartphone, pn532-serial. Defaults to smartphone
supportedTagTypesTag families this device handles, e.g. ["MIFARE Classic", "NTAG"]
maxBaudRateMaximum baud rate in bps, for serial-attached readers
maxHoldMsHow 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"
        }
      ]
    }
  }
}
FieldDescription
ndefBytesThe encoded NDEF message, base64 in transit. Authoritative where it and ndefMessage disagree: prefer it if the device can write raw NDEF
ndefMessageThe same message as records, for APIs like Web NFC that only accept records. Cannot express every record type faithfully
tagUIDUID the agent expects to be in the field. Report TAG_REMOVED if a different tag is present
lockMake the tag permanently read-only after a successful write. Irreversible
idempotencyKeyIdentifies 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
  }
}
FieldDescription
dataCommand bytes, base64 in transit
rawfalse for APDU-level exchange (IsoDep.transceive, iOS sendCommand, PN532 InDataExchange); true for framing-level (NfcA.transceive, PN532 InCommunicateThru)
tagUIDUID the agent expects in the field. Report TAG_REMOVED if a different tag is present
timeoutMsBound 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.