@endstate-sdk/reader
v0.2.0
Published
Read Endstate chips with NFC readers in the browser - TapTrack Tappy (Web Serial / WebUSB) and Web NFC behind one interface.
Maintainers
Readme
@endstate-sdk/reader
Read Endstate chips from your own application. One interface across supported NFC readers - a TapTrack Tappy plugged into a computer, or a phone's built-in NFC - with zero runtime dependencies.
npm install @endstate-sdk/readerQuick start
import { pickReader } from "@endstate-sdk/reader";
const reader = pickReader(); // best available reader, or null
if (!reader) throw new Error("No supported NFC reader in this browser.");
await reader.connect(); // picker on the first visit, silent after; call from a click
await reader.start({
onTap: ({ url, chipId, e, c }) => {
if (!chipId || !e) return; // tag carried some other URL
// chipId/e/c are the values the chip endpoints take - send chipId
// as chip_id, with your unit_id, and the pairing body is complete.
},
onError: (message) => console.warn(message),
});A tap yields an identified chip:
| Field | Shape | Meaning |
| --- | --- | --- |
| url | string | Full URL read from the tag |
| chipId | ^[0-9A-F]{10}$ | The chip's id - the API's chip_id |
| e | ^[0-9A-F]{32}$ | Single-use encrypted tap payload |
| c | ^[0-9A-F]{16}$ | Tap verification code, when the tag provides one - forward it with e |
chipId and e are present when the tag carries an Endstate chip URL (both
the /verify/{chipId}?e= and /u/{chipId} forms), and absent
otherwise; c rides along whenever the tag includes it. parseChipUrl(url)
and identifyTap(tap) are exported for URLs and taps you obtained some other
way.
Pairing chips: the two-legged flow
Reader hardware runs in the browser (Web Serial / WebUSB / Web NFC). Endstate
API keys (end_sk_…) are server-side only and must never reach the browser.
So pairing is always two legs:
// Operator UI (browser): tap → hand the identified chip to YOUR backend
onTap: ({ chipId, e, c }) =>
fetch("/api/pair", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chipId, e, c, unitId }),
});
// Your backend (holds end_sk_): create the unit, then pair the chip
// POST /v1/units { collection_id, external_id?, name? } → unit.id
// POST /v1/chips { unit_id, chip_id: chipId, e, c }Readers and environments
pickReader() picks the best supported driver:
- Phone NFC (
WebNfcReader) - Android Chrome. No extra hardware. - Tappy over Web Serial (
WebSerialTappyReader) - Chrome/Edge desktop. Preferred for the Tappy: rides the OS serial driver, works on Windows, macOS, and Linux. - Tappy over WebUSB (
RawTappyReader) - fallback. On Windows the OS serial driver owns the device, so WebUSBopen()is denied - Web Serial is used there instead.
Connecting once, not every time
The browser remembers which reader an origin may use, so picking a device is a
one-time authorization, not part of connecting. @endstate-sdk/reader treats
it that way:
// Silent. Reconnects to a reader this site is already authorized to use.
// No prompt, no user gesture, safe to call on page load.
await reader.connect({ prompt: false });
// The pairing step. Always opens the browser device chooser, so it must run
// in a click handler.
await reader.requestDevice?.();
// Plain connect() does both: silent if possible, chooser if this site has no
// reader yet. Call it from a click so the fallback is allowed to prompt.
await reader.connect();stopScanning() stops delivering taps and leaves the device connected, so
the next scan starts instantly and without a prompt - that is what a component
teardown wants. disconnect() is the explicit release, for when another tab or
a desktop app needs the reader. stop() still means "stop and release" as it
always has; it is now a deprecated alias for disconnect().
Drive the UI off the connection state rather than guessing:
type NfcConnectionState =
| "disconnected"
| "authorization-required" // no reader paired yet: show "Connect reader"
| "connecting"
| "connected"
| "error";
reader.onConnectionStateChange?.((state) => render(state));Only authorization-required needs a user gesture. Every other state either
resolves itself (a replugged reader reconnects on its own while the browser
still holds the grant) or needs a different fix, so a "Connect reader" button
gated on that one state disappears once a reader is paired. It comes back if
the browser drops the grant: a different origin, an incognito window, or a
reader that reports no serial number after an unplug or a browser restart.
pickReader() is memoized per document: the reader owns an open device
connection that has to outlive a component remount or an SPA navigation. Use
it as your single entry point. One document holds one reader per device - if
you construct a driver yourself (new WebSerialTappyReader()), keep one
instance alive at a time. Two instances that resolve the same reader share one
byte stream and one command channel, so the last to connect takes the taps and
either one's disconnect() releases the device for both.
Phone NFC permissions
Web NFC permission is per origin. If you move your operator flow to a new
domain, every operator is prompted again on the new origin. A denied
permission never re-prompts: the operator must re-enable NFC via the
address-bar lock icon → Permissions. Chrome also refuses to show the prompt
while another app draws a screen overlay. Taps surface these states through
onError with operator-actionable messages.
Subpath exports
@endstate-sdk/reader- readers + chip URL parsing (browser)@endstate-sdk/reader/react-useNfcReaderhook (requires React ≥ 19, optional peer dependency)@endstate-sdk/reader/ndef- NDEF URI parsing (pure, runs anywhere)@endstate-sdk/reader/tcmp- TapTrack TCMP protocol helpers (pure, runs anywhere)
React
import { useNfcReader } from "@endstate-sdk/reader/react";
const {
isScanning,
error,
needsAuthorization,
startScanning,
stopScanning,
requestDevice,
} = useNfcReader({
onTap: ({ chipId, e, c }) => {
/* … */
},
// Scan as soon as an already-authorized reader connects, with no click.
// Without this, a return visit connects silently and then sits idle.
autoStartWhenGranted: true,
});
// Pairing: only while the browser has not been given a reader yet.
{
needsAuthorization && <button onClick={requestDevice}>Connect reader</button>;
}
// Scanning: for a reader that is connected but stopped.
{
!needsAuthorization &&
(isScanning ? (
<button onClick={stopScanning}>Stop</button>
) : (
<button onClick={startScanning}>Start scanning</button>
));
}The hook owns the reader lifecycle: connect/start sessions, per-tag debounce,
permission surfacing, and an optional mock mode for development. On mount it
reconnects silently to an already-authorized reader (autoConnect, default
true), so remounts, SPA navigation, and refreshes show no prompt.
For AI agents
This package ships an AGENTS.md (in the install root, next to this README)
with the integration rules that prevent real mistakes - secret-key handling,
the single-use e contract, and the browser/hardware support matrix. Endstate
docs are agent-readable at https://docs.endstate.io/llms.txt, and every docs
page is searchable through the MCP endpoint at https://docs.endstate.io/mcp.
