A verification is the platform’s verdict for a scan. When someone taps a tag, your solution forwards the scan to the platform and receives a verification that says whether the tag is genuine. This is the core call your integration makes.
How scanning works
Your solution owns the scan entry point. A tag’s chip is programmed with a URL
that points at your app (https://<your-entry-point>/<tag_id>?<scan params>;
create the tag with {id} in its url to
get the tag id into the path), so a tap arrives as a request to you, with the tag id in the path and the scan
parameters in the query string. You then hand the scan to the platform, which
validates it and records the verification.
Hand it to your coding agent
Copy this brief into Claude Code, Cursor, or whatever you build with. It covers the scan, the session, and the page the customer reloads. Read it before you write the handler.
Add a TAGBASE scan flow to this app.
A customer taps a tag. The tag opens a URL on your server. You forward that
tap to TAGBASE and show the verdict. Read these pages first:
https://platform.tagbase.io/docs/api/verifications
https://platform.tagbase.io/docs/api/sessions
https://platform.tagbase.io/docs/guides/building-a-solution
1. Own the tap URL. Read the tag id from the path. Copy the query string
into attributes, unchanged. From your server, POST once to
/api/v1/tags/:tag_id/verifications. The customer's page never calls
the platform. Send no session relationship on the first tap.
2. Store the response before you answer the browser. Keep the verification
id (vrf_…) and the session id (ses_…, under relationships.session.data.id)
against this visitor.
3. Redirect the browser to a stable page whose address is the verification
id. The tap URL is not that page. A reload of the stable page calls
GET /api/v1/verifications/:id and renders attributes.status. It does
not POST.
4. Keep one flow for this visit. When a request for this tap is in flight,
or you already hold a verification whose status is pending, valid, or
invalid, render that state. A second render does not POST the same
query string again. A second tap is a new URL with new parameters.
POST that tap once, with the stored session id in relationships.session.
5. Detect private browsing in the customer's browser, on the result page.
TAGBASE does not return a private-browsing field, and it has no call
that detects it. When the window is private and the status is pending,
tell the customer to leave private browsing and tap again. Pending in
that window is not a step that is still working.
6. Recover a stale session. A session stays open for the tag's
session_duration (600 seconds unless the tag sets another value). When
you sent a session id and the response carries a different one, TAGBASE
opened a new pending session. Store the new id. Show pending for it.
7. Render every status. pending asks for a second tap, except in a private
window (step 5). valid shows the item as genuine. invalid warns the
customer that the item may be a copy.
When the page is built, run the checklist at
https://platform.tagbase.io/docs/guides/best-practices.Fields
| Field | Type | Notes |
|---|---|---|
id |
string | vrf_-prefixed, assigned by the platform. |
status |
string | The verdict (see below). |
tamper_status |
string | The seal reading (see below). null on a tag without a seal. |
inserted_at |
string | ISO 8601 timestamp (UTC, microsecond precision). |
A verification also references its session and its tag as relationships.
Status values
| Status | Meaning |
|---|---|
pending |
Scan accepted; the session hasn’t resolved to a final verdict yet. |
valid |
Genuine: the tag verified successfully. |
invalid |
Failed: the scan didn’t check out. |
A verification you create comes back as exactly one of these three.
Seal readings
Some tags carry a tamper seal: a physical loop that the tag checks each time
it is tapped to see whether the product has been opened. Every scan of such a tag
returns the reading in tamper_status, alongside the verdict:
| Reading | Meaning |
|---|---|
sealed |
The seal is intact; the product has not been opened. |
resealed |
The seal was opened at some point and is now closed again. |
open |
The seal is broken; the product has been opened. |
invalid |
The tag could not read its seal. |
tamper_status is null for a tag without a seal. Order a tag with one and the
field starts carrying a reading; nothing else about your integration changes.
The seal is independent of status. A tag can verify as valid and still report
open, which describes a genuine product in opened packaging. Treat the two as
separate signals, and decide for yourself whether an opened seal changes what you
show the customer.
Submit a scan
POST /api/v1/tags/:tag_id/verifications:tag_id is the tag id of the scanned tag. The key you present must own that
tag, or the platform responds 404.
This posts one scan at a time. If you already hold both scans of a tag, you can submit them together and resolve in a single call.
Request
The attributes are the tap URL’s query string, parsed into key/value pairs:
every parameter, unchanged. When a tag is tapped, its chip produces a URL whose
query string carries the data for that tap; your entry point receives it, and you
copy the whole parsed query string into attributes. You never name or interpret
those parameters: pass them through verbatim.
The parameter names depend on the chip type and can change between chip generations, so don’t filter by name. Forward every query parameter. Keep your own routing data (such as the tag id) in the path, not the query string, so the query string holds only what the chip wrote.
To continue a session an earlier verification returned, set the session
relationship to its id; omit relationships entirely to start a fresh one.
| Field | Required | Notes |
|---|---|---|
attributes |
yes | The scan parameters, copied from the tap URL’s query string. |
relationships.session |
no | The session to continue. Omit to start a new one. |
Here’s a scan that starts a fresh session, with just the forwarded tap parameters
and no session relationship (to continue a session you’d add one alongside them):
{
"data": {
"type": "verifications",
"attributes": {
"...": "...scan parameters copied from the tap URL..."
}
}
}To continue a session, add the session relationship:
{
"data": {
"type": "verifications",
"attributes": {
"...": "...scan parameters copied from the tap URL..."
},
"relationships": {
"session": { "data": { "type": "sessions", "id": "ses_2cZuH8Cb7L5fseae6FzNDc" } }
}
}
}curl https://platform.tagbase.io/api/v1/tags/tag_XPg3Zq7gEExNezDWWW7Zzo/verifications \
-X POST \
-H "Authorization: Bearer $TAGBASE_API_KEY" \
-H "Content-Type: application/vnd.api+json" \
-H "Accept: application/vnd.api+json" \
-d '{ "data": { "type": "verifications", "attributes": { "...": "...tap URL params..." } } }'const res = await fetch(
`https://platform.tagbase.io/api/v1/tags/${tagId}/verifications`,
{
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.TAGBASE_API_KEY}`,
"Content-Type": "application/vnd.api+json",
"Accept": "application/vnd.api+json",
},
body: JSON.stringify({ data: { type: "verifications", attributes } }),
},
);
const verification = await res.json();$response = $client->post(
"https://platform.tagbase.io/api/v1/tags/{$tagId}/verifications",
[
"headers" => [
"Authorization" => "Bearer " . getenv("TAGBASE_API_KEY"),
"Content-Type" => "application/vnd.api+json",
"Accept" => "application/vnd.api+json",
],
"json" => ["data" => ["type" => "verifications", "attributes" => $attributes]],
],
);
$verification = json_decode((string) $response->getBody(), true);verification =
Req.post!("https://platform.tagbase.io/api/v1/tags/#{tag_id}/verifications",
headers: [
{"authorization", "Bearer #{System.fetch_env!("TAGBASE_API_KEY")}"},
{"content-type", "application/vnd.api+json"},
{"accept", "application/vnd.api+json"}
],
json: %{data: %{type: "verifications", attributes: attributes}}
).bodyResponse: 201 Created
{
"data": {
"type": "verifications",
"id": "vrf_AoNAiMHGpUb2fxAB93eM4R",
"attributes": {
"status": "pending",
"tamper_status": "sealed",
"inserted_at": "2026-06-08T12:34:56.123456Z"
},
"relationships": {
"session": { "data": { "type": "sessions", "id": "ses_2cZuH8Cb7L5fseae6FzNDc" } },
"tag": { "data": { "type": "tags", "id": "tag_XPg3Zq7gEExNezDWWW7Zzo" } }
}
}
}Persist id, status, and the session id on your side as soon as you receive
them; you can also re-fetch a verification by id
later. Store tamper_status too if your tags carry a seal. The tag measures it
at tap time, so re-fetching a verification later returns the reading from that
tap rather than the state of the seal today.
Errors
| Status | When |
|---|---|
400 |
The body has no data.attributes object. |
401 |
Missing, invalid, or revoked key. |
404 |
No such tag under your team, or the tag isn’t written to a chip yet. |
422 |
The scan couldn’t be recorded; nothing was saved, so retrying the same request is safe. Not a failed security check. |
Note that a tag that fails its security check still returns
201withstatus: "invalid". That’s a successful verification with a negative verdict.422means the request itself couldn’t be processed, not that the tag is fake.
Sessions and resolution
- A scan posted without a
sessionrelationship opens a new session: the verification comes backpendingwith asessionid. Store that id against the scanning visitor (their session or a cookie). Scans are linked only because you re-present this id, not by anything the platform tracks about the device. - A scan posted with a
sessionrelationship continues that session, and the verdict resolves tovalidorinvalid.
A session stays open for the tag’s session_duration: 10 minutes unless
you configured the tag otherwise. After that it expires; a
later scan referencing it starts a fresh pending session instead of resolving
the old one. The response then carries a new session id. Store that id.
Keep the page safe to reload
Scan parameters work once. POST them once from your server, then redirect the
browser to a page whose address is the verification id. A reload of that page
calls GET /api/v1/verifications/:id and shows attributes.status.
Store the session id against the visitor before you answer. A reload reads
that id. It does not open a session, and it does not POST the same tap again.
When a request for this tap is already in flight, or you already hold a
pending, valid, or invalid verification for it, render that state.
The two taps have to share the session you stored. Private browsing keeps them
apart. TAGBASE does not return a private-browsing field. Detect private
browsing in the customer’s browser. When the window is private, tell the
customer to leave private browsing and tap again. A pending status there is
not a step that is still working.
The checks to run when the page is built are on Best practices.
Submit both scans at once
The flow above spans two requests glued by a session. If you already hold
both scans of a tag (for example a client that buffered taps while offline
and is now syncing), you can submit them together and get the final verdict in a
single call, with no pending step and no second round-trip.
Send data as an array of exactly two scans instead of a single object.
Each entry is a verifications resource whose attributes are one scan’s tap
parameters, copied verbatim, the same pass-through as a single scan. Order
matters: the first entry is the first tap, the second is the second tap. You
don’t set a session relationship; the call resolves on its own.
Request
{
"data": [
{ "type": "verifications", "attributes": { "...": "...first tap params..." } },
{ "type": "verifications", "attributes": { "...": "...second tap params..." } }
]
}curl https://platform.tagbase.io/api/v1/tags/tag_XPg3Zq7gEExNezDWWW7Zzo/verifications \
-X POST \
-H "Authorization: Bearer $TAGBASE_API_KEY" \
-H "Content-Type: application/vnd.api+json" \
-H "Accept: application/vnd.api+json" \
-d '{ "data": [
{ "type": "verifications", "attributes": { "...": "...first tap params..." } },
{ "type": "verifications", "attributes": { "...": "...second tap params..." } }
] }'const res = await fetch(
`https://platform.tagbase.io/api/v1/tags/${tagId}/verifications`,
{
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.TAGBASE_API_KEY}`,
"Content-Type": "application/vnd.api+json",
"Accept": "application/vnd.api+json",
},
body: JSON.stringify({
data: [
{ type: "verifications", attributes: firstTap },
{ type: "verifications", attributes: secondTap },
],
}),
},
);
const verifications = (await res.json()).data;$response = $client->post(
"https://platform.tagbase.io/api/v1/tags/{$tagId}/verifications",
[
"headers" => [
"Authorization" => "Bearer " . getenv("TAGBASE_API_KEY"),
"Content-Type" => "application/vnd.api+json",
"Accept" => "application/vnd.api+json",
],
"json" => [
"data" => [
["type" => "verifications", "attributes" => $firstTap],
["type" => "verifications", "attributes" => $secondTap],
],
],
],
);
$verifications = json_decode((string) $response->getBody(), true)["data"];verifications =
Req.post!("https://platform.tagbase.io/api/v1/tags/#{tag_id}/verifications",
headers: [
{"authorization", "Bearer #{System.fetch_env!("TAGBASE_API_KEY")}"},
{"content-type", "application/vnd.api+json"},
{"accept", "application/vnd.api+json"}
],
json: %{
data: [
%{type: "verifications", attributes: first_tap},
%{type: "verifications", attributes: second_tap}
]
}
).body["data"]Response: 201 Created
You get a data array of the verification records the call created, in scan
order. The last entry carries the final verdict (valid or invalid).
Read your authenticity result from it.
{
"data": [
{
"type": "verifications",
"id": "vrf_AoNAiMHGpUb2fxAB93eM4R",
"attributes": { "status": "pending", "inserted_at": "2026-06-08T12:34:56.123456Z" },
"relationships": {
"session": { "data": { "type": "sessions", "id": "ses_2cZuH8Cb7L5fseae6FzNDc" } },
"tag": { "data": { "type": "tags", "id": "tag_XPg3Zq7gEExNezDWWW7Zzo" } }
}
},
{
"type": "verifications",
"id": "vrf_XBUGqbnP8BzQ7yJAmyrtaw",
"attributes": { "status": "valid", "inserted_at": "2026-06-08T12:34:56.789012Z" },
"relationships": {
"session": { "data": { "type": "sessions", "id": "ses_2cZuH8Cb7L5fseae6FzNDc" } },
"tag": { "data": { "type": "tags", "id": "tag_XPg3Zq7gEExNezDWWW7Zzo" } }
}
}
]
}As with the single-scan call, persist each id and status on receipt; each is
also retrievable by id.
Errors
Same as a single scan, plus one shape check:
| Status | When |
|---|---|
400 |
data is an array but doesn’t hold exactly two scans, or an entry is missing its attributes. |
Retrieve a verification
GET /api/v1/verifications/:idFetch a verification by id: its status and its session
and tag relationships. The key you present must own the
underlying tag, or the platform responds 404.
curl https://platform.tagbase.io/api/v1/verifications/vrf_AoNAiMHGpUb2fxAB93eM4R \
-H "Authorization: Bearer $TAGBASE_API_KEY" \
-H "Accept: application/vnd.api+json"const res = await fetch(
"https://platform.tagbase.io/api/v1/verifications/vrf_AoNAiMHGpUb2fxAB93eM4R",
{
headers: {
"Authorization": `Bearer ${process.env.TAGBASE_API_KEY}`,
"Accept": "application/vnd.api+json",
},
},
);
const verification = await res.json();$response = $client->get("https://platform.tagbase.io/api/v1/verifications/vrf_AoNAiMHGpUb2fxAB93eM4R", [
"headers" => [
"Authorization" => "Bearer " . getenv("TAGBASE_API_KEY"),
"Accept" => "application/vnd.api+json",
],
]);
$verification = json_decode((string) $response->getBody(), true);verification =
Req.get!("https://platform.tagbase.io/api/v1/verifications/vrf_AoNAiMHGpUb2fxAB93eM4R",
headers: [
{"authorization", "Bearer #{System.fetch_env!("TAGBASE_API_KEY")}"},
{"accept", "application/vnd.api+json"}
]
).bodyResponse: 200 OK
{
"data": {
"type": "verifications",
"id": "vrf_AoNAiMHGpUb2fxAB93eM4R",
"attributes": {
"status": "valid",
"inserted_at": "2026-06-08T12:34:56.123456Z"
},
"relationships": {
"session": { "data": { "type": "sessions", "id": "ses_2cZuH8Cb7L5fseae6FzNDc" } },
"tag": { "data": { "type": "tags", "id": "tag_XPg3Zq7gEExNezDWWW7Zzo" } }
}
}
}Errors
| Status | When |
|---|---|
401 |
Missing, invalid, or revoked key. |
404 |
No such verification under your team. |