TAGBASE

Guides

Building a solution

An end-to-end walkthrough: a pack authentication app built on the platform.

This guide builds a real solution on the platform end to end. The running example is a pack authentication app for pharmaceutical brands. The first brand on it is Zanna Therapeutics, whose Lonafen packs each carry an NFC tag. A pharmacist taps the pack at the counter to check that it is genuine. It maps cleanly onto the platform: a pack is a tag, a pack check 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 subteam 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 pack, which pharmacy, at what time) 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 “which packs were checked today?”. You learn each verdict from the response to the verification you submit, and you persist your own records. For the Lonafen app: the platform validates the tap; your database holds the packs and the check rows.

Map the domain

Lonafen app concept Platform concept
Zanna Therapeutics (the tenant) A subteam
A Lonafen pack A tag (+ a packs row you own)
Tapping a pack A verification
A pack check A session
A pharmacy’s check log Rows in your database

Step 1: Provision a tenant

Each brand gets its own subteam, so its packs are isolated from every other tenant’s. Create it once, when you onboard the brand, and store the returned key: it’s shown only here.

curl https://platform.tagbase.io/api/v1/teams \
-X POST \
-H "Authorization: Bearer $TAGBASE_API_KEY" \
-H "Content-Type: application/vnd.api+json" \
-d '{ "data": { "type": "teams", "attributes": { "name": "Zanna Therapeutics" } } }'
{
"data": {
"type": "teams",
"id": "tea_UUkZTHiaPbRKiiJ4VLfCgF",
"attributes": { "name": "Zanna Therapeutics" },
"relationships": {
"api_keys": {
"data": [ { "type": "api_keys", "id": "key_VfUttEQbh1ceW7jtwV66rF" } ]
}
}
},
"included": [
{ "type": "api_keys", "id": "key_VfUttEQbh1ceW7jtwV66rF", "attributes": { "secret": "key_VfUttEQbh1ceW7jtwV66rF:superstrongrandomsecret" } }
]
}

Save data.id as the brand’s team id and included[0].attributes.secret as its API key. From here on, every call about Zanna’s packs uses that subteam’s key, not your root team’s key.

Step 2: Register tags to packs

For each pack you tag, provision a tag under Zanna’s subteam and store the mapping. Send a url per pack as an array. The platform assigns each tag’s id and returns it next to the url you sent.

curl https://platform.tagbase.io/api/v1/tags \
-X POST \
-H "Authorization: Bearer $SUBTEAM_KEY" \
-H "Content-Type: application/vnd.api+json" \
-d '{ "data": [ { "type": "tags", "attributes": { "protocol": "ntag_424_dna", "url": "https://zanna.example/verify/lonafen/8a3f9c2b" } } ] }'
{ "data": [ { "type": "tags", "id": "tag_XPg3Zq7gEExNezDWWW7Zzo", "attributes": { "url": "https://zanna.example/verify/lonafen/8a3f9c2b" } } ] }

Persist each returned tag id against the pack it belongs to:

packs
id pack_a1
product "Lonafen"
tagbase_tag tag_XPg3Zq7gEExNezDWWW7Zzo
team tea_UUkZTHiaPbRKiiJ4VLfCgF

The physical chips are written separately; once a pack’s tag is configured (see Tags) it can be tapped at the counter.

Step 3: Verify a tap (a pack check)

Your app owns the URL the chip is programmed with. Each pack’s chip is written with https://<your-entry-point>/<tag_id>?<scan parameters>, where the query string carries the data for that tap. So a pharmacist’s tap lands on your server as an ordinary request, with the tag id in the path and the scan parameters in the query string:

GET https://verify.zanna.example/t/tag_XPg3Zq7gEExNezDWWW7Zzo?<scan parameters>

Your handler reads the tag id from the path, looks up which pack 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 pack = await packs.findByTag(req.params.tagId); // your data
const attributes = { ...req.query }; // the parsed query string, verbatim
// ...pass a stored session id to continue a session (below)...
const status = await verify(req.params.tagId, attributes, pack.teamKey);
// render based on status
});

The attributes are only ever the forwarded scan parameters. A session you’re continuing rides alongside them as the session relationship (next section), not as an attribute.

(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.)

Every tap arrives as the same GET /t/:tag_id request, so your handler decides whether it starts or continues a flow:

  • If you have a session id stored for this pharmacist and this tag that is less than 10 minutes old, send it as the session relationship.
  • Otherwise, send no session relationship.

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.

Starting a flow (no session id yet):

curl https://platform.tagbase.io/api/v1/tags/tag_XPg3Zq7gEExNezDWWW7Zzo/verifications \
-X POST \
-H "Authorization: Bearer $SUBTEAM_KEY" \
-H "Content-Type: application/vnd.api+json" \
-d '{ "data": { "type": "verifications", "attributes": { "...": "...tap URL params..." } } }'
{
"data": {
"type": "verifications",
"id": "vrf_AoNAiMHGpUb2fxAB93eM4R",
"attributes": { "status": "pending", "inserted_at": "2026-06-08T22:00:00.000000Z" },
"relationships": {
"session": { "data": { "type": "sessions", "id": "ses_2cZuH8Cb7L5fseae6FzNDc" } },
"tag": { "data": { "type": "tags", "id": "tag_XPg3Zq7gEExNezDWWW7Zzo" } }
}
}
}

Store the returned session id for this pharmacist and prompt them to tap again: that stored id is what makes the next tap continue the flow rather than start a new one.

Continuing the flow. Carry the session id back as the session relationship:

curl https://platform.tagbase.io/api/v1/tags/tag_XPg3Zq7gEExNezDWWW7Zzo/verifications \
-X POST \
-H "Authorization: Bearer $SUBTEAM_KEY" \
-H "Content-Type: application/vnd.api+json" \
-d '{ "data": { "type": "verifications", "attributes": { "...": "...tap URL params..." }, "relationships": { "session": { "data": { "type": "sessions", "id": "ses_2cZuH8Cb7L5fseae6FzNDc" } } } } }'
{
"data": {
"type": "verifications",
"id": "vrf_XBUGqbnP8BzQ7yJAmyrtaw",
"attributes": { "status": "valid", "inserted_at": "2026-06-08T22:00:08.000000Z" },
"relationships": {
"session": { "data": { "type": "sessions", "id": "ses_2cZuH8Cb7L5fseae6FzNDc" } },
"tag": { "data": { "type": "tags", "id": "tag_XPg3Zq7gEExNezDWWW7Zzo" } }
}
}
}

Every verification response carries the session relationship (including this one), so you can confirm the returned ses_2cZuH8Cb7L5fseae6FzNDc matches the session you sent and know the flow resolved rather than starting over.

status: "valid" is your green light. Now write the check record in your own database. The platform doesn’t store it for you:

checks
pack pack_a1
pharmacy pharmacy_77
checked_at 2026-06-08T22:00:08Z
tagbase_tag tag_XPg3Zq7gEExNezDWWW7Zzo
session ses_2cZuH8Cb7L5fseae6FzNDc

If the verification resolves invalid, reject the pack and surface a “could not verify this tag” message: the pharmacist has no proof that the pack is genuine.

Step 4: Reporting

A pack is trusted once its check resolves valid at the counter; an invalid check is a pack to flag. Because check rows live in your database, all of the reporting (which packs were checked and when, which failed, per pharmacy, per day) is ordinary querying on your side. The platform’s job ended when it returned the verdict.

Recap

  • One subteam per tenant gives you isolation for free.
  • Tags are the platform’s handle on your physical things; you keep the mapping from tag id to pack.
  • A tap becomes a verification; a pack counts as genuine only once its flow resolves valid.
  • 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.