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

# Browser

> EndstatePublicClient: what a publishable key may do, capturing taps, and why the browser never holds a secret key.

`EndstatePublicClient` takes a publishable key (`end_pk_...`) and is safe to
construct in page source. The key identifies your organization and **grants no
access by itself**.

```ts theme={null}
import { EndstatePublicClient, publishableKey } from "@endstate-sdk/core";

const endstate = new EndstatePublicClient({
  publishableKey: publishableKey(process.env.NEXT_PUBLIC_ENDSTATE_KEY),
});
```

<Warning>
  Never put a secret key (`end_sk_...`) in browser or Electron-renderer code,
  and never proxy one through a public endpoint. For server work, see
  [Server](/sdks/core/server).
</Warning>

## What a publishable key can do

Exactly one thing: record a tap.

```ts theme={null}
const session = await endstate.verify({ chip_id: chipId, e, c });
```

That is deliberate. The authority for anything further is not the key - it is
the **tap**, which cannot be forged and cannot be replayed. Verifying returns a
[session](/sdks/core/tap-sessions) scoped to that one unit, and the session is
what authorizes claiming, transferring, and reading it.

So the public client's surface is small on purpose:

|                   | `EndstateClient`       | `EndstatePublicClient` |
| ----------------- | ---------------------- | ---------------------- |
| Credential        | `end_sk_...`           | `end_pk_...`           |
| Resources         | all of them            | `taps.create` only     |
| `verify()`        | yes, and may set `ttl` | yes, default `ttl`     |
| Safe in a browser | **no**                 | yes                    |

A publishable key may not send `ttl` or `dry_run`. An untrusted browser does
not get to choose how long its own credential lives.

## Allow-list your origin first

The browser blocks the request before it reaches the API unless your page's
origin is on your [browser allow-list](/settings/cors-origins). The symptom is
an opaque network error rather than a useful response, so check this first when
a call fails only in the browser.

Core sends requests the way the API expects - it never sets `credentials` - so
once the origin is listed there is nothing further to configure.

## Capturing the tap

Core declares the tap-source interface and dispatches to it; it never bundles a
reader. Register one and `captureTap()` runs the highest-priority source that
can capture right now.

```ts theme={null}
import { parseTapUrl } from "@endstate-sdk/core";

const manualEntry = {
  name: "manual",
  priority: 0,
  isAvailable: () => true,
  capture: async () => {
    const url = await promptForTapUrl();
    return url ? parseTapUrl(url, "manual") : null;
  },
};

const endstate = new EndstatePublicClient({
  publishableKey,
  tapSources: [manualEntry],
});

await endstate.availableTapSources(); // capability detection
const tap = await endstate.captureTap(); // call inside a user gesture
if (tap) await endstate.verify(tap);
```

Sources are registered per client, so server rendering never shares one.

The `source` a tap reports is an open string, so adding one is not a breaking
change. The well-known names ship as constants - `TAP_SOURCE_WEB_NFC`,
`TAP_SOURCE_READER`, `TAP_SOURCE_REDIRECT`, `TAP_SOURCE_MANUAL`, and
`TAP_SOURCE_UNKNOWN` (`"web-nfc"`, `"reader"`, `"redirect"`, `"manual"`,
`"unknown"`).

For real hardware - desktop USB readers and Android phone NFC - use
[`@endstate-sdk/reader`](/sdks/reader/quickstart) and hand its output straight
to `verify()`:

```ts theme={null}
reader.start({
  onTap: async ({ chipId, e, c }) => {
    const session = await endstate.verify({ chip_id: chipId, e, c });
    // session.item is the verified unit
  },
});
```

## Reading a tap redirect

On a page you host as a [tap redirect](/concepts/tap-redirects) destination,
the values arrive as query parameters rather than in the path.
`tryParseTapUrl` reads that shape too, on any path:

```ts theme={null}
import { tryParseTapUrl } from "@endstate-sdk/core";

const tap = tryParseTapUrl(window.location.href);
if (!tap) return renderTapPrompt();

const session = await endstate.verify(tap);
```

Or read the two parameters yourself and pass them straight to `verify()`:

```ts theme={null}
const params = new URLSearchParams(window.location.search);
const chipId = params.get("endstate_chip_id");
const e = params.get("endstate_e");

if (chipId && e) {
  const session = await endstate.verify({ chip_id: chipId, e });
}
```

See [Host your own verify page](/guides/host-verify-page) for the full flow,
including the version where your own server holds the secret key.

## Chip ids are normalized

Chip ids are matched either case and always returned uppercase. The SDK
normalizes them for you, so never compare raw user input to an API response.

```ts theme={null}
import { normalizeChipId, tryParseTapUrl } from "@endstate-sdk/core";

normalizeChipId("04a1b2c3d4"); // "04A1B2C3D4"

const tap = tryParseTapUrl(input); // null when it is not a tap URL
```
