> ## Documentation Index
> Fetch the complete documentation index at: https://docs.endstate.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Tap capture

> Lead with React - EndstateProvider autoVerify for the landing tap, useTap() for an in-page Web NFC scan - or drop to captureTap in vanilla JS, and present the right message when a scan fails, with @endstate-sdk/web.

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()`](/sdks/core/tap-sessions) 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()`](#let-a-customer-scan-an-item-in-page) drives an in-page scan from a
button. Outside React, the same work is one call -
[`captureTap()`](/sdks/core/reference/browser/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`](/sdks/core/quickstart) 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](#how-capturetap-chooses-a-source) when more than one
is possible.

<Note>
  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.
</Note>

## Capture the tap that opened the page

The common case, and the one the [quickstart](/sdks/web/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()`](#capture-the-tap-that-opened-the-page) 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"] })`](/sdks/core/reference/browser/captureTap)
yourself and hand the tap to `verify`.

<CodeGroup>
  ```tsx React theme={null}
  "use client";
  import { EndstateProvider, useSession } from "@endstate-sdk/web/react";

  // autoVerify defaults to true: the provider captures and verifies the landing
  // redirect tap on mount. tapSources defaults to defaultTapSources().
  export function App() {
    return (
      <EndstateProvider publishableKey="end_pk_...">
        <LandedItem />
      </EndstateProvider>
    );
  }

  function LandedItem() {
    const { item, status } = useSession();
    if (status === "verifying") return <Spinner />;
    if (!item) return null; // no tap on this page (direct visit)
    return <ItemCard item={item} />; // the verified unit - no extra request
  }
  ```

  ```ts Vanilla theme={null}
  import { createEndstateClient } from "@endstate-sdk/web";

  const client = createEndstateClient({ publishableKey: "end_pk_..." });

  const tap = await client.captureTap({ only: ["redirect"] });
  if (tap) {
    const session = await client.verify(tap);
    const item = session.item; // the verified unit - no extra request
  }
  ```
</CodeGroup>

Reading the item is where the [browser quickstart](/sdks/web/quickstart) picks
up: from the session you provision the customer's [wallet](/sdks/web/wallet) and
[claim](/sdks/web/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()`](/sdks/web/reference/tap-sources/redirectTapSource) when
you need to parse a URL you hold yourself.

<Warning>
  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.
</Warning>

## 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()`](#let-a-customer-scan-an-item-in-page) 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`](#present-the-right-message-when-a-scan-fails). Call
`scan()` from the control the customer pressed. Outside React, call
`captureTap()` from that same handler.

<CodeGroup>
  ```tsx React theme={null}
  "use client";
  import { useTap, useSession } from "@endstate-sdk/web/react";

  function ScanButton() {
    const { scan, status, permission } = useTap();
    const { item } = useSession();

    if (permission === "unsupported") return <RedirectOnlyUi />; // no reader here

    return (
      <>
        <button onClick={() => scan()} disabled={status === "scanning"}>
          {status === "scanning" ? "Scanning..." : "Scan an item"}
        </button>
        {item && <ItemCard item={item} />}
      </>
    );
  }
  ```

  ```ts Vanilla theme={null}
  scanButton.onclick = async () => {
    const scanned = await client.captureTap();
    if (scanned) await client.verify(scanned);
  };
  ```
</CodeGroup>

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`](#present-the-right-message-when-a-scan-fails) 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.

<Warning>
  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.
</Warning>

## 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()`](/sdks/web/reference/tap-sources/webNfcPermissionState)
resolves to the same four values for the current origin.

```tsx theme={null}
const { permission } = useTap();
// "granted" | "prompt" | "denied" | "unsupported"

if (permission === "unsupported") return <RedirectOnlyUi />; // no reader here
if (permission === "denied") return <SiteSettingsHint />; // retrying will not help
// "prompt" - a scan started inside a gesture will ask
// "granted" - a scan will read without prompting
```

* **`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:

| `reason`                   | What happened                                                                                         | What to present                           |
| -------------------------- | ----------------------------------------------------------------------------------------------------- | ----------------------------------------- |
| `permission-denied`        | Scanning is blocked for this site                                                                     | The browser's site settings for this page |
| `permission-not-requested` | The prompt was dismissed, or was suppressed and never appeared (commonly an overlay over the control) | Retry from an unobstructed control        |
| `empty-tag`                | A tag was detected but returned no content                                                            | Open the tap link instead                 |
| `unreadable-tag`           | A tag was detected but could not be read                                                              | Open the tap link instead                 |
| `not-supported`            | The device cannot scan                                                                                | Open the tap link instead                 |
| `nfc-disabled`             | Reading is turned off in the device settings                                                          | Turn it on there, or open the tap link    |

```tsx theme={null}
import { WebNfcError } from "@endstate-sdk/web";

// `error` is useTap().error (React) or a captureTap() catch (Vanilla).
function actionForScanError(error: unknown) {
  if (!(error instanceof WebNfcError)) throw error;
  switch (error.reason) {
    case "permission-denied":
      return pointAtSiteSettings();
    case "permission-not-requested":
      return promptRetryFromCleanControl();
    default:
      return offerTapLinkFallback(); // empty-tag, unreadable-tag, not-supported, nfc-disabled
  }
}
```

<Note>
  `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.
</Note>

## 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:

<CodeGroup>
  ```tsx React theme={null}
  "use client";
  import { useEndstateClient, useSession } from "@endstate-sdk/web/react";
  import { ChipUrlError } from "@endstate-sdk/core";

  // manualTapSource is registered through EndstateProvider's tapSources.
  function ManualTapForm() {
    const client = useEndstateClient();
    const { verify } = useSession();

    const onSubmit = async () => {
      try {
        const tap = await client.captureTap({ only: ["manual"] });
        if (tap) await verify(tap);
      } catch (error) {
        if (error instanceof ChipUrlError)
          showFieldError("That is not a tap link.");
        else throw error;
      }
    };
    // render an input bound to the manual source's provider, plus a submit button
  }
  ```

  ```ts Vanilla theme={null}
  import { manualTapSource } from "@endstate-sdk/web";
  import { ChipUrlError } from "@endstate-sdk/core";

  client.registerTapSource(manualTapSource(() => inputElement.value));

  submitButton.onclick = async () => {
    try {
      const tap = await client.captureTap({ only: ["manual"] });
      if (tap) await client.verify(tap);
    } catch (error) {
      if (error instanceof ChipUrlError)
        showFieldError("That is not a tap link.");
      else throw error;
    }
  };
  ```
</CodeGroup>

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()`](/sdks/core/reference/browser/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:

```ts theme={null}
function defaultTapSources(): TapSource[] {
  return [redirectTapSource(), webNfcTapSource()];
}
```

* **`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`:

<CodeGroup>
  ```tsx React theme={null}
  "use client";
  import { EndstateProvider } from "@endstate-sdk/web/react";
  import { defaultTapSources, manualTapSource } from "@endstate-sdk/web";

  // Pass a source array to the provider; it defaults to defaultTapSources().
  <EndstateProvider
    publishableKey="end_pk_..."
    tapSources={[
      ...defaultTapSources(),
      manualTapSource(() => inputRef.current?.value ?? ""),
    ]}
  >
    {children}
  </EndstateProvider>;
  ```

  ```ts Vanilla theme={null}
  import {
    createEndstateClient,
    defaultTapSources,
    manualTapSource,
  } from "@endstate-sdk/web";

  // At construction, through the tapSources option.
  const client = createEndstateClient({
    publishableKey: "end_pk_...",
    tapSources: defaultTapSources(),
  });

  // Or later, through registerTapSource - it returns an unregister function.
  const unregister = client.registerTapSource(
    manualTapSource(() => inputElement.value),
  );

  // Read only the redirect: a tap that opened this page, no scan.
  const tap = await client.captureTap({ only: ["redirect"] });
  ```
</CodeGroup>

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"`).

<Note>
  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`](/sdks/reader/quickstart) hardware source), and
  `captureTap()` folds it into the same priority dispatch.
</Note>

## Next steps

<CardGroup cols={2}>
  <Card title="defaultTapSources" href="/sdks/web/reference/tap-sources/defaultTapSources">
    The redirect-then-scan pair a browser page registers by default.
  </Card>

  <Card title="redirectTapSource" href="/sdks/web/reference/tap-sources/redirectTapSource">
    Read the tap the redirect landed on this page.
  </Card>

  <Card title="webNfcTapSource" href="/sdks/web/reference/tap-sources/webNfcTapSource">
    Scan with the device's own reader, where supported.
  </Card>

  <Card title="webNfcPermissionState" href="/sdks/web/reference/tap-sources/webNfcPermissionState">
    The scan permission state for this origin.
  </Card>

  <Card title="manualTapSource" href="/sdks/web/reference/tap-sources/manualTapSource">
    A tap source over a value your own interface collected.
  </Card>

  <Card title="Browser (core)" href="/sdks/core/browser">
    The TapSource seam and captureTap dispatch, from core's side.
  </Card>
</CardGroup>
