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> —
so a tap arrives as a request to you, with the tag id in the path and the scan
proof in the query string. You then hand the scan to the platform, which
validates it and records the verification. See
two-scan liveness for why a single check
resolves over two scans.
tap → your app receives the scan → POST .../verifications → verdict
Fields
| Field | Type | Notes |
|---|---|---|
id |
string | vrf_-prefixed, assigned by the platform. |
status |
string | The verdict — see below. |
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 |
First scan accepted; awaiting the second scan to resolve. |
valid |
Genuine — the tag proved itself live on the second scan. |
invalid |
Failed — the scan didn’t check out, or a copy replayed an old scan. |
A verification you create comes back as exactly one of these three.
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.
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 proof 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 one key you add yourself is tagbase_session_id, to resolve a two-scan flow:
set it on the second scan to the session id the first returned. Omit it on a
first scan (or to start a fresh flow). Scan parameters never use that name, so it
won’t collide with the proof you’re forwarding.
| Attribute | Required | Notes |
|---|---|---|
| (tap URL params) | yes | The scan proof, copied from the tap URL’s query string. |
tagbase_session_id |
no | Session id of the flow to continue. Omit on a first scan. |
Here’s a first scan — just the forwarded tap parameters, no
tagbase_session_id (on a second scan you’d add that key alongside them):
{
"data": {
"type": "verifications",
"attributes": {
"...": "...scan parameters copied from the tap URL..."
}
}
}
curl https://platform.tagbase.io/api/v1/tags/tag_RKJi9LYU4zzSVBZNUZ743M/verifications \
-X POST \
-H "Authorization: Bearer $TAGBASE_API_KEY" \
-H "Content-Type: 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",
},
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",
],
"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"}
],
json: %{data: %{type: "verifications", attributes: attributes}}
).body
Response — 201 Created
{
"data": {
"type": "verifications",
"id": "vrf_Xb3F29W4UVfJWBPBV2qY5t",
"attributes": {
"status": "pending",
"inserted_at": "2026-06-08T12:34:56.123456Z"
},
"relationships": {
"session": { "data": { "type": "sessions", "id": "ses_VUojMdqsFoywqi2yR4re37" } },
"tag": { "data": { "type": "tags", "id": "tag_RKJi9LYU4zzSVBZNUZ743M" } }
}
}
}
Persist id, status, and the session id on your side as soon as you receive
them — there’s no endpoint to re-fetch a verification later.
Errors
| Status | When |
|---|---|
400 |
The body has no data.attributes object. |
401 |
Missing, invalid, or revoked key. |
404 |
No such tag under your account, or the tag isn’t written to a chip yet. |
422 |
The scan couldn’t be recorded — a rare transactional error; 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.
The two-scan flow
- First scan. POST the tap parameters with no
tagbase_session_id. You get a verification withstatus: "pending"and a newsessionid. Store that id against the scanning visitor (their session or a cookie) — the two scans are linked only because you re-present this id, not by anything the platform tracks about the device. - Second scan. POST the next tap’s parameters with
tagbase_session_idset to that session id. The verdict resolves tovalid(genuine) orinvalid(a copy).
The second scan must arrive within 10 minutes of the first. After that the
flow expires; a later scan starts a fresh pending session instead of resolving
the old one.
If you omit tagbase_session_id entirely, every scan creates a new pending
session — useful only for one-step checks where you accept the weaker guarantee
described under two-scan liveness.