# 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/introduction) 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_XPg3Zq7gEExNezDWWW7Zzo",
    "attributes": {
      "url": "https://zanna.example/verify/lonafen/8a3f9c2b",
      "status": "configured",
      "protocol": "ntag_424_dna"
    }
  },
  "meta": {
    "event": {
      "id": "evt_HJgZqxGoqag8wYmFEC1dGf",
      "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_XPg3Zq7gEExNezDWWW7Zzo",
    "attributes": {
      "url": "https://zanna.example/verify/lonafen/8a3f9c2b",
      "status": "created",
      "protocol": "ntag_424_dna"
    }
  },
  "meta": {
    "event": {
      "id": "evt_YQUJM76zBx4jjtAYsDcnu1",
      "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.

<!-- tabs -->
```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
<?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);
}
```
<!-- /tabs -->

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.

