# 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](https://docs.guzzlephp.org)** for PHP (`$client = new GuzzleHttp\Client()`),
and **[Req](https://hexdocs.pm/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](/docs/api/teams)                          |
| A Lonafen pack                | A [tag](/docs/api/tags) (+ a `packs` row you own)       |
| Tapping a pack                | A [verification](/docs/api/verifications)               |
| A pack check                  | A [session](/docs/api/sessions)                         |
| 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.

<!-- tabs -->
```bash cURL
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" } } }'
```
```js
const res = await fetch("https://platform.tagbase.io/api/v1/teams", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.TAGBASE_API_KEY}`,
    "Content-Type": "application/vnd.api+json",
  },
  body: JSON.stringify({
    data: { type: "teams", attributes: { name: "Zanna Therapeutics" } },
  }),
});
const team = await res.json();
```
```php
$response = $client->post("https://platform.tagbase.io/api/v1/teams", [
    "headers" => [
        "Authorization" => "Bearer {$apiKey}",
        "Content-Type"  => "application/vnd.api+json",
    ],
    "json" => [
        "data" => ["type" => "teams", "attributes" => ["name" => "Zanna Therapeutics"]],
    ],
]);
$team = json_decode((string) $response->getBody(), true);
```
```elixir
team =
  Req.post!("https://platform.tagbase.io/api/v1/teams",
    headers: [
      {"authorization", "Bearer #{api_key}"},
      {"content-type", "application/vnd.api+json"}
    ],
    json: %{data: %{type: "teams", attributes: %{name: "Zanna Therapeutics"}}}
  ).body
```
<!-- /tabs -->

```json
{
  "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.

<!-- tabs -->
```bash cURL
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" } } ] }'
```
```js
const res = await fetch("https://platform.tagbase.io/api/v1/tags", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${subteamKey}`,
    "Content-Type": "application/vnd.api+json",
  },
  body: JSON.stringify({
    data: [
      {
        type: "tags",
        attributes: {
          protocol: "ntag_424_dna",
          url: "https://zanna.example/verify/lonafen/8a3f9c2b",
        },
      },
    ],
  }),
});
const tags = (await res.json()).data;
```
```php
$response = $client->post("https://platform.tagbase.io/api/v1/tags", [
    "headers" => [
        "Authorization" => "Bearer {$subteamKey}",
        "Content-Type"  => "application/vnd.api+json",
    ],
    "json" => [
        "data" => [[
            "type" => "tags",
            "attributes" => [
                "protocol" => "ntag_424_dna",
                "url"      => "https://zanna.example/verify/lonafen/8a3f9c2b",
            ],
        ]],
    ],
]);
$tags = json_decode((string) $response->getBody(), true)["data"];
```
```elixir
tags =
  Req.post!("https://platform.tagbase.io/api/v1/tags",
    headers: [
      {"authorization", "Bearer #{subteam_key}"},
      {"content-type", "application/vnd.api+json"}
    ],
    json: %{
      data: [
        %{
          type: "tags",
          attributes: %{
            protocol: "ntag_424_dna",
            url: "https://zanna.example/verify/lonafen/8a3f9c2b"
          }
        }
      ]
    }
  ).body["data"]
```
<!-- /tabs -->

```json
{ "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](/docs/api/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:

<!-- tabs -->
```js JavaScript
// 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
});
```
```php
// GET /t/{tagId}
$pack       = packs_find_by_tag($tagId);  // your data
$attributes = $_GET;                      // the parsed query string, verbatim
$status     = verify($tagId, $attributes, $pack['team_key']);
```
```elixir
# Phoenix controller for GET /t/:tag_id
def show(conn, %{"tag_id" => tag_id} = params) do
  pack = Packs.get_by_tag!(tag_id)                    # your data
  attributes = Map.delete(params, "tag_id")           # the parsed query string, verbatim
  status = verify(tag_id, attributes, pack.team_key)
  # render based on status
end
```
<!-- /tabs -->

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):

<!-- tabs -->
```bash cURL
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..." } } }'
```
```js
const res = await fetch(
  `https://platform.tagbase.io/api/v1/tags/${tagId}/verifications`,
  {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${subteamKey}`,
      "Content-Type": "application/vnd.api+json",
    },
    body: JSON.stringify({ data: { type: "verifications", attributes } }),
  },
);
const verification = await res.json();
```
```php
$response = $client->post(
    "https://platform.tagbase.io/api/v1/tags/{$tagId}/verifications",
    [
        "headers" => [
            "Authorization" => "Bearer {$subteamKey}",
            "Content-Type"  => "application/vnd.api+json",
        ],
        "json" => ["data" => ["type" => "verifications", "attributes" => $attributes]],
    ],
);
$verification = json_decode((string) $response->getBody(), true);
```
```elixir
verification =
  Req.post!("https://platform.tagbase.io/api/v1/tags/#{tag_id}/verifications",
    headers: [
      {"authorization", "Bearer #{subteam_key}"},
      {"content-type", "application/vnd.api+json"}
    ],
    json: %{data: %{type: "verifications", attributes: attributes}}
  ).body
```
<!-- /tabs -->

```json
{
  "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:

<!-- tabs -->
```bash cURL
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" } } } } }'
```
```js
const res = await fetch(
  `https://platform.tagbase.io/api/v1/tags/${tagId}/verifications`,
  {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${subteamKey}`,
      "Content-Type": "application/vnd.api+json",
    },
    body: JSON.stringify({
      data: {
        type: "verifications",
        attributes: { ...req.query },
        relationships: { session: { data: { type: "sessions", id: storedSessionId } } },
      },
    }),
  },
);
const verification = await res.json();
```
```php
$response = $client->post(
    "https://platform.tagbase.io/api/v1/tags/{$tagId}/verifications",
    [
        "headers" => [
            "Authorization" => "Bearer {$subteamKey}",
            "Content-Type"  => "application/vnd.api+json",
        ],
        "json" => ["data" => [
            "type" => "verifications",
            "attributes" => $_GET,
            "relationships" => ["session" => ["data" => ["type" => "sessions", "id" => $storedSessionId]]],
        ]],
    ],
);
$verification = json_decode((string) $response->getBody(), true);
```
```elixir
verification =
  Req.post!("https://platform.tagbase.io/api/v1/tags/#{tag_id}/verifications",
    headers: [
      {"authorization", "Bearer #{subteam_key}"},
      {"content-type", "application/vnd.api+json"}
    ],
    json: %{
      data: %{
        type: "verifications",
        attributes: attributes,
        relationships: %{session: %{data: %{type: "sessions", id: stored_session_id}}}
      }
    }
  ).body
```
<!-- /tabs -->

```json
{
  "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.

