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

# Pair chips from your own application

> End-to-end: an operator taps a chip in your app, the Reader SDK identifies it, and your backend pairs it to a unit through the API.

This guide wires the full pairing station flow: your operator finishes an
internal process, taps a pre-encoded chip on a reader plugged into their
computer, and your backend writes the pair through the API. The
[Reader SDK](/sdks/reader/quickstart) handles the hardware; you write two small
pieces of glue.

The flow is **always two-legged**: the reader runs in the browser, and your
[API key](/credentials) (`end_sk_…`) stays on your server. Neither half can
do the other's job - that separation is what keeps the key out of operator
machines.

## Prerequisites

* An [active collection](/concepts/collections) for the units being paired -
  you have its `collection_id` and `contract.status` is `"active"`.
* `@endstate-sdk/reader` in your operator UI
  ([install](/sdks/reader/quickstart)) and a
  [supported reader](/sdks/reader/platforms) - or
  [test chips](/guides/testing-without-hardware) to build against first.
* Encoded chips in hand. Each tap of one yields a `chip_id` and a fresh
  single-use `e` value (see [Chips](/concepts/chips)).

<Steps>
  <Step title="Create the unit (your backend)">
    Each physical item gets a unit. Create it when your internal process
    finishes - `external_id` is your own identifier, so you can find the unit
    again without storing Endstate ids.

    <CodeGroup>
      ```ts SDK theme={null}
      await endstate.units.create({
        collection_id: "8e1a7f50-90ab-4cde-8012-3456789abcde",
        external_id: "order-4821-item-2",
        name: "Mega Charizard X ex",
      });
      ```

      ```bash cURL theme={null}
      curl -X POST https://api2.endstate.io/v1/units \
        -H "Authorization: Bearer end_sk_..." \
        -H "Content-Type: application/json" \
        -d '{
          "collection_id": "8e1a7f50-90ab-4cde-8012-3456789abcde",
          "external_id": "order-4821-item-2",
          "name": "Mega Charizard X ex"
        }'
      ```
    </CodeGroup>

    Keep the returned `unit.id` - the tap that follows pairs against it.
  </Step>

  <Step title="Read the tap (operator UI)">
    The reader identifies the chip and hands you exactly what the pairing call
    needs. Send it straight to your backend - never to the Endstate API from the
    browser.

    The browser prompt rules split this into two paths, and they must stay split:
    anything that can prompt has to run inside a real click handler.

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

    const reader = pickReader();
    if (!reader) throw new Error("No supported NFC reader in this browser.");

    const handlers = {
      onTap: ({ chipId, e, c }) => {
        if (!chipId || !e) return; // not an Endstate chip
        // Post to YOUR backend - the secret key never reaches the browser.
        fetch("/api/pair", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ chipId, e, c, unitId }),
        });
      },
    };

    // On page load: reconnect to a reader this operator already authorized.
    // Never prompts, so it needs no user activation. If it fails, the connection
    // state drops to "authorization-required" or "error" and the button takes over.
    await reader.connect({ prompt: false }).catch(() => {});

    // Only scan now if that can happen without a prompt: the device is connected
    // AND, on phone NFC, the permission is already granted. Otherwise show your
    // "Connect reader" button and wait for the click.
    const permission = (await reader.permission?.()) ?? "granted";
    if (reader.isConnected() && permission === "granted") {
      await reader.start(handlers);
    }

    // Anything that prompts can reject. These are the ones an operator can fix.
    const READER_ERRORS: Record<string, string> = {
      NotFoundError:
        "No reader picked. Press Connect reader and choose your Tappy.",
      SecurityError: "That has to run from a click. Press Connect reader again.",
      NetworkError:
        "Couldn't open the reader. Close any other tab or app using it.",
    };

    // In the button's click handler: the pairing step, plus Web NFC's permission
    // prompt. Both require user activation, so they cannot run on load.
    async function onConnectClick() {
      try {
        if (reader.getConnectionState?.() === "authorization-required") {
          await reader.requestDevice?.(); // opens the browser device chooser
        } else {
          await reader.connect(); // busy or retrying: no chooser needed
        }
        await reader.start(handlers);
      } catch (err) {
        // Phone NFC rejects with operator-ready text, so fall back to the message.
        const name = err instanceof DOMException ? err.name : "";
        showError(READER_ERRORS[name] ?? (err as Error).message);
      }
    }
    ```

    Leave the button clickable after a failure. `NotFoundError` only means the
    operator dismissed the chooser, and `NetworkError` clears as soon as whatever
    holds the reader releases it - see
    [Troubleshooting](/sdks/reader/troubleshooting).

    `e` is single-use and short-lived - forward it immediately, never queue or
    retry with a stale one. Include `c` whenever the tap provides it.

    An operator pairs their reader once, not once per unit. Use `start()` and
    `stopScanning()` between units and leave the connection open: `stopScanning()`
    does not release the device, so the next unit needs no prompt. Full model in
    [Connecting once, not every time](/sdks/reader/platforms#connecting-once-not-every-time).

    On a phone rather than a USB reader there is no device to authorize, so
    `connect()` never prompts - but `start()` raises the Android NFC permission
    prompt unless it has already been granted. That is the whole reason the
    load-time path checks `permission()` before scanning and the click path does
    not have to. Readers with no queryable permission (the USB ones) report
    `undefined`, which the `?? "granted"` above treats as "safe to scan". See
    [Readers and environments](/sdks/reader/platforms).
  </Step>

  <Step title="Pair the chip (your backend)">
    Your `/api/pair` handler makes the one call that binds the chip to the unit:

    <CodeGroup>
      ```ts SDK theme={null}
      import { EndstateClient, secretKey } from "@endstate-sdk/core";

      const endstate = new EndstateClient({
        apiKey: secretKey(process.env.ENDSTATE_API_KEY),
      });

      const result = await endstate.chips.pair({
        unit_id: "22222222-2222-2222-2222-222222222222",
        chip_id: "6BB168BBCA",
        e: "71124AA2E4D1D0678C5F300BE1FCB9AE",
        c: "940E8AA6628759B3",
      });

      // Pairing triggers issuance; wait for it rather than polling by hand.
      await endstate.units.waitUntilIssued(result.unit.id);
      ```

      ```bash cURL theme={null}
      curl -X POST https://api2.endstate.io/v1/chips \
        -H "Authorization: Bearer end_sk_..." \
        -H "Content-Type: application/json" \
        -d '{
          "unit_id": "22222222-2222-2222-2222-222222222222",
          "chip_id": "6BB168BBCA",
          "e": "71124AA2E4D1D0678C5F300BE1FCB9AE",
          "c": "940E8AA6628759B3"
        }'
      ```
    </CodeGroup>

    The response embeds a snapshot of the paired unit, including its issuance
    status - poll the unit until `collection.token.status` is `active`, or let
    [`waitUntilIssued`](/sdks/core/server#waiting-for-work-to-finish) do it.

    Two errors are worth handling specifically:

    * `chip.already_paired` (409) - the chip is bound to a different unit. A
      re-tap of an already-paired chip against its **own** unit is idempotent and
      succeeds.
    * `chip.invalid_e_value` (422) - the `e` didn't decode. Usually a stale or
      replayed value: have the operator tap again.
  </Step>

  <Step title="Confirm with a verify (optional)">
    A second tap run through `POST /v1/taps` (`chip_id` and `e` in the body)
    proves the pair end-to-end - the response names the unit the chip now belongs
    to. This is the same call your field verification uses; see
    [Verify a unit](/guides/verify-a-unit).
  </Step>
</Steps>

## Build it without hardware first

The whole flow runs with zero hardware: create chips with `is_test: true`,
generate tap credentials with [`POST /v1/test-helpers/taps`](/guides/testing-without-hardware),
and drive the operator UI with the Reader SDK's
[mock mode](/sdks/reader/react#develop-without-hardware). Swap in the real
reader and encoded chips at the end - nothing else changes.
