Skip to content

Webhooks

View as Markdown

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

Section titled “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.

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.

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.

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.

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

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

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.