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 an endpoint
Webhook endpoints are configured per account 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. An account can have several endpoints; each
has its own secret and can be disabled or deleted.
An endpoint receives events only for its own account’s tags — register it on the account 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 account. |
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 types as a no-op rather than
erroring, so new events never break your receiver.
Payload
Deliveries are POSTed as application/vnd.api+json, in the same
JSON:API 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:
{
"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:
{
"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). 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 endpoint’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.
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
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
function webhook_valid(string $rawBody, string $presented, string $secret): bool {
$expected = base64_encode(hash_hmac('sha256', $rawBody, $secret, true));
return hash_equals($expected, $presented);
}
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
2xxis retried with exponential backoff. The same event may therefore arrive more than once — dedupe on the eventidand make handling idempotent. - Order isn’t guaranteed. Don’t assume events arrive in the order they
occurred; use
created_atif you need to reason about timing. - Disabling. Disable an endpoint 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
idso a replayed delivery is a no-op. - Keep your signing secret server-side; rotate it (delete + re-add the endpoint) if it’s ever exposed.