# Webhooks

> We POST to your URL when something happens on your workspace.

We POST to your URL when something happens on your workspace. This page is the contract:
what arrives, how to verify it, and — the part worth reading twice — **what we do not
promise**.

## Until webhooks are switched on for your workspace

Webhooks are off until we switch them on, per workspace or for everyone. You can register
an endpoint and rotate its secret while they are off, but nothing is delivered: no request
is made and the endpoint's delivery log stays empty. The **Webhooks** section under **Apps
and keys** says when this is the case, and `GET /v1/workspace/webhooks` answers
`"enabled": false`. Once they are on, events from that moment are delivered; nothing that
happened while they were off is sent later.

On staging (`app-stg.batondeck.com`) webhooks are on by default, so an endpoint registered
there receives deliveries from the start; we can still switch them off for a workspace, and
`"enabled"` says which. Production starts with them off.

## One attempt. No retries.

**A delivery is attempted exactly once.** If your endpoint is slow, down, or answers
anything outside 2xx, that event is not sent again. The event passes through our own queue
on its way to you, and that queue does not retry it either: no second attempt, no backoff,
and no dead-letter replay.

That is a deliberate choice, and the trade is yours to plan around:

- **What you get**: a webhook sent soon after the event, not at the moment of it — the event
  waits in our queue first, usually for a few seconds and occasionally for much longer, because
  the queue does not bound that wait; then the one attempt is made — and a log in the portal
  showing every attempt, its HTTP status, its latency and the first 500 bytes your server
  answered with.
- **What you do not get**: a guarantee that every event reaches you. A network blip on
  your side loses that event.

**If you need certainty, treat the webhook as a hint and `/v1` as the record.** Poll the
resource the event names — `GET /v1/identities/:slug/threads`, `/contacts`, and so on —
and reconcile. A webhook tells you *when* to look; the API tells you *what is true*. If
you need a queue with retries, put one in front of your own endpoint: accept the POST,
enqueue it yourself, answer 200 immediately.

We may add at-least-once delivery later. We will not remove this page's promise without
telling you.

## Verifying a delivery

Every request carries:

| Header | What it is |
|---|---|
| `X-BatonDeck-Signature` | `t=<unix seconds>,v1=<hex>` — see below |
| `X-BatonDeck-Event-Id` | Stable per event. The same id never means two different things. |
| `X-BatonDeck-Event-Type` | e.g. `message.received` |
| `X-BatonDeck-Delivery` | This attempt. Quote it if you contact us. |

The signature is HMAC-SHA256 over `` `${t}.${rawBody}` `` with your endpoint's signing
secret, hex-encoded. Verify it against the **raw** body, before any JSON parsing.

```js
import { createHmac, timingSafeEqual } from 'node:crypto'

export function verify(secret, rawBody, header, nowSeconds = Date.now() / 1000) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
  // Reject anything older than five minutes, or a captured delivery can be replayed
  // against you for ever. This check is not optional.
  if (Math.abs(nowSeconds - Number(parts.t)) > 300) return false
  const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex')
  // Constant time. A byte-by-byte compare leaks the signature to a patient attacker.
  const a = Buffer.from(expected), b = Buffer.from(parts.v1 ?? '')
  return a.length === b.length && timingSafeEqual(a, b)
}
```

**During a secret rotation the header carries two `v1` values** — the new secret first,
the old one second — for 24 hours. Accept the delivery if *either* verifies, and you can
deploy the new secret whenever suits you inside that window.

## What is in the body

Ids and metadata. **Never a message body, media, a contact card, or key material.**

```json
{
  "id": "evt_9f8c…",
  "type": "message.received",
  "created_at": 1757462400000,
  "identity": "alice",
  "data": { "thread_id": "th-1", "contact": "sha256:…" }
}
```

Use the ids to fetch what you need from `/v1` with an API key. That is the same rule as
the retry policy above, for the same reason: the webhook is a notification, not a copy of
your data.

## Endpoints

Registered under **Apps and keys** in the portal.

- **https only.** A signature over plaintext still hands the payload to anyone on the path.
- **A hostname, not an IP address**, and not a name that only resolves on your own
  network. Our requests come from Cloudflare, so a private address would never reach you.
- **From the moment it is registered.** An endpoint is sent the events that happen after it is
  created, never one from before, even when that event is still on its way to you.
- **An event filter, or none** — no filter means every event. A filter may name only events we
  send; a name that is reserved and not yet sent is refused rather than stored.
- **Twenty consecutive failures pauses the endpoint.** We stop dialling a URL that has
  stopped answering; you resume it in the portal when it is fixed.

The signing secret is shown once, when you create the endpoint and when you rotate it. It
is stored sealed under your workspace's key and opened only to sign a delivery; no route
reads it back, so nobody here can look it up for you. Lost it? Rotate.
