This guide builds a real solution on the platform end to end. The running example is Night Watch — a patrol check-in app for a museum. A guard walks a fixed route through the building and, at each checkpoint, taps an NFC tag to prove they physically passed it. It maps cleanly onto the platform: a checkpoint is a tag, a checkpoint visit is a verification.
Every platform call is shown in curl, JavaScript, PHP, and Elixir — pick your
language with the tabs. The snippets assume fetch (Node 18+ or the browser),
Guzzle for PHP ($client = new GuzzleHttp\Client()),
and Req for Elixir.
The shape of a solution
A solution on the platform always has the same three responsibilities:
- Own a subaccount per tenant so each customer’s tags are isolated.
- Provision tags for the physical things you track, and map each tag id to your own domain object.
- Own the scan entry point — the chip points at your app — and forward each scan to the platform for a verdict, recording the result on your side.
The platform is a stateless validation service. It tells you whether a scan is genuine; everything about what the scan means (which checkpoint, which guard, at what time on which round) lives in your application.
What the platform stores vs. what you store. The platform has no read or list endpoints — you can’t ask it later “what checkpoints were visited tonight?”. You learn each verdict from the response to the verification you submit, and you persist your own records. In Night Watch terms: the platform validates the tap; your database holds checkpoints, guards, and visit rows.
Map the domain
| Night Watch concept | Platform concept |
|---|---|
| Museum (the tenant) | A subaccount |
| Checkpoint (a station) | A tag (+ a checkpoints row you own) |
| Tapping a checkpoint | A verification |
| The two-tap confirmation | A session |
| A patrol round / visit log | Rows in your database |
Step 1 — Provision a tenant
Each museum gets its own subaccount, so its checkpoints are isolated from every other tenant’s. Create it once, when you onboard the museum, and store the returned key — it’s shown only here.
curl https://platform.tagbase.io/api/v1/accounts \
-X POST \
-H "Authorization: Bearer $TAGBASE_API_KEY" \
-H "Content-Type: application/vnd.api+json" \
-d '{ "data": { "type": "accounts", "attributes": { "name": "Metropolitan Museum — Night Watch" } } }'
const res = await fetch("https://platform.tagbase.io/api/v1/accounts", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.TAGBASE_API_KEY}`,
"Content-Type": "application/vnd.api+json",
},
body: JSON.stringify({
data: { type: "accounts", attributes: { name: "Metropolitan Museum — Night Watch" } },
}),
});
const account = await res.json();
$response = $client->post("https://platform.tagbase.io/api/v1/accounts", [
"headers" => [
"Authorization" => "Bearer {$apiKey}",
"Content-Type" => "application/vnd.api+json",
],
"json" => [
"data" => ["type" => "accounts", "attributes" => ["name" => "Metropolitan Museum — Night Watch"]],
],
]);
$account = json_decode((string) $response->getBody(), true);
account =
Req.post!("https://platform.tagbase.io/api/v1/accounts",
headers: [
{"authorization", "Bearer #{api_key}"},
{"content-type", "application/vnd.api+json"}
],
json: %{data: %{type: "accounts", attributes: %{name: "Metropolitan Museum — Night Watch"}}}
).body
{
"data": { "type": "accounts", "id": "acc_G5aNFLDZd6es1brCrj7QFi", "attributes": { "name": "Metropolitan Museum — Night Watch" } },
"included": [
{ "type": "api_keys", "id": "key_PD5omomyAxXscwKde4mpUS", "attributes": { "secret": "key_PD5omomyAxXscwKde4mpUS:MFV6n8TpDjNYfaLH7BE2W4Clyu8UOV6DqM3yFzxhhqs" } }
]
}
Save data.id as the museum’s account id and included[0].attributes.secret as
its API key. From here on, every call about this museum’s checkpoints uses that
subaccount’s key, not your master key.
Step 2 — Register tags to checkpoints
When you place a checkpoint on the route, provision a tag under the museum’s subaccount and store the mapping. Create them in bulk if you’re fitting out a whole building at once.
curl https://platform.tagbase.io/api/v1/tags \
-X POST \
-H "Authorization: Bearer $SUBACCOUNT_KEY" \
-H "Content-Type: application/vnd.api+json" \
-d '{ "data": { "type": "tags", "attributes": { "protocol": "<protocol>", "count": 10 } } }'
const res = await fetch("https://platform.tagbase.io/api/v1/tags", {
method: "POST",
headers: {
"Authorization": `Bearer ${subaccountKey}`,
"Content-Type": "application/vnd.api+json",
},
body: JSON.stringify({
data: { type: "tags", attributes: { protocol: "<protocol>", count: 10 } },
}),
});
const tags = (await res.json()).data;
$response = $client->post("https://platform.tagbase.io/api/v1/tags", [
"headers" => [
"Authorization" => "Bearer {$subaccountKey}",
"Content-Type" => "application/vnd.api+json",
],
"json" => [
"data" => ["type" => "tags", "attributes" => ["protocol" => "<protocol>", "count" => 10]],
],
]);
$tags = json_decode((string) $response->getBody(), true)["data"];
tags =
Req.post!("https://platform.tagbase.io/api/v1/tags",
headers: [
{"authorization", "Bearer #{subaccount_key}"},
{"content-type", "application/vnd.api+json"}
],
json: %{data: %{type: "tags", attributes: %{protocol: "<protocol>", count: 10}}}
).body["data"]
{ "data": [ { "type": "tags", "id": "tag_RKJi9LYU4zzSVBZNUZ743M" }, { "type": "tags", "id": "tag_TRFzfZMEzrpR6fKrk4Liur" } ] }
Persist each tag id against the checkpoint it belongs to:
checkpoints
id chk_a1
name "Hall of Antiquities — North Door"
tagbase_tag tag_RKJi9LYU4zzSVBZNUZ743M
account acc_G5aNFLDZd6es1brCrj7QFi
The physical chips are written separately; once a checkpoint’s tag is
configured (see Tags) it can be tapped on a round.
Step 3 — Verify a tap (a checkpoint visit)
Your app owns the URL the chip is programmed with. Each checkpoint’s chip is
written with https://<your-entry-point>/<tag_id>?<scan parameters>, where the
query string carries the scan proof for that tap. So a guard’s tap lands on
your server as an ordinary request — the tag id in the path, the scan
parameters in the query string:
GET https://nightwatch.example.com/t/tag_RKJi9LYU4zzSVBZNUZ743M?<scan parameters>
Your handler reads the tag id from the path, looks up which checkpoint it belongs
to, and forwards the scan to the platform. The verification attributes are
exactly the inbound query string, parsed into key/value pairs — every parameter,
unchanged. You never name or interpret those parameters; you copy the whole
parsed query string across. In practice that’s one line:
// Express-style handler for GET /t/:tag_id
app.get("/t/:tagId", async (req, res) => {
const checkpoint = await checkpoints.findByTag(req.params.tagId); // your data
const attributes = { ...req.query }; // the parsed query string, verbatim
// ...add tagbase_session_id here on a second tap (below)...
const status = await verify(req.params.tagId, attributes, checkpoint.accountKey);
// render based on status
});
// GET /t/{tagId}
$checkpoint = checkpoints_find_by_tag($tagId); // your data
$attributes = $_GET; // the parsed query string, verbatim
$status = verify($tagId, $attributes, $checkpoint['account_key']);
# Phoenix controller for GET /t/:tag_id
def show(conn, %{"tag_id" => tag_id} = params) do
checkpoint = Checkpoints.get_by_tag!(tag_id) # your data
attributes = Map.delete(params, "tag_id") # the parsed query string, verbatim
status = verify(tag_id, attributes, checkpoint.account_key)
# render based on status
end
The only key you ever add to attributes yourself is tagbase_session_id (next
section). Scan parameters never use that name, so it won’t collide.
(The entry-point hostname your chips point at is set up with TAGBASE when your tags are written; it isn’t part of the tag-creation request.)
Confirming a checkpoint is a two-tap flow — the second tap is the liveness
proof that a genuine tag is present, not a screenshot of an earlier tap. This is
what stops a guard from “phoning in” the round by replaying a checkpoint URL
saved earlier: only a physical tap on the live tag produces fresh proof. Both
taps arrive as the same GET /t/:tag_id request, so your handler decides which
is which:
- If you have a session id stored for this guard and this tag, less than 10
minutes old → it’s the second tap. Send that id as
tagbase_session_id. - Otherwise → it’s a first tap. Send no
tagbase_session_id.
You don’t have to get this exactly right: if you send a session id that’s stale or
belongs to a different tag, the platform just opens a fresh flow and returns a new
pending with a new session id. Compare the returned session id against the one
you sent to tell a resolved flow from a restarted one.
First tap — no session id yet:
curl https://platform.tagbase.io/api/v1/tags/tag_RKJi9LYU4zzSVBZNUZ743M/verifications \
-X POST \
-H "Authorization: Bearer $SUBACCOUNT_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 ${subaccountKey}`,
"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 {$subaccountKey}",
"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 #{subaccount_key}"},
{"content-type", "application/vnd.api+json"}
],
json: %{data: %{type: "verifications", attributes: attributes}}
).body
{
"data": {
"type": "verifications",
"id": "vrf_Xb3F29W4UVfJWBPBV2qY5t",
"attributes": { "status": "pending", "inserted_at": "2026-06-08T22:00:00.000000Z" },
"relationships": {
"session": { "data": { "type": "sessions", "id": "ses_VUojMdqsFoywqi2yR4re37" } },
"tag": { "data": { "type": "tags", "id": "tag_RKJi9LYU4zzSVBZNUZ743M" } }
}
}
}
Store the returned session id for this guard and prompt them to tap again — that stored id is what makes the next tap a second tap rather than a new one.
Second tap — carry the session id back as tagbase_session_id:
curl https://platform.tagbase.io/api/v1/tags/tag_RKJi9LYU4zzSVBZNUZ743M/verifications \
-X POST \
-H "Authorization: Bearer $SUBACCOUNT_KEY" \
-H "Content-Type: application/vnd.api+json" \
-d '{ "data": { "type": "verifications", "attributes": { "...": "...tap URL params...", "tagbase_session_id": "ses_VUojMdqsFoywqi2yR4re37" } } }'
const attributes = { ...req.query, tagbase_session_id: storedSessionId };
const res = await fetch(
`https://platform.tagbase.io/api/v1/tags/${tagId}/verifications`,
{
method: "POST",
headers: {
"Authorization": `Bearer ${subaccountKey}`,
"Content-Type": "application/vnd.api+json",
},
body: JSON.stringify({ data: { type: "verifications", attributes } }),
},
);
const verification = await res.json();
$attributes = $_GET + ["tagbase_session_id" => $storedSessionId];
$response = $client->post(
"https://platform.tagbase.io/api/v1/tags/{$tagId}/verifications",
[
"headers" => [
"Authorization" => "Bearer {$subaccountKey}",
"Content-Type" => "application/vnd.api+json",
],
"json" => ["data" => ["type" => "verifications", "attributes" => $attributes]],
],
);
$verification = json_decode((string) $response->getBody(), true);
attributes = Map.put(attributes, "tagbase_session_id", stored_session_id)
verification =
Req.post!("https://platform.tagbase.io/api/v1/tags/#{tag_id}/verifications",
headers: [
{"authorization", "Bearer #{subaccount_key}"},
{"content-type", "application/vnd.api+json"}
],
json: %{data: %{type: "verifications", attributes: attributes}}
).body
{
"data": {
"type": "verifications",
"id": "vrf_MqiFaFwAs6U1pu5ALCxa5M",
"attributes": { "status": "valid", "inserted_at": "2026-06-08T22:00:08.000000Z" },
"relationships": {
"session": { "data": { "type": "sessions", "id": "ses_VUojMdqsFoywqi2yR4re37" } },
"tag": { "data": { "type": "tags", "id": "tag_RKJi9LYU4zzSVBZNUZ743M" } }
}
}
}
Every verification response carries the session relationship — including this
one — so you can confirm the returned ses_VUojMdqsFoywqi2yR4re37 matches the session you sent and
know the flow resolved rather than starting over.
status: "valid" is your green light. Now write the visit record in your own
database — the platform doesn’t store it for you:
visits
checkpoint chk_a1
guard user_77
visited_at 2026-06-08T22:00:08Z
tagbase_tag tag_RKJi9LYU4zzSVBZNUZ743M
session ses_VUojMdqsFoywqi2yR4re37
If the second tap comes back invalid (a copied tag replaying an old tap, for
instance), reject the checkpoint and surface a “could not verify this tag”
message — the guard hasn’t proven they were there.
Step 4 — Completing the round and reporting
A patrol round is just a sequence of checkpoint visits. A round is complete when
every checkpoint on the guard’s route has a valid visit inside the shift
window; a missed or invalid checkpoint is a gap to flag. Because visit rows
live in your database, all of the reporting — which checkpoints were hit and
when, which were missed, per guard, per night — is ordinary querying on your
side. The platform’s job ended when it returned the verdict.
Recap
- One subaccount per tenant gives you isolation for free.
- Tags are the platform’s handle on your physical things; you keep the tag-id ↔ checkpoint mapping.
- A tap becomes a verification; the two-tap flow returns
validonly for a genuine, live tag — so a guard can’t fake a checkpoint they didn’t visit. - The platform validates; your application records and reports. Persist verdicts and session ids when you receive them — there’s no second chance to read them back.