The NFC Agent serves both roles from a single server on one port:
| Server | Port | Purpose |
|---|---|---|
| Agent Server | 9470 | Serves both NFC devices (hardware readers, smartphones, browsers) via /ws?mode=device and client applications via /ws |
| CA Bootstrap | 9472 | Serves TLS certificates for device setup |
The agent server port is configurable via -device-port (default 9470).
Both listeners are plugins the program registers, so what an agent serves is decided by the build rather than fixed here: this page describes the shipped binary. See Custom Builds for the Go API behind it.
The device endpoint accepts connections from NFC devices that provide tag data.
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.
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.
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;
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": "..."
}
}
}
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 |
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.
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.
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.
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:
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.
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.
Send when a tag leaves the reader:
{
"type": "tagRemoved",
"payload": {
"deviceID": "dev_abc123",
"uid": "04A1B2C3D4E5F6",
"removedAt": "2024-10-06T12:35:00Z"
}
}
Keep connection alive:
{
"type": "deviceHeartbeat",
"payload": {
"deviceID": "dev_abc123",
"timestamp": "2024-10-06T12:35:30Z"
}
}
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.
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.
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.
The agent advertises via mDNS/Bonjour:
_nfc-device._tcplocal.Devices can discover the agent on the local network without knowing the IP address.
The agent provides NFC data to client applications on the same port as devices
(plain /ws, without the ?mode=device query). This is the agent server port
(default 9470, configurable via -device-port).
Connect via WebSocket:
const ws = new WebSocket('ws://localhost:9470/ws');
With API secret:
const ws = new WebSocket('ws://localhost:9470/ws?secret=your-secret');
A client on the agent’s own host presents the secret like any other. See The loopback bypass for the setting that exempts it.
409 Conflict until first disconnects{
"type": "deviceStatus",
"payload": {
"connected": true,
"message": "Device connected",
"cardPresent": false
}
}
When a card is detected and read:
{
"type": "tagData",
"payload": {
"uid": "04A1B2C3D4E5F6",
"type": "MIFARE Classic 1K",
"technology": "ISO14443A",
"scannedAt": "2024-10-06T12:34:56Z",
"deviceID": "dev_abc123",
"capabilities": {
"canRead": true,
"canWrite": true,
"canLock": true,
"maxNdefSize": 716,
"tagFamily": "MIFARE Classic",
"supportsNdef": true
},
"message": {
"type": "ndef",
"records": [
{
"tnf": 1,
"type": "text",
"content": "Hello, NFC!",
"language": "en",
"payload": "AmVuSGVsbG8sIE5GQyE="
}
]
},
"text": "Hello, NFC!",
"err": null
}
}
Payload Fields:
| Field | Description |
|---|---|
uid |
Card unique identifier (hex string). For a non-NFC scan (a QR or barcode), the raw value the device reported, carried verbatim. See Non-NFC scans |
type |
Card type: MIFARE Classic 1K, MIFARE Classic 4K, DESFire, MIFARE Ultralight, NTAG213, NTAG215, NTAG216, NTAG424, Type4, FeliCa. Free-form for a non-NFC scan (whatever the device reported). A DESFire reports one type across its generations; capabilities.tagFamily names the generation |
technology |
NFC technology standard (ISO14443A, ISO14443B, etc.), or whatever the device reported for a non-NFC scan |
scannedAt |
ISO 8601 timestamp |
deviceID |
The paired device that scanned the tag. Omitted when the agent’s own hardware reader read it. That is the only reader deviceStatus describes, so a client holding a tag can tell whether that status has anything to say about it |
capabilities |
What the tag supports. See Tag Capabilities |
message |
Structured NDEF message data. Absent when the tag holds none, see Identity-only scans |
text |
Quick access to first text record |
err |
Error message or null on success |
A readable tag holding no NDEF message scans normally: err: null, no
message key, text: "", and capabilities.supportsNdef false. This covers a
DESFire whose NDEF file requires keys the agent does not hold, a MIFARE Classic
not using default keys, and every FeliCa: the agent reads a FeliCa’s IDm and
reports technology: "ISO18092", but its command set is not implemented, so
transit cards and access badges scan for their identity alone.
Read capabilities.supportsNdef to distinguish it from a failed read, which
sets err:
{
"uid": "04A1B2C3D4E5F6",
"type": "DESFire",
"technology": "ISO14443A",
"capabilities": { "canRead": true, "supportsNdef": false },
"text": "",
"err": null
}
A request that asks such a tag for its message is answered with NO_PAYLOAD.
NDEF Message Structure:
{
"type": "ndef",
"records": [
{
"tnf": 1,
"type": "text",
"content": "Decoded text",
"language": "en",
"payload": "AmVuRGVjb2RlZCB0ZXh0"
}
]
}
tnf: Type Name Format (0x01 = Well Known)type: record type, human-readable. One of text, uri, mime,
smartposter, aar, external, and so on. Not the raw NFC Forum type bytecontent: the record’s decoded value, whatever its type. The text of a text
record, the URI of a URI record. One field rather than one per type, since a
record carries a single value and type beside it already says which kind.
Omitted for a record with nothing decodablelanguage: language code, text records onlyid: record ID, when the record carries onepayload: the raw record payload, base64-encoded. This is the record’s bytes
as they sit on the tag, not the decoded value. A text record’s payload leads
with a status byte and the language code, which is why it does not simply
base64-decode to contentThe write direction uses these same names (see Write Request), so a record read from one tag can be written back to another unchanged.
All client messages support an optional id field for request/response correlation.
Write NDEF data to a card (complete overwrite):
{
"id": "req_1",
"type": "writeRequest",
"payload": {
"records": [
{
"type": "text",
"content": "Hello, NFC!",
"language": "en"
}
]
}
}
Multiple records:
{
"id": "req_2",
"type": "writeRequest",
"payload": {
"records": [
{
"type": "text",
"content": "Hello, NFC!",
"language": "en"
},
{
"type": "uri",
"content": "https://example.com"
}
]
}
}
Record Fields:
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | No | Record type (see below). Defaults to text. |
content |
string | Varies | Primary value: text, URI, domain, package name, etc. |
language |
string | No | ISO language code for text/smartposter (default: en) |
mimeType |
string | No | Media type for mime records |
title |
string | No | Display title for smartposter records |
payload |
bytes (base64) | No | Raw bytes for mime, vcard, external, raw |
tnf |
number | No | Type Name Format (0–7) for raw records |
typeBytes |
bytes (base64) | No | NDEF type bytes for raw records |
id |
bytes (base64) | No | Optional record ID for raw records |
Supported type values:
type |
Fields used | Notes |
|---|---|---|
text |
content, language |
Default when type omitted |
uri / url |
content |
Prefix is auto-abbreviated to save tag space |
mailto / email, tel, sms, geo |
content |
URI shortcut; scheme prepended if absent |
smartposter |
content (URI), title, language |
“Tap to open title”: URI + label |
mime |
mimeType, payload (or content) |
Arbitrary MIME media record |
vcard |
content or payload |
Contact card (text/vcard MIME) |
external |
content (domain:type), payload |
NFC Forum external type |
aar |
content (package name) |
Android Application Record (app launch) |
empty / erase |
none | Empty record: blanks/formats the tag (reversible) |
raw |
tnf, typeBytes, id, payload |
Fully custom record |
WiFi credentials can be written as a mime record with mimeType set to
application/vnd.wfa.wsc and a WSC-formatted payload.
Success:
{
"id": "req_1",
"type": "writeResponse",
"success": true,
"payload": {
"message": "Write operation completed successfully",
"uid": "04A1B2C3D4E5F6",
"tagType": "MIFARE Ultralight",
"bytesWritten": 28,
"verified": true,
"attempts": 1
}
}
The agent confirms every write before reporting success: it checks the encoded message against the tag’s capacity, retries transient failures, and reads the data back to verify it landed.
Success Payload Fields:
| Field | Type | Description |
|---|---|---|
message |
string | Human-readable status |
uid |
string | UID of the tag that was written |
tagType |
string | Detected tag type |
bytesWritten |
number | Size of the encoded NDEF message written |
verified |
bool | true when the write was confirmed by reading it back |
attempts |
number | Number of write attempts before success |
locked |
bool | true when the tag was made read-only (see below) |
A write that cannot be confirmed (verification mismatch after retries) returns an
error response rather than a success: success: true means the data is on the
tag. A response with verified: false only occurs if verification was explicitly
disabled by the agent.
Exchange raw bytes with the tag currently present. Command and response are base64 in transit, matching how the device protocol carries byte slices.
{
"id": "req_3",
"type": "transceiveRequest",
"payload": {
"data": "/8oAAAA=",
"raw": false
}
}
| Field | Type | Required | Description |
|---|---|---|---|
data |
bytes (base64) | Yes | Command bytes to send |
raw |
bool | No | Framing-level exchange (NfcA.transceive, InCommunicateThru) instead of APDU-level (IsoDep.transceive, InDataExchange) |
Response:
{
"id": "req_3",
"type": "transceiveResponse",
"success": true,
"payload": { "data": "BKKzxNXmgJAA" }
}
The request is routed like a write: to the remote device holding a tag when no hardware reader has a card present, otherwise to the reader.
A tag answering with an error status word is still success: true, because the
exchange happened, and interpreting SW1SW2 is the caller’s job. success is
false only when the exchange itself could not be performed.
Gated behind the raw APDU channel. The channel that carries raw exchanges is off by default and refuses one with
RAW_CHANNEL_DISABLEDuntil an operator opens it — on the command line with-allow-raw-apdu, from the tray’s Allow Raw APDU Channel toggle, or in the Control Center. A raw command reaches the tag unmodified and can burn OTP bits or lock a tag permanently, and the agent can neither recognise nor undo that, so opening the channel is a deliberate step.Refused in read-only mode. The channel being open is not enough: the agent cannot tell a
SELECTfrom a write to a configuration page, so a raw exchange is treated as a write and also refused withREAD_ONLYwhile the reader is read-only. The mode is checked first, so its refusal is the one you see when both apply.
Accepts an optional deviceID. See Naming the tag.
Every tagData broadcast includes a capabilities object describing what the
present tag supports, so a client can gate its UI (show “lock”/”password” only
when supported, render a capacity meter, etc.) without a round-trip.
{
"canRead": true,
"canWrite": true,
"canTransceive": false,
"canLock": true,
"isReadOnly": false,
"memorySize": 540,
"maxNdefSize": 504,
"technology": "ISO14443A",
"tagFamily": "NTAG",
"supportsNdef": true,
"supportsPassword": true
}
| Field | Description |
|---|---|
canRead / canWrite |
Whether read / write operations are supported |
canTransceive |
Raw APDU transceive supported |
canLock |
Tag can be made permanently read-only |
isReadOnly |
Tag is already locked (omitted when false) |
memorySize |
Total memory in bytes (omitted when unknown) |
maxNdefSize |
Maximum NDEF message size in bytes (omitted when unknown) |
tagFamily |
MIFARE Classic, DESFire, DESFire EV1, DESFire EV2, DESFire EV3, NTAG, MIFARE Ultralight, Type 4, FeliCa, … |
supportsNdef |
Tag supports NDEF |
supportsPassword |
Tag supports simple password protection (NTAG21x PWD/PACK) |
canWrite, canLock and canTransceive describe what the agent will actually
do, not just what the tag is built for: they are reported false while the agent
is in read-only mode, and, for a tag held by a remote device, false unless
that device declared the operation and is still connected. A capability the
agent would refuse is never advertised.
Query on demand: to fetch capabilities without waiting for the next scan,
send a capabilitiesRequest:
{
"id": "req_cap",
"type": "capabilitiesRequest"
}
Response (type: "capabilitiesResponse"):
{
"id": "req_cap",
"type": "capabilitiesResponse",
"success": true,
"payload": {
"capabilities": { "canWrite": true, "canLock": true, "supportsPassword": true, "maxNdefSize": 504 }
}
}
The query is routed like a write: to the device holding a tag when no hardware
reader has a card, otherwise to the reader. For a device-held tag it is answered
from what the device declared at the scan, with no round trip, so it costs
nothing to ask. Accepts an optional deviceID; see
Naming the tag.
If nothing is holding a tag, success is false with NO_CARD.
Locking is irreversible: once a tag is made read-only it can never be written again. Only tags that support locking (e.g. NTAG, MIFARE Ultralight) can be locked; others return an error.
Write and lock in one step: add "lock": true to a write request:
{
"id": "req_1",
"type": "writeRequest",
"payload": {
"lock": true,
"records": [{ "type": "uri", "content": "https://example.com" }]
}
}
The write response then includes "locked": true.
Lock an already-written tag: send a lockRequest:
{
"id": "req_9",
"type": "lockRequest"
}
Response (type: "lockResponse"):
{
"id": "req_9",
"type": "lockResponse",
"success": true,
"payload": {
"message": "Lock operation completed successfully",
"uid": "04A1B2C3D4E5F6",
"tagType": "MIFARE Ultralight",
"locked": true
}
}
If the present tag does not support locking, success is false with an error.
The request is routed like a write: to whichever source is holding the tag it
names. A device receives it as a deviceWriteRequest with lock: true and no
message.
Both take the uid of the tag they apply to, optionally a deviceID, and
optionally an idempotencyKey. See Naming the tag. A lock
cannot be undone, so it is refused rather than redirected when the tag named is
not the tag present.
Refused in read-only mode. Locking is irreversible, so the agent’s read-only mode refuses it with
READ_ONLYon every route. A tag held by a phone included. Writes are refused the same way.
writeRequest, lockRequest, transceiveRequest and capabilitiesRequest all
name the tag they apply to, with the uid from the tagData they are
responding to:
{
"id": "req_10",
"type": "writeRequest",
"payload": {
"uid": "04A1B2C3D4E5F6",
"records": [{ "type": "text", "content": "Hello" }]
}
}
The agent finds whichever source is holding that tag, its own reader or a paired device, and refuses the request if none is. It does not matter which scanned most recently, or whether anything has been scanned since.
Naming the tag is what makes the target deterministic. Resolving instead by whichever source scanned most recently is evaluated when the request arrives, not when the tag was scanned, so a card lifted in between moves the write to a different tag: a payload encoded for one tag lands on another, irreversibly so when the request also locks.
A request whose tag is not present fails with NO_CARD and is never applied
somewhere else. It is retryable: present the tag again and the same request
works. If a tag is present but is not the one named, the failure is
TAG_MISMATCH, which is not retryable. Re-read the tag instead, because the
one now on the reader is a different tag with a different UID.
Naming a device instead. Every tagData carries the deviceID of the
device that scanned it, and a request may name that instead of, or alongside,
the uid:
{ "deviceID": "dev_abc123", "uid": "04A1B2C3D4E5F6" }
Naming a device is decisive: the request goes to that device or fails, never
falling back to the reader, since a tag on the reader is a different tag. Giving
both holds the device to the UID too, so a deviceID remembered from an earlier
scan cannot act on whatever that device is holding now.
Naming neither. A request with no uid and no deviceID is refused with
TAG_NOT_NAMED. A client that genuinely cannot name its tag may opt back into
the old guess, per request:
{ "allowUntargeted": true, "records": [{ "type": "text", "content": "Hello" }] }
It is a request field rather than an agent setting so that one such client carries the risk itself, instead of the operator lowering the guarantee for every client on the agent. A request that does name a tag is still checked.
The bundled JavaScript client fills in
uidfrom the last tag it saw, soclient.write({ records })is already targeted and needs no change.
The reader the operator picked. When a reader is selected in the console or
the tray, the agent works with that one: its scans are the only ones sent, and
its tag is the only one a request can reach. A request naming another reader, or
a UID only another reader has seen, fails as though nothing were holding that
tag, and allowUntargeted resolves among the selected reader alone. What a
client is shown is what it can act on.
Devices that report their own scans, such as paired phones, are not affected: the operator picked which reader to work with, not which phone.
Idempotency. writeRequest and lockRequest also accept an
idempotencyKey, passed through to the device. Reuse it when retrying after a
lost response and a device that already applied it reports the previous outcome
instead of writing again. Omitted, the request id is used, so reusing that on
a retry has the same effect.
Password protection (NTAG PWD/PACK/AUTH0) is not yet available. The
per-tag capability is reported (supportsPassword, true for NTAG21x) and the
API contract below is fixed, but the destructive configuration writes are gated
off pending validation on real hardware: a wrong AUTH0/ACCESS value can
permanently lock a tag. Calls currently return a not-supported error.
Planned request shape (subject to change until enabled):
{
"id": "req_10",
"type": "passwordRequest",
"payload": {
"action": "set", // "set" or "remove"
"password": "01020304", // hex, 4 bytes
"protectRead": false, // false = write-protect only
"startPage": 4 // first protected page (AUTH0)
}
}
Error:
{
"id": "req_1",
"type": "error",
"success": false,
"error": "Write failed: card removed",
"payload": {
"code": "WRITE_FAILED"
}
}
To append records, use read-modify-write:
// 1. Read current tag data
const currentData = await client.getLastTag();
// 2. Extract existing records
const existingRecords = currentData.message.records.map(r => ({
type: r.type === 'T' ? 'text' : 'uri',
content: r.text || r.uri,
language: r.language || 'en'
}));
// 3. Write back with new record appended
socket.send(JSON.stringify({
type: 'writeRequest',
payload: {
records: [...existingRecords, { type: 'text', content: 'New record' }]
}
}));
A connection from the agent’s own host presents the shared API secret or a token issued at pairing, like any other connection. Loopback identifies the host, so admitting it without a credential also admits other accounts on that host, local proxies, and port forwards into it.
-allow-loopback-bypass (or DAVI_NFC_ALLOW_LOOPBACK_BYPASS=1) admits loopback
with no credential, for a local client that cannot be given the secret. It
covers the shared secret only: under Requiring pairing a
device connection still needs a paired credential. The console’s control surface
is unaffected either way, requiring loopback, its own origin and a session
token.
The shipped console reads the secret from its session and sends it, so it needs nothing here.
Base URL: http://localhost:9470/api/v1
GET /api/v1/health
curl http://localhost:9470/api/v1/health
Response:
{
"status": "ok",
"type": "agent",
"timestamp": "2026-03-14T09:26:53Z",
"clients": 2
}
clients is how many are connected right now.
Both /health and /api/v1/health are served on the agent server port and
report "type": "agent". They are the agent’s own routes, mounted on whatever
listener the build registers, so they are there whatever else is. A build puts
its own paths on the same port as endpoints of the server plugin, which is how
the Control Center is served from it.
The agent serves wss:// with a self-signed certificate generated from a key it
creates once and keeps. Nothing is installed into any trust store by default.
Phones, readers and other native clients should not install a certificate
authority. They verify the agent by pinning its public key, reported as
serverInfo.publicKeyPin at registration and handed out at pairing. The pin
survives certificate reissues, which happen whenever the host’s addresses
change.
See Setting up an iOS or Android device for the pairing flow and the trust-evaluation code, including the two ways it commonly goes wrong.
A browser cannot pin, so it needs a certificate it already trusts:
-cert / -key at a certificate for a name you
control that resolves to the agent. Nothing is installed, and the browser
trusts it because a public CA issued it.-install-ca: creates a local certificate authority and installs it in
the system trust store. A CA there can sign for any name, not just this
agent, so prefer option 1 where you can arrange it.With -install-ca, the bootstrap server on port 9472 serves the root
certificate for installation, PIN-gated.
Browsers also need their origin allowed. See Browser origins. A trusted certificate and an allowed origin are separate requirements, and a failure of either looks the same from the page.
Errors arrive as a response with success: false, a human-readable error
string, and a structured payload:
{
"id": "req_1",
"type": "error",
"success": false,
"error": "data too large: 900 bytes exceeds tag NDEF capacity of 504 bytes",
"payload": {
"code": "CAPACITY_EXCEEDED",
"retryable": false,
"op": "WriteData",
"tagUID": "04:A1:B2:C3"
}
}
code has always been present and its strings are stable. retryable, op,
and tagUID are additive: a client reading only code is unaffected.
retryable answers whether repeating the identical request could plausibly
succeed. Combined with code it gives three distinct outcomes:
| Condition | Meaning | What a client should do |
|---|---|---|
retryable: true, code ≠ TAG_REMOVED |
Transient: I/O glitch, full queue, timeout | Retry, with backoff |
retryable: true, code = TAG_REMOVED |
The tag left the field mid-operation | Ask the user to present the tag again |
retryable: false |
Refused on its merits | Do not retry; surface it |
Raised by the bridge itself, before reaching a tag.
| Code | Retryable | Description |
|---|---|---|
PARSE_ERROR |
no | Message was not valid JSON |
INVALID_PAYLOAD |
no | Payload did not match the message type |
INVALID_REQUEST |
no | Required field missing or invalid |
INVALID_MESSAGE_TYPE |
no | Message type not valid at this point in the exchange |
UNKNOWN_TYPE |
no | Unrecognized message type |
INVALID_DEVICE |
no | Device ID did not match the connection |
TAG_MISMATCH |
no | The tag present is not the one the request named |
TAG_NOT_NAMED |
no | Request named no tag and did not ask for one to be guessed |
REGISTRATION_FAILED |
no | Device could not be registered |
SESSION_LOCKED |
no | Another client holds the session |
TAG_SEND_FAILED |
yes | Tag data could not be delivered internally |
READ_ERROR |
yes | Failed to read from the connection |
TIMEOUT |
yes | Operation timed out |
DEVICE_GONE |
no | Target device disconnected |
INTERNAL_ERROR |
yes | Unexpected agent-side failure |
BUSY |
yes | Earlier work has not finished: a reader completing an operation its caller abandoned, or more requests outstanding than the connection queues |
UNKNOWN_ERROR |
no | Unclassified: never advertised as retryable |
Something happened at the tag. These mirror the agent’s internal error codes.
| Code | Retryable | Description |
|---|---|---|
NOT_SUPPORTED |
no | Tag or device does not support the operation |
TAG_REMOVED |
yes | Tag left the field mid-operation |
AUTH_FAILED |
no | Authentication failed: the same key will fail again |
READ_FAILED |
yes | Read failed |
WRITE_FAILED |
yes | Write failed |
TRANSCEIVE_FAILED |
yes | Raw exchange failed |
TAG_NOT_CONNECTED |
yes | No tag connected |
READ_ONLY |
no | Tag is locked, or the agent is in read-only mode |
RAW_CHANNEL_DISABLED |
no | The raw APDU channel is off; enable it to send raw exchanges |
CAPACITY_EXCEEDED |
no | Data larger than the tag’s usable NDEF capacity |
INVALID_DATA |
no | Data was malformed |
MULTIPLE_TAGS |
no | More than one tag in the field; separate them and try again |
NO_CARD |
yes | Nothing is holding the tag the request named |
NO_PAYLOAD |
no | Tag read, holds no NDEF message. See Identity-only scans |