Skip to content

NFC

The device can poll an NFC reader mounted under (or near) the screen and surface tag taps — UID, NDEF content, or raw APDU exchanges for closed-loop token schemes — to both the displayed page and the host, over the same two-transport API used for everything else.

What this is not. NFC support here is for loyalty cards, closed-loop tokens (a private/integrator-controlled scheme, e.g. local currency or points), and reading tag UID/NDEF content. It is not regulated contactless bank-card (EMV) payment — that requires certified payment-terminal hardware and is entirely out of scope.

Supported hardware

The only reader backend currently implemented is the ACR122U (USB), which requires a free USB host port — Pi 4 or Pi 5 only (the Zero 2 W's single USB port is used by the gadget link to the host). PN532 readers over I2C/SPI (which would work on the Zero 2 W too, since they use GPIO rather than a USB host port) are planned but not yet available; nfc.backend already reserves the pn532-i2c/pn532-spi values for when they land, so a config written today will keep working.

Supported tag technologies: UID (any tag), NDEF read/write on Type 2 (NTAG/Ultralight) and NDEF read on Type 4 (DESFire in NDEF mode) tags, and raw ISO-DEP (ISO 14443-4) APDU exchange for your own application-level protocol. Mifare Classic crypto1 authentication, FeliCa and ISO 15693 are not supported.

Enable NFC

NFC is disabled by default. Enable it via configuration:

curl -X PATCH http://10.99.0.1/api/v1/config \
  -H 'Content-Type: application/json' \
  -d '{"nfc": {"enabled": true, "origins": ["https://pos.example.com"]}}'

This applies live — the reader manager starts immediately, no reboot needed. While nfc.enabled is false (the default), every /api/v1/nfc/* call answers 503 with error code nfc_disabled, and no NFC events are emitted — this is a hard, unambiguous gate, not a degraded "empty reader list" response.

nfc.origins is a page-origin allow-list: only pages loaded from an origin in this list can call the NFC API or receive NFC events from JavaScript (see "Reacting to taps from the displayed page" below). It's empty (nobody allowed) by default even once NFC itself is enabled — set it explicitly. This does not restrict the host's own API access, which is always allowed (the trusted USB link).

Other nfc.* fields (poll interval, backend selection, bus parameters for a future PN532 reader, auto-feedback on detect) are documented in the configuration reference; the poll interval and feedback toggle apply live, while switching backend/bus reinitializes the reader.

Web UI: NFC page — enable toggle, origin allow-list, reader status.

List readers

curl http://10.99.0.1/api/v1/nfc/readers

Returns each detected reader's id, backend, whether it's currently present, and its capabilities (supported techs, LED/buzzer presence). An empty list here (with NFC enabled) means no reader is plugged in — plug the ACR122U into a USB-A port on the Pi and it appears without a reboot (hotplug is supported).

Web UI: NFC page — reader list.

React to a tag tap

Tag arrivals and departures come as events, not as a poll — subscribe on the event stream (see Events):

{ "type": "subscribe", "topics": ["nfc_tag", "nfc_tag_removed"] }
{ "type": "event", "topic": "nfc_tag", "payload": {
    "reader_id": "acr122u-0", "tag_handle": "h-17",
    "uid": "04a1b2c3", "atqa": "0044", "sak": "00",
    "techs": ["ndef"], "ndef": { "records": [ /* … */ ] }
} }

tag_handle is a short-lived, opaque handle — it's only valid while that specific tag stays in the field. Use it as the target of the calls below; a stale handle (tag already removed, reader unplugged) gets 409 invalid_state.

Both nfc_reader_connected/nfc_reader_disconnected (USB hotplug) and nfc_tag/nfc_tag_removed arrive on both transports (network WebSocket and serial NDJSON), so a host wired up over serial only sees exactly the same events as one on the network link.

Read/write NDEF content

An NDEF-formatted tag's parsed content already arrives in the nfc_tag event's ndef field — you don't need a separate read call for the common case. To write:

curl -X POST http://10.99.0.1/api/v1/nfc/ndef/write \
  -H 'Content-Type: application/json' \
  -d '{"reader_id": "acr122u-0", "tag_handle": "h-17", "message": { "records": [ {"recordType": "text", "data": "..."} ] } }'

A tag that isn't NDEF-writable (wrong type, over capacity) gets 409 nfc_tag_unsupported.

Raw APDU exchange (closed-loop token schemes)

curl -X POST http://10.99.0.1/api/v1/nfc/transceive \
  -H 'Content-Type: application/json' \
  -d '{"reader_id": "acr122u-0", "tag_handle": "h-17", "apdu": "<base64 APDU bytes>"}'

Returns {"response": "<base64>", "sw": "9000"} — the raw response payload (status word stripped) plus the ISO 7816 status word as 4 hex digits. This is the building block for your own application-specific token/loyalty protocol against an ISO-DEP tag; the device does not interpret the exchange in any way. Requires the tag to support ISO-DEP (409 nfc_tag_unsupported otherwise).

Drive reader LED/buzzer feedback

curl -X POST http://10.99.0.1/api/v1/nfc/reader/feedback \
  -H 'Content-Type: application/json' \
  -d '{"reader_id": "acr122u-0", "led": "green", "buzzer_ms": 200}'

led is one of off | red | green | orange | blink-red | blink-green | blink-orange; buzzer_ms is a pulse duration (rounded to 100 ms steps on the ACR122U, capped at 2500 ms). Omit whichever field you don't want to change. If nfc.feedback.on_detect is enabled (the default), the device already auto-pulses on every successful tap, so you may not need to call this explicitly for the common "beep on tap" behavior.

Web UI: NFC page — manual LED/buzzer test controls.

Reacting to taps from the displayed page

A page shown by the device can subscribe to NFC directly, without going through the host, as long as its origin is in nfc.origins — this uses the same page-channel mechanism as any other host↔page or page↔device messaging (see REST and WebSocket API). A future release adds a navigator.nfc (Web NFC-shaped) polyfill for this; until then, page-side NFC access goes through the same low-level page-channel primitives as any other custom integration.

See also