# TAGBASE Developer Documentation > Build product authentication on TAGBASE: dynamic NFC tags and the APIs to register tags and verify scans. # Overview Trust infrastructure for physical objects, and what you can build on it. TAGBASE is trust infrastructure for physical objects. We give every item an **uncopyable, easily verifiable digital identity**, so anyone can confirm a product is genuine with a single tap of their phone, no app required. This documentation is for developers building on top of that foundation. It covers the concepts you need, a quickstart to your first integration, the solutions you can assemble, and the API reference. ## What you can build The platform exposes one identity layer that many kinds of solution sit on top of: - **Verify**: TAGBASE's own anti-counterfeiting product, built on this very platform. It's the reference for what you can build, and you can use it ready-made. - **Custom**: your own solution, [built directly against the API](/docs/guides/building-a-solution) the same way. That's what this documentation is for. The same tap-to-prove identity underpins a lot of ground: presence checks that confirm someone or something was somewhere at a given time, cashless payments at festivals, NFC signature stickers for signing documents, event access control, supply-chain track & trace, and digital product passports. Each is a different way of reading the platform's trust signals. ## How verification works Each item carries a **dynamic NFC tag** bound to its digital identity. A tap produces scan data that your solution forwards to the platform; the platform validates it and returns a trusted verdict in milliseconds. ## Where to start - [Resource model](/docs/concepts/resource-model): how teams, tags, sessions, and verifications relate. - [Get started](/docs/quickstart/get-started): your first verification in three API calls. - [Building a solution](/docs/guides/building-a-solution): an end-to-end walkthrough on top of the platform. --- # Resource model How teams, tags, sessions, and verifications fit together. The platform exposes a small set of resources. Understanding how they relate is enough to build almost any solution on top. ## The resources - **Team**: who owns things. Your integration authenticates as a team. A team can create **subteams** beneath it, so you can give each customer or tenant its own isolated space and its own key. - **API key**: how a team authenticates. A key belongs to exactly one team and only ever sees that team's data. - **Tag**: the digital identity bound to a physical item. Tags belong to a team. A tag is what gets scanned. - **Session**: one verification flow against a tag. A session groups the scans that make up a single check. - **Verification**: the verdict for a scan, one of `pending`, `valid`, or `invalid`. Verifications belong to a session. ## How they relate ``` Team ──┬── owns ──▶ API key (one team, many keys) └── owns ──▶ Tag (one team, many tags) Tag ──── has ──▶ Session ──── has ──▶ Verification (one scan flow) (the verdict) ``` - A **team** owns its **tags** and its **API keys**. - A **tag** accumulates **sessions**, one per scan flow. - A **session** holds the **verifications** produced as the flow resolves: a `pending`, later upgraded to `valid` or `invalid`. ## Subteams: multi-tenant by design Teams nest. The team that owns your API key is your **root team**; every team you create through the API becomes a **subteam** of it, with a fresh key of its own. This is the tenancy primitive. A solution typically provisions one subteam per end customer, stores that subteam's key, and provisions that customer's tags under it. Because a key only sees its own team's tags, tenants are isolated from each other automatically: a scan validated with one subteam's key can only ever resolve a tag that subteam owns. Teams and subteams exist for two reasons: - **Sandbox separation.** Sandbox is a property of the *team*, never of an individual tag. A team is either a sandbox team or a production team, so production tags and sandbox tags can never appear side by side in the same team. - **Billing and quota boundaries.** A team is the unit a bill attaches to: a team could be a company, a customer, a legal entity. A multi-tenant solution that needs separate billing per tenant is structured correctly by giving each tenant its own team. Teams are strict access boundaries, and access is never inherited: a dashboard user sees only the teams they are a member of. Root-team membership grants nothing on its subteams: each membership is granted per team, explicitly. ## Identifiers Every resource has a prefixed, URL-safe id you'll see throughout the API: | Resource | Id prefix | Example | |--------------|-----------|------------------------| | Team | `tea_` | `tea_abcdef0123456789` | | API key | `key_` | `key_abcdef0123456789` | | Tag | `tag_` | `tag_abcdef0123456789` | | Session | `ses_` | `ses_abcdef0123456789` | | Verification | `vrf_` | `vrf_abcdef0123456789` | The prefix tells you the type at a glance; treat the whole string as an opaque identifier. --- # Get started Authenticate, provision a tag, and run your first verification. This quickstart takes you from zero to a first verification using the live API. You'll authenticate, provision a tag, and submit a scan. It covers the API surface and the verdicts you get back. The whole public API is three `POST` endpoints under `https://platform.tagbase.io/api/v1`. Every request carries your API key and the JSON:API content type. Each call below is shown in `curl`, JavaScript (`fetch`), PHP ([Guzzle](https://docs.guzzlephp.org)), and Elixir ([Req](https://hexdocs.pm/req)). Pick your language with the tabs. ## 1. Authenticate You authenticate as a team with a bearer key. Don't have one yet? [Sign up for a free sandbox team](/dashboard/sign-in) and mint a key from the dashboard. Sandbox tags are virtual, so you can run this whole guide without any hardware. Put the key in an environment variable so it never lands in your shell history: ```bash export TAGBASE_API_KEY="key_abcdef0123456789:superstrongrandomsecret" ``` See [Authentication](/docs/api/authentication) for the key format and where keys come from. ## 2. Provision a tag Create a tag under your team. You get back the tag's id, which you'll keep and reference when verifying scans. ```bash cURL curl https://platform.tagbase.io/api/v1/tags \ -X POST \ -H "Authorization: Bearer $TAGBASE_API_KEY" \ -H "Content-Type: application/vnd.api+json" \ -d '{ "data": [ { "type": "tags", "attributes": { "protocol": "ntag_424_dna", "url": "https://zannatherapeutics.com/verify/lonafen/8a3f9c2b" } } ] }' ``` ```js const res = await fetch("https://platform.tagbase.io/api/v1/tags", { method: "POST", headers: { "Authorization": `Bearer ${process.env.TAGBASE_API_KEY}`, "Content-Type": "application/vnd.api+json", }, body: JSON.stringify({ data: [{ type: "tags", attributes: { protocol: "ntag_424_dna", url: "https://zannatherapeutics.com/verify/lonafen/8a3f9c2b" } }], }), }); const tag = (await res.json()).data[0]; ``` ```php $response = $client->post("https://platform.tagbase.io/api/v1/tags", [ "headers" => [ "Authorization" => "Bearer " . getenv("TAGBASE_API_KEY"), "Content-Type" => "application/vnd.api+json", ], "json" => [ "data" => [["type" => "tags", "attributes" => ["protocol" => "ntag_424_dna", "url" => "https://zannatherapeutics.com/verify/lonafen/8a3f9c2b"]]], ], ]); $tag = json_decode((string) $response->getBody(), true)["data"][0]; ``` ```elixir %{"data" => [tag]} = Req.post!("https://platform.tagbase.io/api/v1/tags", headers: [ {"authorization", "Bearer #{System.fetch_env!("TAGBASE_API_KEY")}"}, {"content-type", "application/vnd.api+json"} ], json: %{data: [%{type: "tags", attributes: %{protocol: "ntag_424_dna", url: "https://zannatherapeutics.com/verify/lonafen/8a3f9c2b"}}]} ).body ``` ```json { "data": [ { "type": "tags", "id": "tag_abcdef0123456789", "attributes": { "url": "https://zannatherapeutics.com/verify/lonafen/8a3f9c2b" } } ] } ``` `protocol` selects the chip type. See [Tags](/docs/api/tags) for the supported values, the full request, and the tag lifecycle. ## 3. Run a verification Once a tag has been written to a physical chip, a tap produces a URL with scan parameters in its query string. Forward those parameters to the platform and read back the verdict: ```bash cURL curl https://platform.tagbase.io/api/v1/tags/tag_abcdef0123456789/verifications \ -X POST \ -H "Authorization: Bearer $TAGBASE_API_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 ${process.env.TAGBASE_API_KEY}`, "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 " . getenv("TAGBASE_API_KEY"), "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 #{System.fetch_env!("TAGBASE_API_KEY")}"}, {"content-type", "application/vnd.api+json"} ], json: %{data: %{type: "verifications", attributes: attributes}} ).body ``` ```json { "data": { "type": "verifications", "id": "vrf_abcdef0123456789", "attributes": { "status": "pending", "inserted_at": "2026-06-08T12:34:56.123456Z" }, "relationships": { "session": { "data": { "type": "sessions", "id": "ses_abcdef0123456789" } }, "tag": { "data": { "type": "tags", "id": "tag_abcdef0123456789" } } } } } ``` The scan returns `pending` and opens a [session](/docs/api/sessions). To finish the check, forward another tap carrying that session id as the `session` relationship: ```bash cURL curl https://platform.tagbase.io/api/v1/tags/tag_abcdef0123456789/verifications \ -X POST \ -H "Authorization: Bearer $TAGBASE_API_KEY" \ -H "Content-Type: application/vnd.api+json" \ -d '{ "data": { "type": "verifications", "attributes": { "...": "...tap URL params..." }, "relationships": { "session": { "data": { "type": "sessions", "id": "ses_abcdef0123456789" } } } } }' ``` ```js const res = await fetch( `https://platform.tagbase.io/api/v1/tags/${tagId}/verifications`, { method: "POST", headers: { "Authorization": `Bearer ${process.env.TAGBASE_API_KEY}`, "Content-Type": "application/vnd.api+json", }, body: JSON.stringify({ data: { type: "verifications", attributes: tapParams, relationships: { session: { data: { type: "sessions", id: sessionId } } }, }, }), }, ); const verification = await res.json(); ``` ```php $response = $client->post( "https://platform.tagbase.io/api/v1/tags/{$tagId}/verifications", [ "headers" => [ "Authorization" => "Bearer " . getenv("TAGBASE_API_KEY"), "Content-Type" => "application/vnd.api+json", ], "json" => ["data" => [ "type" => "verifications", "attributes" => $tapParams, "relationships" => ["session" => ["data" => ["type" => "sessions", "id" => $sessionId]]], ]], ], ); $verification = json_decode((string) $response->getBody(), true); ``` ```elixir verification = Req.post!("https://platform.tagbase.io/api/v1/tags/#{tag_id}/verifications", headers: [ {"authorization", "Bearer #{System.fetch_env!("TAGBASE_API_KEY")}"}, {"content-type", "application/vnd.api+json"} ], json: %{ data: %{ type: "verifications", attributes: tap_params, relationships: %{session: %{data: %{type: "sessions", id: session_id}}} } } ).body ``` ```json { "data": { "type": "verifications", "id": "vrf_MqiFaFwAs6U1pu5ALCxa5M", "attributes": { "status": "valid", "inserted_at": "2026-06-08T12:34:58.654321Z" }, "relationships": { "session": { "data": { "type": "sessions", "id": "ses_abcdef0123456789" } }, "tag": { "data": { "type": "tags", "id": "tag_abcdef0123456789" } } } } } ``` `status: "valid"` means a genuine tag. That's a complete verification. > **Testing.** Your own code is testable offline: since the verification > `attributes` are just the inbound query string passed through, you can unit-test > your tap handler and session branching with any synthetic parameters. What you > can't fake is a real `valid`/`invalid` verdict: that needs a tag TAGBASE has > provisioned and written to a physical chip. There's no public sandbox, so ask > TAGBASE for test tags to exercise the flow end to end. ## Next steps - [Resource model](/docs/concepts/resource-model): how teams, tags, sessions, and verifications relate. - [Verifications](/docs/api/verifications): the full scan-to-verdict sequence. - [Building a solution](/docs/guides/building-a-solution): an end-to-end walkthrough that puts it all together. --- # Conventions Base URL, content type, identifiers, and error format. The platform API is a JSON:API over HTTPS. Every endpoint follows the same conventions, so once you've made one request the rest are predictable. ## Base URL ``` https://platform.tagbase.io/api/v1 ``` All paths in this reference are relative to that base. ## Content type Send `Content-Type: application/vnd.api+json` on any request with a body. It's the expected media type, and if you send a *different* one the request is rejected. (Omitting it is tolerated, but sending it is best practice.) `Accept` is optional; responses always come back as `application/vnd.api+json` regardless. If you do send `Accept`, use the same media type. ``` Content-Type: application/vnd.api+json ``` Request bodies are a single JSON:API document, a `data` object carrying a `type` and an `attributes` map: ```json { "data": { "type": "tags", "attributes": { "...": "..." } } } ``` ## Identifiers Every resource id is a URL-safe string with a type prefix (`team_`, `key_`, `tag_`, `ses_`, `vrf_`). The prefix identifies the resource type; treat the full string as opaque. ## Errors Errors come back as a JSON:API `errors` array. Each entry has an HTTP `status` and a short `title`: ```json { "errors": [ { "status": "401", "title": "Unauthorized" } ] } ``` The HTTP response status matches the `status` field. The statuses you'll see: | Status | Meaning | |--------|-------------------------------------------------------------------| | `400` | The request body is missing or malformed (no `data.attributes`, or an out-of-range value). | | `401` | Missing, invalid, or revoked API key. | | `404` | The resource doesn't exist under your team. | | `422` | The request was well-formed but could not be processed (validation failed). | Error bodies carry only `status` and `title`. There's no `detail` or `source` pointer naming the attribute that failed. A `422` tells you the request was rejected, not which field to fix, so when one is unexpected, check your request against the relevant endpoint's reference (for example, that a tag `protocol` is one of the supported values). ## Idempotency The API has no idempotency keys, and every `POST` creates a new resource each time it's called. Retrying a request that already succeeded (after a timeout or a dropped connection) creates a **duplicate**: another batch of tags, another subteam, or another verification. So retry deliberately. Treat a network-level failure (no response, a timeout) as "unknown", not "failed": before retrying a create, confirm on your side whether the first attempt actually landed, or accept and reconcile the possibility of a duplicate. A response you *did* receive tells you what to do: - `400`, `401`, `404` are **definitive**: the same request will fail the same way, so fix the request rather than retry it. - `422` means nothing was recorded, so retrying the identical request is safe and won't create a duplicate. A `422` that keeps recurring is a problem to escalate, not a transient blip. - `201` succeeded. Don't resend it, or you'll create a duplicate. ## Rate limiting Requests are throttled per client IP at **600 requests per minute** across the API. Exceeding the limit returns a plain-text `429 Too Many Requests` (not a JSON:API error document), and no rate-limit headers are sent, so there's nothing to inspect ahead of time: back off and retry after a pause. The limit is generous for interactive traffic; batch work (like registering hundreds of tags) should pace itself rather than burst. ## What the API does not have (yet) Keeping this explicit so you don't design around features that aren't there: - **No list endpoints.** Each resource can be retrieved individually by id: `GET /tags/:id`, `GET /verifications/:id`, and so on (see the "Retrieve a…" section on each resource's page). But there is no way to list, search, or paginate a collection. If you didn't keep the id, you can't find the resource again. - **Webhooks** push event notifications (e.g. when a tag is written) to a URL you register. They're the one place the platform calls *you*. See [Webhooks](/docs/api/webhooks). Scan verdicts still come back in the response to the verification request you made, not via a callback. - **No sandbox or test mode for verifications.** A verification only succeeds against a tag that's been written to a physical chip, one whose lifecycle has reached `configured` (see [Tags](/docs/api/tags)). A freshly provisioned tag returns `404` until then, and there's no API to simulate a scan or mint a pre-configured test tag. Exercising the verification path end to end means using real written hardware. When you need to find a resource again later, store its id at the moment you receive it. The retrieve endpoints can re-fetch the rest. Verdicts and session ids are still yours to persist when they arrive. --- # Authentication API keys, bearer tokens, scope, and auth errors. Every request is authenticated with an **API key** belonging to a team. The key both identifies the team and scopes what it can see. ## Obtaining a key There are two ways in, depending on what you're after: **Just want to build and try things?** [Sign up for a free sandbox team](/dashboard/sign-in) and mint a key right from the dashboard. Sandbox tags are virtual: they're provisioned instantly with everything a physical tag would carry, and every scan is emulated in the browser (no hardware, no shipping, no waiting). You can build your entire integration against the sandbox, from the first API request to a full verification flow. **Ready for production?** Production teams and their physical dynamic NFC tags are provisioned by us: [contact TAGBASE](mailto:contact@tagbase.io) to get your root team and its API keys. Your integration carries over unchanged: sandbox and production speak exactly the same API. From there you're self-service for tenants: use your root team's key to [create a subteam](/docs/api/teams) for each customer or tenant, and the response hands back that subteam's own key. So you get your root team and key to begin with, and mint the rest yourself. ## Key format A key is two parts, a public key id and a secret, joined by a colon: ``` key_abcdef0123456789:superstrongrandomsecret ``` You receive the full string **once**, when the key is minted (see [Teams](/docs/api/teams)). The platform stores only a hash of the secret and can never show it to you again. Treat the whole string as a credential: store it somewhere secret, never commit it, never put it in a URL. ## Sending the key Pass the full `key_id:secret` string as a bearer token: ```bash cURL curl https://platform.tagbase.io/api/v1/tags \ -X POST \ -H "Authorization: Bearer key_abcdef0123456789:superstrongrandomsecret" \ -H "Content-Type: application/vnd.api+json" \ -d '{ "data": [ { "type": "tags", "attributes": { "protocol": "ntag_424_dna", "url": "https://zannatherapeutics.com/verify/lonafen/8a3f9c2b" } } ] }' ``` ```js await fetch("https://platform.tagbase.io/api/v1/tags", { method: "POST", headers: { "Authorization": "Bearer key_abcdef0123456789:superstrongrandomsecret", "Content-Type": "application/vnd.api+json", }, body: JSON.stringify({ data: [{ type: "tags", attributes: { protocol: "ntag_424_dna", url: "https://zannatherapeutics.com/verify/lonafen/8a3f9c2b" } }], }), }); ``` ```php $client->post("https://platform.tagbase.io/api/v1/tags", [ "headers" => [ "Authorization" => "Bearer key_abcdef0123456789:superstrongrandomsecret", "Content-Type" => "application/vnd.api+json", ], "json" => [ "data" => [["type" => "tags", "attributes" => ["protocol" => "ntag_424_dna", "url" => "https://zannatherapeutics.com/verify/lonafen/8a3f9c2b"]]], ], ]); ``` ```elixir Req.post!("https://platform.tagbase.io/api/v1/tags", headers: [ {"authorization", "Bearer key_abcdef0123456789:superstrongrandomsecret"}, {"content-type", "application/vnd.api+json"} ], json: %{data: [%{type: "tags", attributes: %{protocol: "ntag_424_dna", url: "https://zannatherapeutics.com/verify/lonafen/8a3f9c2b"}}]} ) ``` ## Scope A key sees **only its own team's resources**. This is the isolation boundary for [subteams](/docs/concepts/resource-model): a subteam's key can create and verify tags under that subteam and nothing else. Presenting a key for a tag owned by a different team returns `404 Not Found`: the platform doesn't distinguish "not yours" from "doesn't exist". ## Auth errors A missing, malformed, revoked, or unrecognized key returns `401`: ```json { "errors": [ { "status": "401", "title": "Unauthorized" } ] } ``` This covers every failure mode: no `Authorization` header, a header that isn't `Bearer :`, a secret that doesn't match, or a key that has been revoked. ## Rotation Each team can hold more than one active key, so you can rotate without downtime: provision the replacement, move your traffic over, then retire the old one. > Self-service key management endpoints (create / list / revoke a key on an > existing team) are not part of the public API yet. Today a key is minted > together with its team. Until they ship, rotation on an existing team is > handled by TAGBASE. Plan key storage so swapping the value is a config change > on your side. --- # Teams Create subteams and mint their API keys. A **team** owns tags and API keys. The team that owns your key is your **root team**; teams you create through the API are **subteams** of it, the unit of multi-tenancy. Creating a subteam also mints its first API key. > **Migrating from the accounts API?** Teams were previously called accounts. > The old `/api/v1/accounts` routes (and `acc_`-prefixed ids) keep working > until **2026-10-14** and answer with `Deprecation`/`Sunset` headers plus a > `meta.deprecation` notice. Please update to `/api/v1/teams` (resource type > `teams`, id prefix `tea_`) as soon as possible. ## When to use subteams Subteams are **optional**, and for most integrations the answer is *no*: you can do everything with your root team's key. Reach for one only when you have a concrete separation requirement. What a subteam isolates is **tags and the keys that read them**: a key sees only its own team's tags, and a tag lookup or verification across teams returns `404`. It's also the billing and quota boundary: a subteam per customer gives each tenant its own bill. Common reasons to create one: - **Per-customer isolation (multi-tenant / reseller).** You serve multiple independent customers whose tags must stay walled off from each other: one subteam per customer, each with its own key. - **Business-unit or regional separation.** One organization that needs hard separation between legal entities or regions. - **Environment separation.** A dedicated subteam for test/staging tags so they never mix with production (useful even for a single-customer integration). Skip them if you can't point to two parties (or environments) that must *not* see each other's tags. A single application with one set of tags should just use the root team directly; a subteam would only add key management for no benefit. **Decide before you provision at scale.** A tag is owned permanently by the team that created it, and there's no endpoint to move it to another team later. So if a tenant or environment will ever need its own isolated tags, create its subteam *first* and provision under that subteam's key: you can't retrofit the split onto tags that already exist. ## Fields | Field | Type | Notes | |--------|--------|----------------------------------------------| | `id` | string | `tea_`-prefixed, assigned by the platform. | | `name` | string | Required. 1 to 100 characters. | A subteam is always created beneath the team whose key you present; you don't pass a parent: it's inferred from the key. ## Create a subteam ``` POST /api/v1/teams ``` ### Request ```json { "data": { "type": "teams", "attributes": { "name": "Metropolitan Museum — Night Watch" } } } ``` `name` is the only required attribute. The request also accepts optional profile attributes if you want to store them on the team: `company`, `vat`, `country`, `address`, `city`, `state`, `zip_code`. ```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": "Metropolitan Museum — Night Watch" } } }' ``` ```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: "Metropolitan Museum — Night Watch" } }, }), }); const team = await res.json(); ``` ```php $response = $client->post("https://platform.tagbase.io/api/v1/teams", [ "headers" => [ "Authorization" => "Bearer " . getenv("TAGBASE_API_KEY"), "Content-Type" => "application/vnd.api+json", ], "json" => [ "data" => ["type" => "teams", "attributes" => ["name" => "Metropolitan Museum — Night Watch"]], ], ]); $team = json_decode((string) $response->getBody(), true); ``` ```elixir team = Req.post!("https://platform.tagbase.io/api/v1/teams", headers: [ {"authorization", "Bearer #{System.fetch_env!("TAGBASE_API_KEY")}"}, {"content-type", "application/vnd.api+json"} ], json: %{data: %{type: "teams", attributes: %{name: "Metropolitan Museum — Night Watch"}}} ).body ``` ### Response: `201 Created` The new subteam, plus its freshly minted API key as an `included` [api_keys](/docs/api/api-keys) resource. **The key's `secret` is shown here and nowhere else.** Capture it now. ```json { "data": { "type": "teams", "id": "tea_abcdef0123456789", "attributes": { "name": "Metropolitan Museum — Night Watch" }, "relationships": { "api_keys": { "data": [ { "type": "api_keys", "id": "key_abcdef0123456789" } ] } } }, "included": [ { "type": "api_keys", "id": "key_abcdef0123456789", "attributes": { "secret": "key_abcdef0123456789:superstrongrandomsecret" } } ] } ``` Store `included[].attributes.secret` immediately: it's the credential you'll use for every request made on behalf of this subteam. ### Errors | Status | When | |--------|---------------------------------------------------------------| | `400` | The body has no `data.attributes` object. | | `401` | Missing, invalid, or revoked key. | | `422` | Validation failed (e.g. `name` is missing or out of length). | ## Create a join link ``` POST /api/v1/teams/join-token ``` Mints a short-lived link that lets whoever opens it join the team that owns your API key, after signing in to (or signing up for) the TAGBASE platform. Surface it in your own app when your users need direct platform access to the team holding their tags, for example to write tags with the mobile writer app. The request takes no body. ### Response ```json { "meta": { "token": "…", "expires_in": 600, "url": "https://platform.tagbase.io/dashboard/teams/join/…" } } ``` The link expires after ten minutes. It is not single-use: several people can join through the same link while it lasts, so share it only through channels you trust. Anyone who opens it becomes a member of the team. ## Retrieve a team ``` GET /api/v1/teams/:id ``` Fetch your own team, or a subteam you created. The key you present must own the team (be its root team, or be the team itself). Otherwise the platform responds `404`. ```bash cURL curl https://platform.tagbase.io/api/v1/teams/tea_abcdef0123456789 \ -H "Authorization: Bearer $TAGBASE_API_KEY" \ -H "Accept: application/vnd.api+json" ``` ```js const res = await fetch( "https://platform.tagbase.io/api/v1/teams/tea_abcdef0123456789", { headers: { "Authorization": `Bearer ${process.env.TAGBASE_API_KEY}`, "Accept": "application/vnd.api+json", }, }, ); const team = await res.json(); ``` ```php $response = $client->get("https://platform.tagbase.io/api/v1/teams/tea_abcdef0123456789", [ "headers" => [ "Authorization" => "Bearer " . getenv("TAGBASE_API_KEY"), "Accept" => "application/vnd.api+json", ], ]); $team = json_decode((string) $response->getBody(), true); ``` ```elixir team = Req.get!("https://platform.tagbase.io/api/v1/teams/tea_abcdef0123456789", headers: [ {"authorization", "Bearer #{System.fetch_env!("TAGBASE_API_KEY")}"}, {"accept", "application/vnd.api+json"} ] ).body ``` ### Response: `200 OK` The team, with its active [api_keys](/docs/api/api-keys) as relationship references. **Key secrets are never returned here**: a secret is shown only once, when the key is minted. Read a key's metadata with [`GET /api/v1/api_keys/:id`](/docs/api/api-keys). ```json { "data": { "type": "teams", "id": "tea_abcdef0123456789", "attributes": { "name": "Metropolitan Museum — Night Watch" }, "relationships": { "api_keys": { "data": [ { "type": "api_keys", "id": "key_abcdef0123456789" } ] } } } } ``` ### Errors | Status | When | |--------|----------------------------------------------------------------| | `401` | Missing, invalid, or revoked key. | | `404` | No such team, or it isn't owned by the team you present. | ## Notes - A created team can be fetched by id (above); there is no endpoint to list teams or to update one. Record the returned `id` and key when you create it. - Subteams nest one level beneath the presenting team; deeper hierarchies aren't exposed through the API. --- # API keys The credential resource: how keys are minted and presented. An **API key** is the credential a team authenticates with. A key belongs to exactly one team and only ever sees that team's resources. ## Fields | Field | Type | Notes | |----------------|--------|----------------------------------------------------------------| | `id` | string | `key_`-prefixed. The public part of the credential. | | `secret` | string | The full `key_id:secret` credential. Returned **once**, at mint time. | | `name` | string | A label for the key. | | `last_used_at` | string | ISO 8601 timestamp of the key's last authenticated request, or `null`. | | `revoked_at` | string | ISO 8601 timestamp when the key was revoked, or `null` while active. | | `inserted_at` | string | ISO 8601 timestamp when the key was minted. | The platform stores only a hash of the secret. After the response that mints a key, the `secret` is unrecoverable: if it's lost, the key must be replaced. ## How keys are minted Keys are not created through a standalone endpoint. Your root team's key is provisioned by TAGBASE when you're onboarded. See [Obtaining a key](/docs/api/authentication). Every key after that is minted automatically when you [create a subteam](/docs/api/teams), and returned as an `included` `api_keys` resource on that response: ```json { "type": "api_keys", "id": "key_abcdef0123456789", "attributes": { "secret": "key_abcdef0123456789:superstrongrandomsecret" } } ``` ## Presenting a key Send the full `secret` string as a bearer token on every request. See [Authentication](/docs/api/authentication) for details and error shapes. ``` Authorization: Bearer key_abcdef0123456789:superstrongrandomsecret ``` ## Retrieve an API key ``` GET /api/v1/api_keys/:id ``` Read a key's metadata: its label, when it was last used, and whether it's been revoked. The key must belong to the team you present, or to a subteam that team owns; otherwise the platform responds `404`. The **`secret` is never returned here.** It's shown only once, when the key is minted (see above); this endpoint exposes only metadata. ```bash cURL curl https://platform.tagbase.io/api/v1/api_keys/key_abcdef0123456789 \ -H "Authorization: Bearer $TAGBASE_API_KEY" \ -H "Accept: application/vnd.api+json" ``` ```js const res = await fetch( "https://platform.tagbase.io/api/v1/api_keys/key_abcdef0123456789", { headers: { "Authorization": `Bearer ${process.env.TAGBASE_API_KEY}`, "Accept": "application/vnd.api+json", }, }, ); const key = await res.json(); ``` ```php $response = $client->get("https://platform.tagbase.io/api/v1/api_keys/key_abcdef0123456789", [ "headers" => [ "Authorization" => "Bearer " . getenv("TAGBASE_API_KEY"), "Accept" => "application/vnd.api+json", ], ]); $key = json_decode((string) $response->getBody(), true); ``` ```elixir key = Req.get!("https://platform.tagbase.io/api/v1/api_keys/key_abcdef0123456789", headers: [ {"authorization", "Bearer #{System.fetch_env!("TAGBASE_API_KEY")}"}, {"accept", "application/vnd.api+json"} ] ).body ``` ### Response: `200 OK` ```json { "data": { "type": "api_keys", "id": "key_abcdef0123456789", "attributes": { "name": "Metropolitan Museum — Night Watch", "last_used_at": "2026-06-08T12:34:56.123456Z", "revoked_at": null, "inserted_at": "2026-06-01T09:00:00.000000Z" } } } ``` ### Errors | Status | When | |--------|-------------------------------------------------------------| | `401` | Missing, invalid, or revoked key. | | `404` | No such key, or it isn't owned by the team you present. | ## Lifecycle - A team can hold more than one active key, which is what lets you rotate without downtime. - A revoked key stops working immediately and authenticates as `401`. > You can read a key's metadata by id (above), but self-service endpoints to > create, list, or revoke a key on an existing team are not part of the public > API yet. Today a key is minted with its team; rotation and revocation on an > existing team are handled by TAGBASE. --- # Tags Provision the digital identities that get scanned. A **tag** is the dynamic NFC identity bound to a physical item. You provision tags under a team; later, each tag is written onto a physical chip and, once in the field, scanned to produce [verifications](/docs/api/verifications). ## Fields | Field | Type | Notes | |------------|--------|-------------------------------------------------------------| | `id` | string | `tag_`-prefixed, assigned by the platform and returned at creation. | | `protocol` | string | Required at creation. The tag protocol to provision (see below). | | `url` | string | Required at creation. The address written to the chip and opened when the tag is scanned. You choose it. | | `comment` | string | Optional, max 50 characters. A human-readable label. Makes it easy to see what any tag is at a glance, e.g. a product name. Writer apps can display it to identify which physical item a tag belongs to. | | `session_duration` | integer | Optional, defaults to `600`. How long a [session](/docs/api/sessions) on this tag stays open between its first and second scan, in seconds (`1` to `3600`). | | `status` | string | Lifecycle state (see below). Returned when you retrieve a tag. | | `configured_at` | string | ISO 8601 timestamp when the tag was written to a chip, or `null`. Returned when you retrieve a tag. | A tag also carries a lifecycle `status` that advances as the tag is manufactured: | Status | Meaning | |--------------|---------------------------------------------------------------| | `created` | Provisioned in the platform; not yet written to a chip. | | `configured` | Written to a chip and ready to be scanned in the field. | A tag can only be verified once it reaches `configured`. Before that it has no chip behind it, so a scan against it returns `404` (see [Verifications](/docs/api/verifications)). ### The `protocol` attribute `protocol` selects which tag protocol the chip uses. The currently supported values are: | Value | Chip | |------------------|--------------| | `ntag_424_dna` | NTAG 424 DNA | | `ntag_223_dna` | NTAG 223 DNA | Use the identifier (left column) as the `protocol` value. Any other value is rejected with `422`. ### The `url` attribute `url` is the address written onto the chip and opened when the tag is scanned: your verification landing page. **You choose it freely**: it can live on your own custom domain, in whatever shape you like. The platform writes exactly what you provide and enforces no format. The platform assigns the `id`, so the URL can't contain it. Keep your own mapping from each `url` you send to the `id` returned for it. ## Create tags ``` POST /api/v1/tags ``` Provision a batch of tags under the team whose key you present. Send a `protocol` and `url` for each; the platform assigns an `id` and returns it. Tags are stored in `created` status, and each one fires a [`tag.created` webhook](/docs/api/webhooks). ### Request A JSON:API **array** under `data`, **1 to 500** resources per request. Each entry: | Member | Type | Required | Notes | |------------------------|--------|----------|----------------------------------------| | `attributes.protocol` | string | yes | The tag protocol identifier. | | `attributes.url` | string | yes | The address to write to the chip. You choose it. | | `attributes.comment` | string | no | A human-readable label, max 50 characters. | | `attributes.session_duration` | integer | no | Session window in seconds, `1` to `3600`. Defaults to `600` (10 minutes). | ```json { "data": [ { "type": "tags", "attributes": { "protocol": "ntag_424_dna", "url": "https://zannatherapeutics.com/verify/lonafen/8a3f9c2b" } } ] } ``` ```bash cURL curl https://platform.tagbase.io/api/v1/tags \ -X POST \ -H "Authorization: Bearer $TAGBASE_API_KEY" \ -H "Content-Type: application/vnd.api+json" \ -d '{ "data": [ { "type": "tags", "attributes": { "protocol": "ntag_424_dna", "url": "https://zannatherapeutics.com/verify/lonafen/8a3f9c2b" } } ] }' ``` ```js const res = await fetch("https://platform.tagbase.io/api/v1/tags", { method: "POST", headers: { "Authorization": `Bearer ${process.env.TAGBASE_API_KEY}`, "Content-Type": "application/vnd.api+json", }, body: JSON.stringify({ data: [ { type: "tags", attributes: { protocol: "ntag_424_dna", url: "https://zannatherapeutics.com/verify/lonafen/8a3f9c2b", }, }, ], }), }); const tags = (await res.json()).data; ``` ```php $response = $client->post("https://platform.tagbase.io/api/v1/tags", [ "headers" => [ "Authorization" => "Bearer " . getenv("TAGBASE_API_KEY"), "Content-Type" => "application/vnd.api+json", ], "json" => [ "data" => [[ "type" => "tags", "attributes" => [ "protocol" => "ntag_424_dna", "url" => "https://zannatherapeutics.com/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 #{System.fetch_env!("TAGBASE_API_KEY")}"}, {"content-type", "application/vnd.api+json"} ], json: %{ data: [ %{ type: "tags", attributes: %{ protocol: "ntag_424_dna", url: "https://zannatherapeutics.com/verify/lonafen/8a3f9c2b" } } ] } ).body["data"] ``` ### Response: `201 Created` A JSON:API **array** of the created tags. Each entry pairs the platform-assigned `id` with the `url` it was created from, so you can map them back to your records. ```json { "data": [ { "type": "tags", "id": "tag_abcdef0123456789", "attributes": { "url": "https://zannatherapeutics.com/verify/lonafen/8a3f9c2b", "comment": null, "session_duration": 600 } } ] } ``` Tags are returned in `created` status. Writing them onto physical chips happens separately and asynchronously; your integration holds the ids in the meantime and learns when each tag advances through [webhooks](/docs/api/webhooks): `tag.configured` when it's written and ready to scan, `tag.configuration_failed` if a write fails. ### Errors | Status | When | |--------|---------------------------------------------------------------------| | `400` | `data` is not an array of 1 to 500 entries, or any entry is missing `protocol` or `url`. | | `401` | Missing, invalid, or revoked key. | | `422` | Validation failed, e.g. a duplicate `url` or an unrecognized `protocol`. | ## Retrieve a tag ``` GET /api/v1/tags/:id ``` Fetch a tag you provisioned, including its current lifecycle `status`. The key you present must own the tag, or the platform responds `404`. ```bash cURL curl https://platform.tagbase.io/api/v1/tags/tag_abcdef0123456789 \ -H "Authorization: Bearer $TAGBASE_API_KEY" \ -H "Accept: application/vnd.api+json" ``` ```js const res = await fetch( "https://platform.tagbase.io/api/v1/tags/tag_abcdef0123456789", { headers: { "Authorization": `Bearer ${process.env.TAGBASE_API_KEY}`, "Accept": "application/vnd.api+json", }, }, ); const tag = await res.json(); ``` ```php $response = $client->get("https://platform.tagbase.io/api/v1/tags/tag_abcdef0123456789", [ "headers" => [ "Authorization" => "Bearer " . getenv("TAGBASE_API_KEY"), "Accept" => "application/vnd.api+json", ], ]); $tag = json_decode((string) $response->getBody(), true); ``` ```elixir tag = Req.get!("https://platform.tagbase.io/api/v1/tags/tag_abcdef0123456789", headers: [ {"authorization", "Bearer #{System.fetch_env!("TAGBASE_API_KEY")}"}, {"accept", "application/vnd.api+json"} ] ).body ``` ### Response: `200 OK` ```json { "data": { "type": "tags", "id": "tag_abcdef0123456789", "attributes": { "url": "https://zannatherapeutics.com/verify/lonafen/8a3f9c2b", "comment": "Lonafen 50mg", "session_duration": 600, "status": "configured", "configured_at": "2026-06-08T12:34:56.123456Z" } } } ``` Polling this endpoint is a fallback for tracking a tag's lifecycle; [webhooks](/docs/api/webhooks) are the push alternative and fire as the tag advances. ### Errors | Status | When | |--------|-----------------------------------| | `401` | Missing, invalid, or revoked key. | | `404` | No such tag under your team. | ## Update a tag ``` PATCH /api/v1/tags/:id ``` Update a tag's mutable attributes. The `comment` and `session_duration` are always editable: they live only in the platform, not on the chip. The `url` can only change while the tag is still `created`; once `configured` it is physically on the chip and locked, and a `url` change responds `422`. ### Request A single JSON:API resource under `data`: ```json { "data": { "type": "tags", "id": "tag_abcdef0123456789", "attributes": { "comment": "Lonafen 50mg" } } } ``` ```bash cURL curl https://platform.tagbase.io/api/v1/tags/tag_abcdef0123456789 \ -X PATCH \ -H "Authorization: Bearer $TAGBASE_API_KEY" \ -H "Content-Type: application/vnd.api+json" \ -d '{ "data": { "type": "tags", "id": "tag_abcdef0123456789", "attributes": { "comment": "Lonafen 50mg" } } }' ``` ```js const res = await fetch( "https://platform.tagbase.io/api/v1/tags/tag_abcdef0123456789", { method: "PATCH", headers: { "Authorization": `Bearer ${process.env.TAGBASE_API_KEY}`, "Content-Type": "application/vnd.api+json", }, body: JSON.stringify({ data: { type: "tags", id: "tag_abcdef0123456789", attributes: { comment: "Lonafen 50mg" }, }, }), }, ); const tag = await res.json(); ``` ```php $response = $client->patch("https://platform.tagbase.io/api/v1/tags/tag_abcdef0123456789", [ "headers" => [ "Authorization" => "Bearer " . getenv("TAGBASE_API_KEY"), "Content-Type" => "application/vnd.api+json", ], "json" => [ "data" => [ "type" => "tags", "id" => "tag_abcdef0123456789", "attributes" => ["comment" => "Lonafen 50mg"], ], ], ]); $tag = json_decode((string) $response->getBody(), true); ``` ```elixir tag = Req.patch!("https://platform.tagbase.io/api/v1/tags/tag_abcdef0123456789", headers: [ {"authorization", "Bearer #{System.fetch_env!("TAGBASE_API_KEY")}"}, {"content-type", "application/vnd.api+json"} ], json: %{ data: %{ type: "tags", id: "tag_abcdef0123456789", attributes: %{comment: "Lonafen 50mg"} } } ).body ``` ### Response: `200 OK` The updated tag, in the same shape as [Retrieve a tag](#retrieve-a-tag). ### Errors | Status | When | |--------|------------------------------------------------------------------| | `400` | The body is not a single JSON:API resource with `attributes`. | | `401` | Missing, invalid, or revoked key. | | `404` | No such tag under your team. | | `422` | Validation failed, e.g. the `url` of a `configured` tag, a duplicate `url`, a `comment` over 50 characters, or a `session_duration` outside `1` to `3600`. | ## Notes - A tag can be fetched by id (above); there is no endpoint to list tags. Persist the returned ids when you create the batch. - Tags belong to the team whose key created them. To keep tenants isolated, create each tenant's tags with that tenant's [subteam](/docs/api/teams) key. --- # Verifications Submit a scan and read back the authenticity verdict. A **verification** is the platform's verdict for a scan. When someone taps a tag, your solution forwards the scan to the platform and receives a verification that says whether the tag is genuine. This is the core call your integration makes. ## How scanning works Your solution owns the scan entry point. A tag's chip is programmed with a URL that points at *your* app (`https:///?`), so a tap arrives as a request to you, with the tag id in the path and the scan parameters in the query string. You then hand the scan to the platform, which validates it and records the verification. ``` tap → your app receives the scan → POST .../verifications → verdict ``` ## Fields | Field | Type | Notes | |---------------|--------|----------------------------------------------------| | `id` | string | `vrf_`-prefixed, assigned by the platform. | | `status` | string | The verdict (see below). | | `inserted_at` | string | ISO 8601 timestamp (UTC, microsecond precision). | A verification also references its [session](/docs/api/sessions) and its [tag](/docs/api/tags) as relationships. ### Status values | Status | Meaning | |-------------------|-----------------------------------------------------------------| | `pending` | Scan accepted; the session hasn't resolved to a final verdict yet. | | `valid` | Genuine: the tag verified successfully. | | `invalid` | Failed: the scan didn't check out. | A verification you create comes back as exactly one of these three. ## Submit a scan ``` POST /api/v1/tags/:tag_id/verifications ``` `:tag_id` is the tag id of the scanned tag. The key you present must own that tag, or the platform responds `404`. This posts one scan at a time. If you already hold both scans of a tag, you can [submit them together](#submit-both-scans-at-once) and resolve in a single call. ### Request The `attributes` are **the tap URL's query string, parsed into key/value pairs: every parameter, unchanged**. When a tag is tapped, its chip produces a URL whose query string carries the data for that tap; your entry point receives it, and you copy the whole parsed query string into `attributes`. You never name or interpret those parameters: pass them through verbatim. To continue a session an earlier verification returned, set the `session` relationship to its id; omit `relationships` entirely to start a fresh one. | Field | Required | Notes | |--------------------------------|----------|------------------------------------------------------| | `attributes` | yes | The scan parameters, copied from the tap URL's query string. | | `relationships.session` | no | The session to continue. Omit to start a new one. | Here's a scan that starts a fresh session, with just the forwarded tap parameters and no `session` relationship (to continue a session you'd add one alongside them): ```json { "data": { "type": "verifications", "attributes": { "...": "...scan parameters copied from the tap URL..." } } } ``` To continue a session, add the `session` relationship: ```json { "data": { "type": "verifications", "attributes": { "...": "...scan parameters copied from the tap URL..." }, "relationships": { "session": { "data": { "type": "sessions", "id": "ses_abcdef0123456789" } } } } } ``` ```bash cURL curl https://platform.tagbase.io/api/v1/tags/tag_abcdef0123456789/verifications \ -X POST \ -H "Authorization: Bearer $TAGBASE_API_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 ${process.env.TAGBASE_API_KEY}`, "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 " . getenv("TAGBASE_API_KEY"), "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 #{System.fetch_env!("TAGBASE_API_KEY")}"}, {"content-type", "application/vnd.api+json"} ], json: %{data: %{type: "verifications", attributes: attributes}} ).body ``` ### Response: `201 Created` ```json { "data": { "type": "verifications", "id": "vrf_abcdef0123456789", "attributes": { "status": "pending", "inserted_at": "2026-06-08T12:34:56.123456Z" }, "relationships": { "session": { "data": { "type": "sessions", "id": "ses_abcdef0123456789" } }, "tag": { "data": { "type": "tags", "id": "tag_abcdef0123456789" } } } } } ``` Persist `id`, `status`, and the `session` id on your side as soon as you receive them; you can also [re-fetch a verification by id](#retrieve-a-verification) later. ### Errors | Status | When | |--------|----------------------------------------------------------------------------| | `400` | The body has no `data.attributes` object. | | `401` | Missing, invalid, or revoked key. | | `404` | No such tag under your team, or the tag isn't written to a chip yet. | | `422` | The scan couldn't be recorded; nothing was saved, so retrying the same request is safe. **Not** a failed security check. | > Note that a tag that fails its security check still returns `201` with > `status: "invalid"`. That's a successful verification with a negative verdict. > `422` means the request itself couldn't be processed, not that the tag is fake. ## Sessions and resolution - A scan posted **without** a `session` relationship opens a new session: the verification comes back `pending` with a `session` id. Store that id against the scanning visitor (their session or a cookie). Scans are linked only because you re-present this id, not by anything the platform tracks about the device. - A scan posted **with** a `session` relationship continues that session, and the verdict resolves to `valid` or `invalid`. A session stays open for the tag's `session_duration`: **10 minutes** unless you [configured the tag](/docs/api/tags) otherwise. After that it expires; a later scan referencing it starts a fresh `pending` session instead of resolving the old one. ## Submit both scans at once The flow above spans two requests glued by a session. If you already hold **both** scans of a tag (for example a client that buffered taps while offline and is now syncing), you can submit them together and get the final verdict in a single call, with no `pending` step and no second round-trip. Send `data` as an **array of exactly two** scans instead of a single object. Each entry is a `verifications` resource whose `attributes` are one scan's tap parameters, copied verbatim, the same pass-through as a single scan. Order matters: the first entry is the first tap, the second is the second tap. You don't set a `session` relationship; the call resolves on its own. ### Request ```json { "data": [ { "type": "verifications", "attributes": { "...": "...first tap params..." } }, { "type": "verifications", "attributes": { "...": "...second tap params..." } } ] } ``` ```bash cURL curl https://platform.tagbase.io/api/v1/tags/tag_abcdef0123456789/verifications \ -X POST \ -H "Authorization: Bearer $TAGBASE_API_KEY" \ -H "Content-Type: application/vnd.api+json" \ -d '{ "data": [ { "type": "verifications", "attributes": { "...": "...first tap params..." } }, { "type": "verifications", "attributes": { "...": "...second tap params..." } } ] }' ``` ### Response: `201 Created` You get a `data` **array** of the verification records the call created, in scan order. The **last** entry carries the final verdict (`valid` or `invalid`). Read your authenticity result from it. ```json { "data": [ { "type": "verifications", "id": "vrf_abcdef0123456789", "attributes": { "status": "pending", "inserted_at": "2026-06-08T12:34:56.123456Z" }, "relationships": { "session": { "data": { "type": "sessions", "id": "ses_abcdef0123456789" } }, "tag": { "data": { "type": "tags", "id": "tag_abcdef0123456789" } } } }, { "type": "verifications", "id": "vrf_bcdefa1234567890", "attributes": { "status": "valid", "inserted_at": "2026-06-08T12:34:56.789012Z" }, "relationships": { "session": { "data": { "type": "sessions", "id": "ses_abcdef0123456789" } }, "tag": { "data": { "type": "tags", "id": "tag_abcdef0123456789" } } } } ] } ``` As with the single-scan call, persist each `id` and `status` on receipt; each is also [retrievable by id](#retrieve-a-verification). ### Errors Same as a single scan, plus one shape check: | Status | When | |--------|-------------------------------------------------------------------------------| | `400` | `data` is an array but doesn't hold exactly two scans, or an entry is missing its `attributes`. | ## Retrieve a verification ``` GET /api/v1/verifications/:id ``` Fetch a verification by id: its `status` and its [session](/docs/api/sessions) and [tag](/docs/api/tags) relationships. The key you present must own the underlying tag, or the platform responds `404`. ```bash cURL curl https://platform.tagbase.io/api/v1/verifications/vrf_abcdef0123456789 \ -H "Authorization: Bearer $TAGBASE_API_KEY" \ -H "Accept: application/vnd.api+json" ``` ```js const res = await fetch( "https://platform.tagbase.io/api/v1/verifications/vrf_abcdef0123456789", { headers: { "Authorization": `Bearer ${process.env.TAGBASE_API_KEY}`, "Accept": "application/vnd.api+json", }, }, ); const verification = await res.json(); ``` ```php $response = $client->get("https://platform.tagbase.io/api/v1/verifications/vrf_abcdef0123456789", [ "headers" => [ "Authorization" => "Bearer " . getenv("TAGBASE_API_KEY"), "Accept" => "application/vnd.api+json", ], ]); $verification = json_decode((string) $response->getBody(), true); ``` ```elixir verification = Req.get!("https://platform.tagbase.io/api/v1/verifications/vrf_abcdef0123456789", headers: [ {"authorization", "Bearer #{System.fetch_env!("TAGBASE_API_KEY")}"}, {"accept", "application/vnd.api+json"} ] ).body ``` ### Response: `200 OK` ```json { "data": { "type": "verifications", "id": "vrf_abcdef0123456789", "attributes": { "status": "valid", "inserted_at": "2026-06-08T12:34:56.123456Z" }, "relationships": { "session": { "data": { "type": "sessions", "id": "ses_abcdef0123456789" } }, "tag": { "data": { "type": "tags", "id": "tag_abcdef0123456789" } } } } } ``` ### Errors | Status | When | |--------|------------------------------------------| | `401` | Missing, invalid, or revoked key. | | `404` | No such verification under your team. | --- # Sessions The unit that groups related scans into one verification flow. A **session** represents a single verification flow against one tag. It's the thread that connects one scan to the next so the platform can resolve them into one verdict. ## Fields | Field | Type | Notes | |---------------|--------|---------------------------------------------| | `id` | string | `ses_`-prefixed, assigned by the platform. | | `inserted_at` | string | ISO 8601 timestamp when the session opened. | A session belongs to a [tag](/docs/api/tags) and groups the [verifications](/docs/api/verifications) produced as the flow resolves: a `pending`, then its `valid` / `invalid` resolution. ## Where sessions come from You don't create a session directly. The platform opens one for you when a scan arrives without a `session` relationship and returns its id in the verification response, under `data.relationships.session`: ```json "relationships": { "session": { "data": { "type": "sessions", "id": "ses_abcdef0123456789" } } } ``` ## Using a session Carry that session id back as the `session` relationship on a later scan so the platform continues the session rather than starting a new flow: ```json "relationships": { "session": { "data": { "type": "sessions", "id": "ses_abcdef0123456789" } } } ``` See [Verifications → Sessions and resolution](/docs/api/verifications) for the full sequence. A session is live for its tag's `session_duration`: **10 minutes** unless you [configured the tag](/docs/api/tags) otherwise. A scan after that window opens a fresh session instead of resolving the old one. ## Retrieve a session ``` GET /api/v1/sessions/:id ``` Fetch a session by id, with its [tag](/docs/api/tags) as a relationship. The key you present must own that tag, or the platform responds `404`. ```bash cURL curl https://platform.tagbase.io/api/v1/sessions/ses_abcdef0123456789 \ -H "Authorization: Bearer $TAGBASE_API_KEY" \ -H "Accept: application/vnd.api+json" ``` ```js const res = await fetch( "https://platform.tagbase.io/api/v1/sessions/ses_abcdef0123456789", { headers: { "Authorization": `Bearer ${process.env.TAGBASE_API_KEY}`, "Accept": "application/vnd.api+json", }, }, ); const session = await res.json(); ``` ```php $response = $client->get("https://platform.tagbase.io/api/v1/sessions/ses_abcdef0123456789", [ "headers" => [ "Authorization" => "Bearer " . getenv("TAGBASE_API_KEY"), "Accept" => "application/vnd.api+json", ], ]); $session = json_decode((string) $response->getBody(), true); ``` ```elixir session = Req.get!("https://platform.tagbase.io/api/v1/sessions/ses_abcdef0123456789", headers: [ {"authorization", "Bearer #{System.fetch_env!("TAGBASE_API_KEY")}"}, {"accept", "application/vnd.api+json"} ] ).body ``` ### Response: `200 OK` ```json { "data": { "type": "sessions", "id": "ses_abcdef0123456789", "attributes": { "inserted_at": "2026-06-08T12:34:56.123456Z" }, "relationships": { "tag": { "data": { "type": "tags", "id": "tag_abcdef0123456789" } } } } } ``` ### Errors | Status | When | |--------|-------------------------------------| | `401` | Missing, invalid, or revoked key. | | `404` | No such session under your team. | ## Notes - A session can be fetched by id (above); there is no endpoint to list sessions. Otherwise it appears only as a relationship on a verification. Hold the id on your side for as long as the flow runs. --- # Webhooks Get notified when things change. Signed, so you can trust them. Most of the API is request/response: you call, you read the result. **Webhooks** are the exception, the one place the platform calls *you*. When something happens to one of your tags, we POST an event to a URL you've registered, so your integration learns about changes it didn't initiate (most importantly, a tag being written to a chip out in the field). Every delivery is **signed**. The signature is what makes a webhook trustworthy, so the rule is simple: **if a request isn't validly signed, reject it.** Never act on an unsigned or mis-signed request. ## Registering a webhook Webhooks are configured per team in the back office. When you add one, a **signing secret** (`whsec_…`) is generated and shown **once**. Store it; it's the key you'll verify deliveries with. A team can have several webhooks; each has its own secret and can be disabled or deleted. A webhook receives events **only for its own team's tags**. Register it on the team whose tags you want to hear about. ## Events Each event has a stable `type`. The catalog today: | Type | When | |--------------------|------------------------------------------------------------------| | `tag.created` | A tag is provisioned under your team. | | `tag.configured` | A tag is written to a chip and reaches `configured`, ready to scan. | | `tag.configuration_failed` | A configuration attempt failed; the tag stays unconfigured. | More types may be added over time. Treat unknown `type`s as a no-op rather than erroring, so new events never break your receiver. ## Payload Deliveries are `POST`ed as `application/vnd.api+json`, in the same [JSON:API](/docs/api/conventions) shape as the rest of the API. The affected resource is the top-level `data`; the event envelope (id, type, timestamp) lives in `meta.event`: ```json { "data": { "type": "tags", "id": "tag_abcdef0123456789", "attributes": { "url": "https://zannatherapeutics.com/verify/lonafen/8a3f9c2b", "status": "configured", "protocol": "ntag_424_dna" } }, "meta": { "event": { "id": "evt_9f8c2b1a4d6e0f3a72", "type": "tag.configured", "created_at": "2026-06-28T12:30:00.000000Z" } } } ``` The example above is a `tag.configured` delivery. Every event uses the same shape: only `data.attributes.status` and `meta.event.type` differ. A `tag.created` delivery looks like: ```json { "data": { "type": "tags", "id": "tag_abcdef0123456789", "attributes": { "url": "https://zannatherapeutics.com/verify/lonafen/8a3f9c2b", "status": "created", "protocol": "ntag_424_dna" } }, "meta": { "event": { "id": "evt_1b2c3d4e5f6a7b8c90", "type": "tag.created", "created_at": "2026-06-28T12:00:00.000000Z" } } } ``` Dispatch on `meta.event.type`. `meta.event.id` is the event's unique id. Use it to deduplicate (see [Delivery & retries](#delivery-and-retries)). For `tag.configuration_failed`, `meta.event` also carries a `reason`. ## Verifying the signature Every request carries a `Tagbase-Hmac-SHA256` header, the Base64-encoded **HMAC-SHA256** of the raw request body, keyed by your webhook's signing secret: ``` Tagbase-Hmac-SHA256: 5dPp1Lq8w4l8m2p0r3s5t7v9x1z3B5D7F9H1J3L5N7P= ``` To verify, recompute the HMAC over the **raw request body** (the exact bytes: don't re-serialize the parsed JSON, or whitespace/key-order differences will break the check), Base64-encode it, and constant-time compare it to the header. ```elixir defmodule Receiver do def valid?(raw_body, presented, secret) do expected = :hmac |> :crypto.mac(:sha256, secret, raw_body) |> Base.encode64() Plug.Crypto.secure_compare(expected, presented) end end ``` ```js import crypto from "node:crypto"; function valid(rawBody, presented, secret) { const expected = crypto .createHmac("sha256", secret) .update(rawBody, "utf8") .digest("base64"); return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(presented)); } ``` ```php The signature covers the body only; use the event `id` (next section) to ignore anything you've already processed. Respond `2xx` once you've accepted the event. Any other status (or a timeout) is treated as a failure and retried. ## Delivery and retries - **At-least-once.** A delivery that doesn't get a `2xx` is retried with exponential backoff. The same event may therefore arrive more than once: **dedupe on the event `id`** and make handling idempotent. - **Order isn't guaranteed.** Don't assume events arrive in the order they occurred; use `created_at` if you need to reason about timing. - **Disabling.** Disable a webhook in the back office to pause deliveries without losing its configuration; delete it to stop permanently. ## Security checklist - Reject any request without a valid `Tagbase-Hmac-SHA256`. This is the only proof the request came from us. - Compare signatures in constant time. - Dedupe on the event `id` so a replayed delivery is a no-op. - Keep your signing secret server-side; rotate it (delete + re-add the webhook) if it's ever exposed. --- # 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](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 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 [subteam](/docs/api/teams) | | Checkpoint (a station) | A [tag](/docs/api/tags) (+ a `checkpoints` row you own) | | Tapping a checkpoint | A [verification](/docs/api/verifications) | | A checkpoint confirmation | A [session](/docs/api/sessions) | | A patrol round / visit log | Rows in *your* database | ## Step 1: Provision a tenant Each museum gets its own subteam, 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. ```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": "Metropolitan Museum — Night Watch" } } }' ``` ```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: "Metropolitan Museum — Night Watch" } }, }), }); 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" => "Metropolitan Museum — Night Watch"]], ], ]); $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: "Metropolitan Museum — Night Watch"}}} ).body ``` ```json { "data": { "type": "teams", "id": "tea_abcdef0123456789", "attributes": { "name": "Metropolitan Museum — Night Watch" }, "relationships": { "api_keys": { "data": [ { "type": "api_keys", "id": "key_abcdef0123456789" } ] } } }, "included": [ { "type": "api_keys", "id": "key_abcdef0123456789", "attributes": { "secret": "key_abcdef0123456789:superstrongrandomsecret" } } ] } ``` Save `data.id` as the museum's team id and `included[0].attributes.secret` as its API key. From here on, every call about this museum's checkpoints uses **that subteam's key**, not your root team's key. ## Step 2: Register tags to checkpoints When you place a checkpoint on the route, provision a tag under the museum's subteam and store the mapping. Send a `url` per checkpoint as an array. The platform assigns each tag's `id` and returns it next to the url you sent. ```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://verify.tagbase.io/north-door" } } ] }' ``` ```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://verify.tagbase.io/north-door", }, }, ], }), }); 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://verify.tagbase.io/north-door", ], ]], ], ]); $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://verify.tagbase.io/north-door" } } ] } ).body["data"] ``` ```json { "data": [ { "type": "tags", "id": "tag_abcdef0123456789", "attributes": { "url": "https://verify.tagbase.io/north-door" } } ] } ``` Persist each returned tag id against the checkpoint it belongs to: ``` checkpoints id chk_a1 name "Hall of Antiquities — North Door" tagbase_tag tag_abcdef0123456789 team tea_abcdef0123456789 ``` The physical chips are written separately; once a checkpoint's tag is `configured` (see [Tags](/docs/api/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:///?`, where the query string carries the data for that tap. So a guard'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://nightwatch.example.com/t/tag_abcdef0123456789? ``` 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: ```js JavaScript // 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 // ...pass a stored session id to continue a session (below)... const status = await verify(req.params.tagId, attributes, checkpoint.teamKey); // render based on status }); ``` ```php // GET /t/{tagId} $checkpoint = checkpoints_find_by_tag($tagId); // your data $attributes = $_GET; // the parsed query string, verbatim $status = verify($tagId, $attributes, $checkpoint['team_key']); ``` ```elixir # 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.team_key) # render based on status end ``` 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 guard **and** this tag, 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): ```bash cURL curl https://platform.tagbase.io/api/v1/tags/tag_abcdef0123456789/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 ``` ```json { "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 continue the flow rather than start a new one. **Continuing the flow.** Carry the session id back as the `session` relationship: ```bash cURL curl https://platform.tagbase.io/api/v1/tags/tag_abcdef0123456789/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_abcdef0123456789" } } } } }' ``` ```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 ``` ```json { "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 verification resolves `invalid`, 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 subteam 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; a checkpoint counts only once its flow resolves `valid`, 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.