Skip to main content
A tap reaches your page one of three ways: the customer taps your product and the chip link opens your page, they scan an item in-page with their phone, or they hand you a tap value some other way (a pasted link, a scanned QR code). @endstate-sdk/web turns any of them into the chip_id and e that verify() accepts. In React it is automatic. Wrap your tree in EndstateProvider and the tap that opened the page is captured and verified on mount (autoVerify), while useTap() drives an in-page scan from a button. Outside React, the same work is one call - captureTap() - on a client you build with createEndstateClient(). Both pick the source that matches how the tap actually arrived, so most pages never branch. The division of labor: @endstate-sdk/core owns the client, the session, and the dispatch; this package supplies the browser sources core dispatches to, plus the React provider and hooks that wrap them. This page covers each way a tap can arrive, the user-gesture rule that gates in-page scanning, how to present a failed scan, and finally how captureTap chooses when more than one is possible.
The React entry point is @endstate-sdk/web/react, and react is an optional peer (^19). The vanilla client factory and tap-source primitives import from @endstate-sdk/web. Every React example below is a client component.

Capture the tap that opened the page

The common case, and the one the quickstart golden path uses: the customer taps your product, the chip link opens your page, and the tap is already in the URL. The redirect source reads it - no scan, no permission, nothing to ask the customer - so it is safe to run the moment the page loads. In React that is EndstateProvider’s job: it captures and verifies the landing tap on mount (autoVerify defaults to true), and useSession() exposes the result. status moves through capturing and verifying to verified, and item is the verified unit, read with no extra request. Outside React, call captureTap({ only: ["redirect"] }) yourself and hand the tap to verify.
Reading the item is where the browser quickstart picks up: from the session you provision the customer’s wallet and claim the unit for it. It is the primary source on every platform - iOS has no in-page scanning at all, and on Android an in-page scan falls back to it. Two landing shapes parse into the same tap:
  • The page is the tap link’s direct destination - an Endstate chip URL (/verify/{chip_id}?e= or /u/{chip_id}?e=).
  • The page sits behind an Endstate hosted redirect - the values arrive as endstate_pathId (the chip id) and endstate_e query parameters on your own URL. (endstate_chip_id is accepted as an alias so both spellings of the contract parse; the delivered name is endstate_pathId.)
Validation is delegated to core’s parser, so the two packages can never disagree about what a chip id or credential looks like. The source reads window.location.href by default; pass a url option (a () => string) to redirectTapSource() when you need to parse a URL you hold yourself.
The hosted redirect records the tap in transit today, so verifying its forwarded values returns chip.already_scanned. Until the redirect hands over a usable session, only the direct chip-URL shape currently supports verify(). Both shapes are detected; know which one your page receives.

Let a customer scan an item in-page

When a customer is already on your page and wants to scan another item without leaving it, the Web NFC source reads a tag with the phone’s own reader. Reach for it for that in-page scan; leave the tap that opened the page to the redirect source above. In React, useTap() wraps it: scan() runs the in-page scan and verifies the result (which lands on useSession()), status reports idle, scanning, or error, and permission is the current WebNfcPermissionState. Call scan() from the control the customer pressed. Outside React, call captureTap() from that same handler.
It is a progressive enhancement, not the primary Android surface: it works only on Android Chrome in a secure context, and even there some device NFC stacks detect the tag but return an empty read. Treat it as a nicer path when it is available, and always keep the redirect flow (open the tap link) as the fallback. Every failure mode is a typed WebNfcError rather than a bare null, so the interface can present a distinct message - and a foreign tag never ends dispatch as this source’s final answer.

In-page scans need a user gesture

A scan starts a permission-gated NFC read, and the browser only allows the permission prompt to appear from inside a live user gesture. Run the scan from a control the customer pressed, never at page load or in an effect: call useTap().scan() from an onClick in React, or captureTap() from the button’s handler in vanilla JS. The Web NFC source is intentionally synchronous in its isAvailable() check so that capture() runs in the same task as the click, keeping the gesture that gates the prompt live. Kick a scan off from a setTimeout, a useEffect, an await that resolves later, or page load, and Chrome silently suppresses the prompt.
Chrome suppresses the permission prompt when the triggering control is covered by an overlay, and does it silently. If scans never prompt, check that the button is the topmost element at the point of the tap - this is the single most common cause of a scan that appears to do nothing.

Present the right message when a scan fails

Web NFC permission is granted per site, and a denial never re-prompts on its own; only the browser’s site settings can re-enable it. Gate the interface ahead of time. In React, useTap().permission is the current state; outside React, webNfcPermissionState() resolves to the same four values for the current origin.
  • granted - a scan reads without prompting.
  • prompt - a scan started inside a user gesture will ask.
  • denied - only the browser’s site settings can re-enable it; present that path rather than a retry.
  • unsupported - the device has no usable reader; show the redirect-only interface.
When a scan does fail, the Web NFC source rejects with a WebNfcError whose reason tells the interface exactly what to present - it lands on useTap().error in React, or the captureTap() rejection in vanilla JS. This is the one place a table is the right shape, because it is a reason-to-action matrix:
permission-denied and permission-not-requested split a single browser NotAllowedError by re-querying the permission after the failure. A prompt state means the ask never landed (dismissed or suppressed), which is recoverable from a clean control; anything else means the site is blocked and only settings will change it.

Accept a tap value you collected

When your own interface has the tap value already - a pasted link, a scanned QR code, a test flow - the manual source wraps it. You supply a provider; the source validates and normalizes the value through the same core parser as every other source, so it can never emit something the others would have rejected or cased differently. The manual source is not in the defaults; add it yourself. In React, include manualTapSource in EndstateProvider’s tapSources and capture it with useEndstateClient().captureTap({ only: ["manual"] }). Outside React, register it on the client with registerTapSource. Either way an invalid URL throws core’s ChipUrlError, so form validation can branch on it:
The provider may return a tap URL string, an already identified tap object, or null/undefined/"" when there is nothing to submit (which captures as null). At priority -10 the manual source runs last, so a genuine redirect or scan always wins over a value sitting in an input.

How captureTap chooses a source

You rarely call the sources directly - you register them on a client (or hand them to EndstateProvider), and captureTap() runs the highest-priority source that reports itself available right now, returning its TapResult or null when none has a tap to offer. The React provider dispatches through the exact same order: autoVerify and useTap() are thin wrappers over it. It does not merge sources or try them all: the winner is whichever available source sits highest in the order. That order is the point - a tap that already arrived in the URL should always beat a scan you would otherwise have to ask the customer to perform. defaultTapSources() returns the two sources a browser page wants, already in priority order, and is what both EndstateProvider and createEndstateClient() register when you pass no tapSources of your own:
  • redirectTapSource() at priority 20 - the tap already opened this page.
  • webNfcTapSource() at priority 0 - an in-page scan, where the device supports one.
  • manualTapSource at priority -10 - not in the defaults; add it when your interface collects a value directly, and it stays below both platform sources.
Register your own set two ways, and scope a single capture to one source with only:
Sources are registered per client, so server rendering never shares one. Under the hood each is a small object with a name, a numeric priority, an isAvailable() check, and a capture() method - core’s TapSource interface - and the well-known names ship from core as constants: TAP_SOURCE_REDIRECT ("redirect"), TAP_SOURCE_WEB_NFC ("web-nfc"), and TAP_SOURCE_MANUAL ("manual").
The defaults are a starting point, not a fixed set. Pass your own array to tapSources, or call registerTapSource to add one (for example a @endstate-sdk/reader hardware source), and captureTap() folds it into the same priority dispatch.

Next steps

defaultTapSources

The redirect-then-scan pair a browser page registers by default.

redirectTapSource

Read the tap the redirect landed on this page.

webNfcTapSource

Scan with the device’s own reader, where supported.

webNfcPermissionState

The scan permission state for this origin.

manualTapSource

A tap source over a value your own interface collected.

Browser (core)

The TapSource seam and captureTap dispatch, from core’s side.