TAGBASE

Guides

Building a solution

An end-to-end walkthrough: a patrol check-in app built on the platform.

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:

  1. Own a subaccount per tenant so each customer’s tags are isolated.
  2. Provision tags for the physical things you track, and map each tag id to your own domain object.
  3. 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" } } }'
{
"data": { "type": "accounts", "id": "acc_abcdef0123456789", "attributes": { "name": "Metropolitan Museum — Night Watch" } },
"included": [
{ "type": "api_keys", "id": "key_abcdef0123456789", "attributes": { "secret": "key_abcdef0123456789:superstrongrandomsecret" } }
]
}

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 } } }'
{ "data": [ { "type": "tags", "id": "tag_abcdef0123456789" }, { "type": "tags", "id": "tag_0123456789abcdef" } ] }

Persist each tag id against the checkpoint it belongs to:

checkpoints
id chk_a1
name "Hall of Antiquities — North Door"
tagbase_tag tag_abcdef0123456789
account acc_abcdef0123456789

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_abcdef0123456789?<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
});

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_abcdef0123456789/verifications \
-X POST \
-H "Authorization: Bearer $SUBACCOUNT_KEY" \
-H "Content-Type: application/vnd.api+json" \
-d '{ "data": { "type": "verifications", "attributes": { "...": "...tap URL params..." } } }'
{
"data": {
"type": "verifications",
"id": "vrf_abcdef0123456789",
"attributes": { "status": "pending", "inserted_at": "2026-06-08T22:00:00.000000Z" },
"relationships": {
"session": { "data": { "type": "sessions", "id": "ses_abcdef0123456789" } },
"tag": { "data": { "type": "tags", "id": "tag_abcdef0123456789" } }
}
}
}

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_abcdef0123456789/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_abcdef0123456789" } } }'
{
"data": {
"type": "verifications",
"id": "vrf_MqiFaFwAs6U1pu5ALCxa5M",
"attributes": { "status": "valid", "inserted_at": "2026-06-08T22:00:08.000000Z" },
"relationships": {
"session": { "data": { "type": "sessions", "id": "ses_abcdef0123456789" } },
"tag": { "data": { "type": "tags", "id": "tag_abcdef0123456789" } }
}
}
}

Every verification response carries the session relationship — including this one — so you can confirm the returned ses_abcdef0123456789 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_abcdef0123456789
session ses_abcdef0123456789

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 valid only 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.
TAGBASE uses cookies to keep you signed in and protect against fraud. With your permission, we also measure how the site is used. Read our cookie policy for details.
Necessary
Analytics