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.
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-intentsanswerstap_intent.disableduntil 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.canceledandtap_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
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.
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.Response
Response
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 The response is the tap intent with
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.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 getstap_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
idempotencyKeythe 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 afteruse_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_consumedwithout 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.approvedis revision 1 andtap_intent.consumedis revision 2. They can arrive out of order when a delivery is retried.
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’sredirect_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
httporhttps, andhttpsonly in a live environment. - It must not carry embedded credentials.
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 withsession_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:
tap_intent.approved:
"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, anderror.details.attempts_remainingsays how many of the five tries are left.tap_intent.code_attempts_exhausted: the fifth wrong code canceled the tap intent, and Endstate senttap_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 isapprovedand gets the same answer; another tap getstap_intent.already_approved, and any tap getstap_intent.already_consumedonce your server consumed it. A cancel of an approved tap intent is refused withtap_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.
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.
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 eleventhPOST /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.
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
CallPOST /v1/tap-intents/{tap_intent_id}/cancel with an empty JSON object when the person backs out. Endstate sends tap_intent.canceled.
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 totap_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.
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.Next steps
Webhooks
Register an endpoint and verify deliveries.
Tap redirects
Control where a tap of your units lands.

