Docs

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.

EventFires when
booking.createdSomeone books a time
booking.rescheduledA booking moves
booking.cancelledA booking is called off
sync.errorA calendar fails to sync
sync.drift_detectedA mirror stops matching its source
account.needs_reauthA provider revokes access
workflow.failedA 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.