Skip to main content
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.
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.

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.
  • Optionally, a webhook endpoint subscribed to tap_intent.approved, tap_intent.canceled and tap_intent.consumed. See Webhooks.
  • 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.
string
required
own. The tap intent is opened on a unit in your own environment.
string
required
The unit the person will tap.
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.
integer
Seconds the tap intent waits for a tap, from 60 to 900. Defaults to 300.
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.
boolean
Whether the person must type a code to confirm. Defaults to true. Read What an approval proves before you pass false.
string
Where the person is sent after they confirm, never before. See After the confirm.
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.
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.
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.
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.
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.
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. 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.
  • 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. In React, EndstateProvider verifies the tap that opened the page, and useTapIntents() does the rest.
React
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. 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:
The approve answers with the outcome, and Endstate sends tap_intent.approved:
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 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

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

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

403
Create. Tap intents are not enabled for the environment.
409
Create. The unit already has 10 open tap intents. error.details.tap_intent_ids names them.
409
Create or approve. The unit is in a vault.
400
Create. redirect_url breaks one of the rules above.
404
Read, approve, consume or cancel. No such tap intent in this environment.
400
Consume. action is not the action the tap intent was opened with.
409
Consume. The person has not approved the tap intent yet.
409
Consume, approve or cancel. Another request consumed the tap intent. On consume, error.details.consumed_at says when. Do not act.
409
Approve or cancel. The tap intent is already approved, by another tap or with another code. An approved tap intent cannot be canceled.
409
Approve, consume or cancel. Nobody approved the tap intent in time, or the approval was not consumed by use_by.
409
Approve or consume. The tap intent was canceled.
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.
400
Approve. The tap intent needs its code. Send it as tap_code. No try is spent.
400
Approve. The code is wrong. error.details.attempts_remaining says how many tries are left.
409
Approve. Five wrong codes were entered, so the tap intent was canceled. Open a new one.
409
Approve. The code is no longer available. Cancel the tap intent and open a new one.
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.

Next steps

Webhooks

Register an endpoint and verify deliveries.

Tap redirects

Control where a tap of your units lands.