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

# Confirm a step with a tap

> Open a tap intent on one of your units, show the person a code, and consume their approval once before you perform your own action.

A tap intent lets a person confirm a step in your app by tapping the item. Your server opens the tap intent and gets a six digit code to show the person. The person taps the item's chip with any phone. The page the tap opens lists the unit's open tap intents, and the person confirms yours there by typing the code. The tap intent is then `approved`.

An approval is good for one use and for a short time. Your server consumes it with `POST /v1/tap-intents/{tap_intent_id}/consume`, and only then performs its own action, such as publishing a listing. Consume succeeds for one request, inside the use window you set when you opened the tap intent.

Your server never approves the tap intent. The person does, on the page their tap opens. A tap intent is not a credential: the secret key authenticates your server, and the approval authorizes one action.

Use it when a step should only go ahead while the item is in someone's hands, for example confirming a listing or a handover.

> [!WARNING]
>
> If a redirect URL of your own applies to your units, taps open your page, not
> Endstate's. Your page must then list the open tap intents and approve them
> itself, or tap intents on those units can never be approved. Build that before
> you ask for tap intents to be enabled. See [If you host your own tap
> page](#if-you-host-your-own-tap-page).

## Prerequisites

* A secret key (`end_sk_...`). Opening, reading, canceling and consuming a tap intent take one, so those calls run on your server. A tap page you host can list, approve and cancel with the tap session instead.
* Tap intents enabled for the environment. They are off by default, and `POST /v1/tap-intents` answers `tap_intent.disabled` until they are on. Ask Endstate to enable tap intents for the environment.
* A table in your own database where you can insert one row per tap intent under a unique constraint. See [Consume once](#consume-once).
* Optionally, a webhook endpoint subscribed to `tap_intent.approved`, `tap_intent.canceled` and `tap_intent.consumed`. See [Webhooks](https://docs.endstate.io/concepts/webhooks.md).
* A unit of your own with a paired chip. A unit in a vault is refused with `tap_intent.unit_vaulted`.

## The flow

**1. Open a tap intent**

Call `POST /v1/tap-intents` with the unit and the step the tap confirms. `rule` is `own`: the unit is in your own environment. `action.type` and `action.reference` are your own strings. Endstate does not interpret them. It returns them on reads and in webhooks, and you send them again when you consume. `use_within` is how long the approval can be used after the person confirms.

```ts SDK theme={null}
const intent = await endstate.tapIntents.create({
  rule: "own",
  unitId: "6f1d2c3b-4a59-4e68-9f70-8a1b2c3d4e5f",
  action: { type: "listing.confirm", reference: "listing_8271" },
  useWithin: 300,
  redirectUrl: "https://app.example.com/listings/8271/confirm",
  display: { title: "Confirm your listing" },
});
```

```bash cURL theme={null}
curl -X POST https://api2.endstate.io/v1/tap-intents \
  -H "Authorization: Bearer end_sk_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: listing_8271-confirm-1" \
  -d '{
    "rule": "own",
    "unit_id": "6f1d2c3b-4a59-4e68-9f70-8a1b2c3d4e5f",
    "action": { "type": "listing.confirm", "reference": "listing_8271" },
    "use_within": 300,
    "redirect_url": "https://app.example.com/listings/8271/confirm",
    "display": { "title": "Confirm your listing" }
  }'
```

```json theme={null}
{
  "id": "tapi_3f9c1b7a2d4e4f60a1b2c3d4e5f60718",
  "status": "pending",
  "rule": "own",
  "unit_id": "6f1d2c3b-4a59-4e68-9f70-8a1b2c3d4e5f",
  "action": { "type": "listing.confirm", "reference": "listing_8271" },
  "redirect_url": "https://app.example.com/listings/8271/confirm",
  "display": { "title": "Confirm your listing" },
  "code_required": true,
  "scan": null,
  "created_at": "2026-11-10T17:02:11.000Z",
  "expires_at": "2026-11-10T17:07:11.000Z",
  "approved_at": null,
  "use_by": null,
  "consumed_at": null,
  "canceled_at": null,
  "tap_code": "482913"
}
```

**rule** `string` *required*

`own`. The tap intent is opened on a unit in your own environment.

**unit\_id** `string` *required*

The unit the person will tap.

**action** `object` *required*

`type` (up to 64 characters) and `reference` (up to 255 characters), both
required. Opaque to Endstate. Surrounding whitespace is trimmed, and you send
the same strings when you consume.

**ttl** `integer`

Seconds the tap intent waits for a tap, from 60 to 900. Defaults to 300.

**use\_within** `integer`

Seconds after the person approves in which you can consume the approval, from
60 to 900. Defaults to 300. It is fixed here and no call extends it.

**code** `boolean`

Whether the person must type a code to confirm. Defaults to `true`. Read [What
an approval proves](#what-an-approval-proves) before you pass `false`.

**redirect\_url** `string`

Where the person is sent after they confirm, never before. See [After the
confirm](#after-the-confirm).

**display.title** `string`

Up to 120 characters. Shown to whoever taps the item, beside the confirm
control. Keep personal data out of it.

`tap_code` is the code to show the person. Only this response carries it. No read and no webhook does, and a replay of the same `Idempotency-Key` returns `tap_code: null`. If you lose the code, cancel the tap intent and open a new one. With `code: false`, `tap_code` is `null` and `code_required` is `false`.

**2. Show the code and ask for a tap**

Show `tap_code` on your screen and ask the person to tap the item. The page their tap opens lists the unit's open tap intents, each with its `display.title`. The person picks yours, types the code and confirms.

The code has six digits and the person has five tries. The fifth wrong code cancels the tap intent, and Endstate sends `tap_intent.canceled`. Open a new tap intent if that happens.

**3. Wait for the approval**

When the person confirms, the tap intent becomes `approved`, with `approved_at`, `scan` set to their tap, and `use_by`, the moment the approval lapses: `approved_at` plus `use_within`.

Learn about it from a read. Read the tap intent with `GET /v1/tap-intents/{tap_intent_id}` when the person arrives back on your `redirect_url`, or poll it about once a second while your screen shows the code. Reads of one tap intent are limited to 2 per second.

```ts SDK theme={null}
const current = await endstate.tapIntents.get(intent.id);

if (current.status === "approved") {
  // The person confirmed. Consume before current.useBy.
}
```

```bash cURL theme={null}
curl https://api2.endstate.io/v1/tap-intents/tapi_3f9c1b7a2d4e4f60a1b2c3d4e5f60718 \
  -H "Authorization: Bearer end_sk_live_..."
```

> [accordion] Response
>
> ```json theme={null}
> {
>   "id": "tapi_3f9c1b7a2d4e4f60a1b2c3d4e5f60718",
>   "status": "approved",
>   "rule": "own",
>   "unit_id": "6f1d2c3b-4a59-4e68-9f70-8a1b2c3d4e5f",
>   "action": { "type": "listing.confirm", "reference": "listing_8271" },
>   "redirect_url": "https://app.example.com/listings/8271/confirm",
>   "display": { "title": "Confirm your listing" },
>   "code_required": true,
>   "scan": {
>     "id": "0b8e6a54-93c2-4d17-8f5e-2a1b3c4d5e6f",
>     "scanned_at": "2026-11-10T17:03:40.000Z"
>   },
>   "created_at": "2026-11-10T17:02:11.000Z",
>   "expires_at": "2026-11-10T17:07:11.000Z",
>   "approved_at": "2026-11-10T17:04:02.000Z",
>   "use_by": "2026-11-10T17:09:02.000Z",
>   "consumed_at": null,
>   "canceled_at": null
> }
> ```

Your page cannot poll Endstate, because the read takes a secret key. Have the page ask your server, and have your server read the tap intent.

> [!WARNING]
>
> Do not rely on the `tap_intent.approved` webhook alone. Consume on the
> redirect or from a read, and treat the webhook as a backstop. Deliveries are
> retried after 5 seconds, then 5 minutes, then 30 minutes. If your endpoint
> fails twice, the third delivery comes no earlier than about 305 seconds after
> the first, often later, which is already past the default use window.

**4. Claim the tap intent in your own store**

Before you call consume, insert a row for the tap intent id in your own database, under a unique constraint, together with a random idempotency key. That row decides which of your handlers acts. A handler that loses the insert stops there and does not call consume.

```ts theme={null}
import { randomUUID } from "node:crypto";

const claimed = await db.tapIntentClaims.insertIfAbsent({
  tapIntentId: intent.id,
  idempotencyKey: randomUUID(),
});
if (!claimed) return;
```

**5. Consume, then act**

Call `POST /v1/tap-intents/{tap_intent_id}/consume` with the action you are about to perform and the idempotency key from your row. Perform your action only after it succeeds.

```ts SDK theme={null}
const consumed = await endstate.tapIntents.consume(
  intent.id,
  { action: { type: "listing.confirm", reference: listing.id } },
  { idempotencyKey: claimed.idempotencyKey },
);

await publishListing(listing.id);
```

```bash cURL theme={null}
curl -X POST https://api2.endstate.io/v1/tap-intents/tapi_3f9c1b7a2d4e4f60a1b2c3d4e5f60718/consume \
  -H "Authorization: Bearer end_sk_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 8b0f6c1e-52c5-4c8e-9a51-0f1f2d7c6a3e" \
  -d '{ "action": { "type": "listing.confirm", "reference": "listing_8271" } }'
```

The response is the tap intent with `status: "consumed"` and `consumed_at` set, and Endstate sends `tap_intent.consumed`.

Send the action from your own state, not copied from a read of the tap intent. `action` is a check that you are spending the approval on the thing the person approved: if you pass the wrong tap intent id, the actions differ and the call is refused with `tap_intent.action_mismatch`. After the same trimming as at create, `type` and `reference` are compared byte for byte, with no case folding and no Unicode normalization. Send the same bytes you sent at create.

## Put together

One function opens the tap intent. One function claims, consumes and acts. Call the second from the page the person returns to, from your poll, and from your webhook handler: the claim makes sure only one of them acts.

```ts theme={null}
import { randomUUID } from "node:crypto";
import { EndstateClient, secretKey } from "@endstate-sdk/core";
import { isEndstateApiError } from "@endstate-sdk/core/errors";

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

export async function openListingConfirm(unitId: string, listingId: string) {
  const intent = await endstate.tapIntents.create({
    rule: "own",
    unitId,
    action: { type: "listing.confirm", reference: listingId },
    useWithin: 300,
    redirectUrl: `https://app.example.com/listings/${listingId}/confirm`,
    display: { title: "Confirm your listing" },
  });
  await db.listings.setTapIntent(listingId, intent.id);
  return { tapIntentId: intent.id, tapCode: intent.tapCode };
}

export async function waitForApproval(tapIntentId: string) {
  let current = await endstate.tapIntents.get(tapIntentId);
  while (current.status === "pending") {
    await new Promise((resolve) => setTimeout(resolve, 1000));
    current = await endstate.tapIntents.get(tapIntentId);
  }
  return current;
}

export async function consumeAndPublish(tapIntentId: string) {
  const listing = await db.listings.findByTapIntent(tapIntentId);
  if (!listing) return;

  // The unique insert decides which handler acts. A loser stops here.
  const claim = await db.tapIntentClaims.insertIfAbsent({
    tapIntentId,
    idempotencyKey: randomUUID(),
  });
  if (!claim) return;

  await finishClaim(claim, listing.id);
}

// The refusals that can never turn into a success for this claim.
const FINAL_REFUSALS = new Set([
  "tap_intent.already_consumed",
  "tap_intent.action_mismatch",
  "tap_intent.canceled",
  "tap_intent.expired",
  "tap_intent.not_found",
]);

// Also run this for claims left unfinished by a crash or an error, with the stored key.
export async function finishClaim(
  claim: { tapIntentId: string; idempotencyKey: string },
  listingId: string,
) {
  try {
    await endstate.tapIntents.consume(
      claim.tapIntentId,
      { action: { type: "listing.confirm", reference: listingId } },
      { idempotencyKey: claim.idempotencyKey },
    );
  } catch (error) {
    // Anything else may have consumed, or may still succeed: keep the claim and retry with its key.
    if (!isEndstateApiError(error) || !FINAL_REFUSALS.has(error.code)) {
      throw error;
    }
    // Expired, canceled or consumed by a request that is not this claim: do not act.
    await db.tapIntentClaims.markRefused(claim.tapIntentId, error.code);
    return;
  }
  await publishListing(listingId);
  await db.tapIntentClaims.markDone(claim.tapIntentId);
}
```

`waitForApproval` returns a tap intent that is `approved`, `canceled` or `expired`. Call `consumeAndPublish` when it is `approved`.

Give up on a claim only for a refusal that is final. A `5xx`, a rate limit, `tap_intent.busy` or `tap_intent.not_approved` leaves the claim open: the consume may have happened, or may still succeed, so run `finishClaim` again with the stored key. The SDK does not retry a `5xx` by itself.

## Consume once

Consume succeeds for one request. Every other request gets `tap_intent.already_consumed`, with `error.details.consumed_at`. That refusal means only that some request holding a secret key of this environment consumed the tap intent. It does not say which one. An app with two handlers, for example the redirect and the webhook, sees one of them win and the other refused, and the refused one must not act. These rules keep that safe:

* **Record the tap intent id in your own store first.** Insert it under a unique constraint before you call consume. That record decides which handler acts. A handler that loses the insert stops there and does not call consume.
* **Store a random idempotency key in that record.** Generate it when you insert the record and pass it on every consume for that record, including after a restart. Without `idempotencyKey` the SDK sends a fresh random key on each call, and a retry with a new key is a different request.
* **A retry with the stored key gets the success again.** Endstate answers the same key under the same secret key with the consumed tap intent and `Idempotent-Replayed: true`, also after `use_by`. It reports the one consume that happened in time and authorizes nothing new. So after a crash between consume and your action, retry consume with the stored key, then act.
* **The key is tied to the secret key that consumed.** If you rotate the secret key between a consume and its retry, the retry is a different request and gets `tap_intent.already_consumed`.
* **Never derive the key from the tap intent id alone.** Two handlers with the same key are the same request, and both would get success.
* **Treat `tap_intent.already_consumed` without your own record as "do not act".** Ask for a new approval if you still need one.
* **Consume happening once does not make your action happen once.** If your action can fail after consume succeeded, your record must let you retry the action itself, or the action must be safe to repeat.
* **Order events by `resource.revision`.** `tap_intent.approved` is revision 1 and `tap_intent.consumed` is revision 2. They can arrive out of order when a delivery is retried.

Without the stored key, a crash between consume and your action spends the approval, and the person has to tap again.

## After the confirm

A tap of the unit opens the same destination as any other tap. See [Tap redirects](https://docs.endstate.io/concepts/tap-redirects.md). Nothing redirects to a tap intent's `redirect_url` at tap time.

On Endstate's hosted page, the person confirms, the page counts down three seconds, and then sends them to the tap intent's `redirect_url` with `tap_intent=<id>` added to the query. Without a `redirect_url` the person stays on the hosted page.

`redirect_url` has rules, and a URL that breaks one is refused with `tap_intent.redirect_origin_not_allowed`:

* Its origin must be on the environment's allowed origins, the same list as [CORS origins](https://docs.endstate.io/settings/cors-origins.md).
* It must be `http` or `https`, and `https` only in a live environment.
* It must not carry embedded credentials.

The redirect carries no proof. `tap_intent=<id>` tells your page which tap intent the visit belongs to and nothing more. Have that page ask your server, and have your server read the tap intent, check that it is `approved`, and consume it.

## If you host your own tap page

A redirect URL of your own (on the unit, its collection, or your organization default) always wins. Taps of those units open your page, and Endstate's hosted page never shows. Your page is then the only place a tap intent on those units can be approved, so it must list the open tap intents and let the person confirm or cancel them.

Your page does it from the browser with the tap session. It verifies the tap, as it does for a claim, and the session token (`end_sess_...`) authorizes the list, the approve and the cancel. You need a publishable key, no secret key and no server of your own to approve. The tap session is the only way to approve a tap intent: no secret key endpoint approves one. Your server still consumes the approval afterwards with its secret key.

Call approve only after the person confirms on your page, never when the page loads. Send `Referrer-Policy: no-referrer` on the page so the tap value does not leak to other sites.

### From the browser with the tap session

Three endpoints take the tap session token and nothing else. The unit in the path must be the session's unit, or the call is refused with `session_token.wrong_chip`.

| Call | Endpoint |
| - | - |
| List the open tap intents | `GET /v1/units/{unit_id}/tap-intents` |
| Approve one | `POST /v1/units/{unit_id}/tap-intents/{tap_intent_id}/approve` |
| Cancel one | `POST /v1/units/{unit_id}/tap-intents/{tap_intent_id}/cancel` |

In React, `EndstateProvider` verifies the tap that opened the page, and `useTapIntents()` does the rest.

```tsx React theme={null}
"use client";
import { useState } from "react";
import { isEndstateApiError } from "@endstate-sdk/core/errors";
import { EndstateProvider, useTapIntents } from "@endstate-sdk/web/react";

export function TapPage() {
  return (
    <EndstateProvider publishableKey="end_pk_live_...">
      <TapIntents />
    </EndstateProvider>
  );
}

function TapIntents() {
  const { tapIntents, status, approve, cancel } = useTapIntents();
  const [tapCode, setTapCode] = useState("");
  const [message, setMessage] = useState<string | null>(null);

  async function onApprove(id: string, codeRequired: boolean) {
    try {
      const { redirectUrl } = await approve(
        id,
        codeRequired ? { tapCode } : undefined,
      );
      if (redirectUrl) window.location.assign(redirectUrl);
    } catch (error) {
      if (!isEndstateApiError(error)) throw error;
      if (error.code === "tap_intent.code_incorrect") {
        const details = error.response.error.details as
          | { attempts_remaining?: number }
          | undefined;
        setMessage(
          `Wrong code. ${details?.attempts_remaining ?? 0} tries left.`,
        );
      } else if (error.code === "tap_intent.tap_not_matched") {
        setMessage("Tap the item again.");
      } else {
        setMessage("This can no longer be confirmed.");
      }
    }
  }

  if (status !== "ready") return null;
  return (
    <ul>
      {tapIntents.map((tapIntent) => (
        <li key={tapIntent.id}>
          {tapIntent.title}
          {tapIntent.codeRequired && (
            <input
              inputMode="numeric"
              value={tapCode}
              onChange={(event) => setTapCode(event.target.value)}
            />
          )}
          <button
            onClick={() => onApprove(tapIntent.id, tapIntent.codeRequired)}
          >
            Confirm
          </button>
          <button onClick={() => cancel(tapIntent.id)}>Cancel</button>
        </li>
      ))}
      {message && <p>{message}</p>}
    </ul>
  );
}
```

`tapIntents` holds one row per open tap intent: `id`, `title`, `codeRequired` and `redirectOrigin`, the origin of the tap intent's `redirect_url`, so you can show the person where confirming takes them. It never carries a code. It is empty when nothing is open and when there is no tap session. A row leaves the list when the person approves or cancels it. See [`useTapIntents`](https://docs.endstate.io/sdks/web/reference/react/useTapIntents).

`approve` does not navigate. It resolves with `redirectUrl`, which already has `tap_intent=<id>` added and is `null` when the tap intent has no `redirect_url`. Your page decides when to send the person there.

Without React, the same calls sit on the session:

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

const endstate = createEndstateClient({ publishableKey: "end_pk_live_..." });

const tap = await endstate.captureTap({ only: ["redirect"] });
if (tap) {
  const session = await endstate.verify(tap);
  const { tapIntents } = await session.tapIntents.list();

  // Show tapIntents. After the person picks one and confirms on your page:
  const { redirectUrl } = await session.tapIntents.approve(tapIntentId, {
    tapCode,
  });
  if (redirectUrl) window.location.assign(redirectUrl);

  // Or the person declines. Canceling needs no code:
  // await session.tapIntents.cancel(tapIntentId);
}
```

```bash cURL theme={null}
curl https://api2.endstate.io/v1/units/6f1d2c3b-4a59-4e68-9f70-8a1b2c3d4e5f/tap-intents \
  -H "Authorization: Bearer end_sess_..."

curl -X POST https://api2.endstate.io/v1/units/6f1d2c3b-4a59-4e68-9f70-8a1b2c3d4e5f/tap-intents/tapi_3f9c1b7a2d4e4f60a1b2c3d4e5f60718/approve \
  -H "Authorization: Bearer end_sess_..." \
  -H "Content-Type: application/json" \
  -d '{ "tap_code": "482913" }'

curl -X POST https://api2.endstate.io/v1/units/6f1d2c3b-4a59-4e68-9f70-8a1b2c3d4e5f/tap-intents/tapi_3f9c1b7a2d4e4f60a1b2c3d4e5f60718/cancel \
  -H "Authorization: Bearer end_sess_..." \
  -H "Content-Type: application/json" \
  -d '{}'
```

The approve answers with the outcome, and Endstate sends `tap_intent.approved`:

```json theme={null}
{
  "id": "tapi_3f9c1b7a2d4e4f60a1b2c3d4e5f60718",
  "status": "approved",
  "redirect_url": "https://app.example.com/listings/8271/confirm?tap_intent=tapi_3f9c1b7a2d4e4f60a1b2c3d4e5f60718"
}
```

The cancel answers `"status": "canceled"` with `"redirect_url": null`, and Endstate sends `tap_intent.canceled`. The person can cancel with no code: holding the item is enough to decline.

Approving does not consume. Send the person to `redirect_url`, and have your server read the tap intent and [consume it](#consume-once) with its secret key. A tap session cannot consume.

Neither the approve nor the cancel takes an `Idempotency-Key`, and the SDK does not retry either after a failure, because a repeated wrong code would spend a try. The one exception is a `429 rate_limit.exceeded`, which Endstate refuses before anything runs. The hook also sends one request per tap intent at a time, so a double press spends one try. Do not retry them in a loop yourself. Branch on `error.code`:

* `tap_intent.code_required`: the tap intent needs its code. No try is spent.
* `tap_intent.code_incorrect`: the code is wrong, and `error.details.attempts_remaining` says how many of the five tries are left.
* `tap_intent.code_attempts_exhausted`: the fifth wrong code canceled the tap intent, and Endstate sent `tap_intent.canceled`.
* `tap_intent.tap_not_matched`: the tap is more than 10 minutes old, or was not made while the tap intent was open. Ask the person to tap the item again.
* `tap_intent.expired`, `tap_intent.canceled`, `tap_intent.already_approved`, `tap_intent.already_consumed`, `tap_intent.not_found`: the tap intent is closed. Drop the row. The same tap can repeat the approve while the tap intent is `approved` and gets the same answer; another tap gets `tap_intent.already_approved`, and any tap gets `tap_intent.already_consumed` once your server consumed it. A cancel of an approved tap intent is refused with `tap_intent.already_approved`.
* `tap_intent.code_unavailable`: the code is gone. The app that opened the tap intent cancels it and opens a new one.
* `tap_intent.busy`: another request is changing the tap intent. Let the person try again.

One tap can approve several tap intents. Each needs its own call and its own code.

## Statuses

| Status | Meaning | Who causes it | Webhook |
| - | - | - | - |
| `pending` | Open. The person has not confirmed yet. | Your server | None |
| `approved` | The person confirmed. `scan` is their tap. Consume it by `use_by`. | The person | `tap_intent.approved` |
| `consumed` | Final. Your server used the approval. | Your server | `tap_intent.consumed` |
| `canceled` | Final. You or the person canceled it, or five wrong codes were entered. | You or the person | `tap_intent.canceled` |
| `expired` | Nobody approved it in time, or it was approved and not consumed by `use_by`. | The clock | None |

`approved` is not final. An approved tap intent can only be consumed or lapse. There is no `tap_intent.expired` webhook.

An approval that lapsed reads as `expired` with `approved_at`, `use_by` and `scan` still set, so the read is the record of what happened. Consume refuses it with `tap_intent.expired`. One edge: a consume decided at or before `use_by` can commit a moment after it, so a read in that gap can say `expired` and then `consumed`. `consumed` and `canceled` never change.

## Timing

`expires_at` is `created_at` plus `ttl`. `ttl` runs from 60 to 900 seconds and defaults to 300. `use_by` is `approved_at` plus `use_within`. `use_within` runs from 60 to 900 seconds and defaults to 300.

| Moment | What happens |
| - | - |
| Up to `expires_at` | A tap of the unit counts. It must be recorded by `expires_at`. |
| For 10 minutes after the person's tap | The person can confirm with that tap. |
| Up to 600 seconds after `expires_at` | A cancel of a pending tap intent is still accepted. |
| More than 600 seconds after | A cancel is refused with `tap_intent.expired`. |
| More than 660 seconds after `expires_at` | A read of a tap intent nobody approved reports `expired`. |
| From `approved_at` to `use_by` | Consume succeeds, once. |
| After `use_by` | The approval reads as `expired`, and consume answers `tap_intent.expired`. |

Until a read reports `expired`, consume on a tap intent nobody approved answers `tap_intent.not_approved`, because a tap that landed in time can still be confirmed.

## What an approval proves

With a code, an approval proves that someone holding the item entered the code shown on your screen.

Without a code, it proves less: a genuine tap of the unit was recorded while the tap intent was open, and a confirm backed by that tap arrived within 10 minutes. It does not prove who confirmed.

* Anyone who taps the item can approve any tap intent on it that has no code.
* A tap value read from the chip earlier and never opened can be opened later and counts as a tap, until a newer tap of the item is recorded.

So pass `code: false` only where the person asking is the person holding the item and a wrong approval costs little, such as a seller confirming their own listing. A buyer confirming receipt must use a code: the buyer's screen shows it, and the seller does not have it.

A code does not stop someone who holds the item from canceling the tap intent on the page their tap opens, or from entering five wrong codes, which also cancels it. Open a new one when you receive `tap_intent.canceled`.

A tap intent does not identify the person. Tie the step to a person with your own sign-in.

## Several tap intents on one unit

A unit can have up to 10 open tap intents, and the tap page lists them all. The eleventh `POST /v1/tap-intents` is refused with `tap_intent.too_many_open`, and `error.details.tap_intent_ids` names the open ones. Cancel one before you open another.

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

async function openTapIntentIds(unitId: string, listingId: string) {
  try {
    await endstate.tapIntents.create({
      rule: "own",
      unitId,
      action: { type: "listing.confirm", reference: listingId },
    });
    return [];
  } catch (error) {
    if (!isEndstateApiError(error)) throw error;
    if (error.code !== "tap_intent.too_many_open") throw error;

    const details = error.response.error.details as
      | { tap_intent_ids?: string[] }
      | undefined;
    return details?.tap_intent_ids ?? [];
  }
}
```

## List a unit's pending and approved tap intents

`GET /v1/tap-intents?unit_id=<unit id>` returns the tap intents you opened on one unit that you can still act on, oldest first, each with its `status`: the `pending` ones, and the `approved` ones you have not consumed and whose `use_by` has not passed. It takes a secret key, and `unit_id` is required.

This is how an app that crashed finds an approval it has not consumed. If your server stopped between the person's confirm and your consume, list the unit, match each `approved` entry to your own record by its id, and consume it with the idempotency key stored in that record. You can also use it to find a pending tap intent you lost track of, or to pick one to cancel.

```ts SDK theme={null}
const { tapIntents } = await endstate.tapIntents.list({ unitId });
const unconsumed = tapIntents.filter((intent) => intent.status === "approved");
```

```bash cURL theme={null}
curl "https://api2.endstate.io/v1/tap-intents?unit_id=6f1d2c3b-4a59-4e68-9f70-8a1b2c3d4e5f" \
  -H "Authorization: Bearer end_sk_live_..."
```

```json theme={null}
{
  "tap_intents": [
    {
      "id": "tapi_3f9c1b7a2d4e4f60a1b2c3d4e5f60718",
      "status": "approved",
      "rule": "own",
      "unit_id": "6f1d2c3b-4a59-4e68-9f70-8a1b2c3d4e5f",
      "action": { "type": "listing.confirm", "reference": "listing_8271" },
      "redirect_url": "https://app.example.com/listings/8271/confirm",
      "display": { "title": "Confirm your listing" },
      "code_required": true,
      "scan": {
        "id": "0b8e6a54-93c2-4d17-8f5e-2a1b3c4d5e6f",
        "scanned_at": "2026-11-10T17:03:40.000Z"
      },
      "created_at": "2026-11-10T17:02:11.000Z",
      "expires_at": "2026-11-10T17:07:11.000Z",
      "approved_at": "2026-11-10T17:04:02.000Z",
      "use_by": "2026-11-10T17:09:02.000Z",
      "consumed_at": null,
      "canceled_at": null
    }
  ]
}
```

Each entry has the same shape as a read, and none carries the code. The list holds only tap intents opened in the environment of the key you call with. Consumed, canceled and expired tap intents are left out, and a unit you cannot see gives an empty list. The list takes no `status` filter and is not paged: it has no `pagination` object. If an approval is being written at that moment, the call answers `tap_intent.busy`. Send it again.

## Canceling

Call `POST /v1/tap-intents/{tap_intent_id}/cancel` with an empty JSON object when the person backs out. Endstate sends `tap_intent.canceled`.

```ts SDK theme={null}
await endstate.tapIntents.cancel(intent.id);
```

```bash cURL theme={null}
curl -X POST https://api2.endstate.io/v1/tap-intents/tapi_3f9c1b7a2d4e4f60a1b2c3d4e5f60718/cancel \
  -H "Authorization: Bearer end_sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{}'
```

On a tap page you host, the person who tapped can also cancel, with the tap session and no code. See [From the browser with the tap session](#from-the-browser-with-the-tap-session).

Canceling a canceled tap intent returns it unchanged. Only a pending tap intent can be canceled. If the person confirmed first, the cancel is refused with `tap_intent.already_approved`, with `tap_intent.already_consumed` once you consumed it, and with `tap_intent.expired` once the approval lapsed. To drop an approval you no longer want, do not consume it: it is useless without your secret key and lapses at `use_by`.

## Webhooks

Subscribe an endpoint to `tap_intent.approved`, `tap_intent.canceled` and `tap_intent.consumed`. A tap intent sends `tap_intent.approved` or `tap_intent.canceled` at `resource.revision` 1, and an approved one sends `tap_intent.consumed` at revision 2 when you consume it. Order them by `resource.revision`, not by arrival. `data` is the tap intent exactly as the read returned it at that moment, and it never carries the code. See [Webhooks](https://docs.endstate.io/concepts/webhooks.md#tap-intents) for the payloads.

The webhook is a backstop for the redirect and the read, not the trigger. In your handler, call the same claim-and-consume function for `tap_intent.approved`:

```ts theme={null}
import type { components } from "@endstate-sdk/core/types";
import { parseWebhook, verifyWebhook } from "@endstate-sdk/core/webhooks";

type TapIntentData = components["schemas"]["TapIntent"];

export async function POST(request: Request) {
  const rawBody = await request.text();
  const { id } = await verifyWebhook(
    rawBody,
    request.headers,
    process.env.WEBHOOK_SECRET!,
  );
  if (await alreadyHandled(id)) return new Response(null, { status: 200 });

  const event = parseWebhook<TapIntentData>(rawBody);
  if (event.type === "tap_intent.approved") {
    await consumeAndPublish(event.data.id);
  }
  if (event.type === "tap_intent.canceled") {
    await releaseListing(event.data.action.reference);
  }
  return new Response(null, { status: 200 });
}
```

`tap_intent.canceled` is sent when you cancel, when the person cancels on the page their tap opened, and when five wrong codes are entered. The person needs no code to cancel. `tap_intent.consumed` is for audit: it records that your server used the approval.

## Errors

**tap\_intent.disabled** `403`

Create. Tap intents are not enabled for the environment.

**tap\_intent.too\_many\_open** `409`

Create. The unit already has 10 open tap intents.
`error.details.tap_intent_ids` names them.

**tap\_intent.unit\_vaulted** `409`

Create or approve. The unit is in a vault.

**tap\_intent.redirect\_origin\_not\_allowed** `400`

Create. `redirect_url` breaks one of the rules above.

**tap\_intent.not\_found** `404`

Read, approve, consume or cancel. No such tap intent in this environment.

**tap\_intent.action\_mismatch** `400`

Consume. `action` is not the action the tap intent was opened with.

**tap\_intent.not\_approved** `409`

Consume. The person has not approved the tap intent yet.

**tap\_intent.already\_consumed** `409`

Consume, approve or cancel. Another request consumed the tap intent. On
consume, `error.details.consumed_at` says when. Do not act.

**tap\_intent.already\_approved** `409`

Approve or cancel. The tap intent is already approved, by another tap or with
another code. An approved tap intent cannot be canceled.

**tap\_intent.expired** `409`

Approve, consume or cancel. Nobody approved the tap intent in time, or the
approval was not consumed by `use_by`.

**tap\_intent.canceled** `409`

Approve or consume. The tap intent was canceled.

**tap\_intent.tap\_not\_matched** `409`

Approve, or cancel with the tap session. The tap is not a tap of the unit made
while the tap intent was open, or it is more than 10 minutes old. Ask the
person to tap the item again.

**tap\_intent.code\_required** `400`

Approve. The tap intent needs its code. Send it as `tap_code`. No try is
spent.

**tap\_intent.code\_incorrect** `400`

Approve. The code is wrong. `error.details.attempts_remaining` says how many
tries are left.

**tap\_intent.code\_attempts\_exhausted** `409`

Approve. Five wrong codes were entered, so the tap intent was canceled. Open a
new one.

**tap\_intent.code\_unavailable** `409`

Approve. The code is no longer available. Cancel the tap intent and open a new
one.

**tap\_intent.busy** `503`

Any write, or the list. Another request is changing the tap intent. Send the
call again, with the same `Idempotency-Key` on consume.

Every code is in the [error reference](https://docs.endstate.io/conventions/errors.md#tap-intents).

## Next steps

> [card] [Webhooks](https://docs.endstate.io/concepts/webhooks.md)
>
> Register an endpoint and verify deliveries.

> [card] [Tap redirects](https://docs.endstate.io/concepts/tap-redirects.md)
>
> Control where a tap of your units lands.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.