Webhooks
Signed, retried HTTP callbacks for everything that happens in your account.
Subscribing
Create an endpoint on the Developers page, or over the API. Zapier and n8n send `targetUrl` and a single `event`; Tandem accepts that shape as well as its own.
The signing secret is returned once, when the endpoint is created.
curl -X POST https://your-tandem-host/api/v1/webhooks \
-H 'Authorization: Bearer tnd_live_...' \
-H 'Content-Type: application/json' \
-d '{"url":"https://example.com/hook","events":["booking.created"]}'Events
Every event Tandem can send.
| Event | Fires when |
|---|---|
| booking.created | Someone books a time |
| booking.rescheduled | A booking moves |
| booking.cancelled | A booking is called off |
| sync.error | A calendar fails to sync |
| sync.drift_detected | A mirror stops matching its source |
| account.needs_reauth | A provider revokes access |
| workflow.failed | A workflow run fails |
Verifying a delivery
Each request carries `X-Tandem-Signature: t=<unix>,v1=<hmac>` where the HMAC is SHA-256 over `"<t>.<raw body>"` using your endpoint secret. The timestamp is inside the signed payload, so a captured request cannot be replayed later.
Reject anything older than five minutes, and compare with a constant-time function.
import { createHmac, timingSafeEqual } from 'node:crypto'
function verify(secret, rawBody, header) {
const parts = Object.fromEntries(header.split(',').map(p => p.split('=', 2)))
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false
const expected = createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`).digest('hex')
const a = Buffer.from(expected, 'hex')
const b = Buffer.from(parts.v1, 'hex')
return a.length === b.length && timingSafeEqual(a, b)
}Retries
A delivery is retried with exponential backoff up to six times. After that it is marked dead and appears on the Sync health page, where Scale accounts can replay it.
Redirects are never followed: the signature covers this body for this endpoint.