TAGBASE

Building a solution

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

View as Markdown

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. You can read one verification back with GET /api/v1/verifications/:id, and one session with GET /api/v1/sessions/:id. There is no list endpoint, so “which packs were checked today?” is a query on your own rows. 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" \
  -H "Accept: 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. Put {id} in the url and the platform replaces it with the tag’s id, so each tap tells you which tag it came from. The response returns each id next to its final url.

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

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. You created each tag with url: "https://verify.zanna.example/t/{id}", and the platform put the tag’s id in place of {id}. On a tap, the chip adds the scan parameters as a query string. 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.

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. Store the new id.

The page you send the pharmacist to is a different address from the tap URL. Redirect there with the verification id, and read that verification back on reload. A reload does not POST the tap again. When the browser is in private browsing, ask them to leave private browsing and tap again. A pending status in that window is not a step that is still working. The brief to paste into a coding agent is on Verifications. The checks to run when the page is built are on Best practices.

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" \
  -H "Accept: 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" \
  -H "Accept: 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. A reload reads the verification back with GET /api/v1/verifications/:id.
  • Run the Best practices checklist when the page is built.