# Webhooks (/docs/agents/scheduling/webhooks)

> For the complete documentation index, see [llms.txt](https://docs.tandemcx.ai/llms.txt). Any page on this site can be fetched as Markdown by appending `.md` to its URL.

<Callout type="info">
    **This page is being rolled out.** If **Scheduling** isn't in your sidebar, it isn't enabled for
    your organization yet — contact support.
</Callout>

## What a webhook is

A webhook sends a signed `POST` to an address you choose each time something happens to a
calendar's bookings. Each calendar can have up to 5 webhooks, and each picks the events it gets.

The request body carries the customer's name, phone number, email address, notes, and their
reschedule-or-cancel link. Tandem sends it only to a public `https://` address, signed so your
receiver can check it came from Tandem.

## Adding a webhook

Open the calendar under **Scheduling → Calendars**, go to the **Webhooks** tab, and click
**Add Webhook**:

- **Endpoint URL** — a public `https://` address on port 443. See
  [Which addresses are allowed](#which-addresses-are-allowed).
- **Events** — all four are checked to start. Pick at least one.
- **Alert email** — optional. This address is emailed if Tandem turns the webhook off after 3
  days of failed deliveries. Clear the field to remove it.

**Add Webhook** shows the webhook's signing secret, which starts with `whsec_`. Copy it then: it
is shown only once. Changes on this tab save as you make them; the editor's **Save** is not
needed.

## Events

| Event                 | Label in the app | Sent                                                                 |
| --------------------- | ---------------- | -------------------------------------------------------------------- |
| `booking.created`     | Booked           | When a callback is booked                                            |
| `booking.rescheduled` | Rescheduled      | When a callback moves to a new time, by the customer or by you       |
| `booking.cancelled`   | Cancelled        | When a callback is cancelled, by the customer or by you              |
| `booking.due`         | Due              | At the callback's start time, or at once for **As soon as possible** |

A `booking.due` event for a callback that has since moved or been cancelled is not sent. A
moved callback gets its `booking.due` at the new time.

## The request

Each delivery is a `POST` with these headers:

- **`Content-Type`** — `application/json`.
- **`Spark-Delivery-Id`** — the delivery's id, the same as `id` in the body.
- **`Spark-Event`** — the event, such as `booking.created`, or `ping` for a test.
- **`Spark-Signature`** — `t=` and the time of the attempt in Unix seconds, then `v1=` and the
  signature. See [Verifying the signature](#verifying-the-signature).
- **`User-Agent`** — `Spark-Webhooks/1`.

The header names keep `Spark`, the platform's earlier name, so receivers built against them keep
working.

The body is JSON:

```json
{
    "id": "01K6Y8Z9Q0D3F7K2M9N4P6R8T1V3X5Z7A9C",
    "type": "booking.created",
    "createdAt": "2026-10-07T14:00:00.112Z",
    "calendarId": "01K6X2B7C4M8N0P3Q5R7S9T1V4",
    "data": {
        "bookingId": "01K6Y8Z8W4E6G8J1K3M5N7P9R2",
        "kind": "scheduled",
        "status": "confirmed",
        "start": "2026-10-08T14:30:00Z",
        "end": "2026-10-08T15:00:00Z",
        "customerTimezone": "America/New_York",
        "name": "Dana Reyes",
        "phone": "+15551234567",
        "email": "dana@example.com",
        "notes": "Afternoons are best.",
        "reason": "Billing question",
        "manageUrl": "https://book.example.com/m/…"
    }
}
```

- **`id`** and **`type`** — the delivery's id and its event. Treat ids as opaque strings.
- **`createdAt`** — when Tandem created the delivery, in UTC.
- **`data.kind`** — `scheduled` for a picked time, `asap` for **As soon as possible**.
- **`data.status`** — `confirmed`, or `cancelled` on a `booking.cancelled` event.
- **`data.start`** — when the callback starts, in UTC. For an **As soon as possible** callback,
  this is when it was booked.
- **`data.end`** — when the callback's length runs out, in UTC.
- **`data.email`** — left out when the customer didn't give one. So are **`data.notes`** and
  **`data.reason`**.
- **`data.manageUrl`** — the customer's own link to reschedule or cancel. Anyone with it can
  change the callback, so keep it private.

The body is a snapshot of the booking when the event happened. A retry, or a resend days later,
carries the same data even if the booking has changed since. A test `ping` carries `"data": {}`.

## Verifying the signature

`Spark-Signature` looks like `t=1791381600,v1=2b95c540…`. The `v1` value is the hex HMAC-SHA256
of the timestamp, a period, and the raw request body, keyed with the whole signing secret,
`whsec_` included. After you rotate the secret the header carries two `v1=` values, one for each
secret, for 24 hours. Accept the request if any of them matches.

Check the signature against the raw bytes you received, before parsing the JSON: a body that is
parsed and serialized again may not match. In Node with Express:

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

const SECRET = process.env.SPARK_WEBHOOK_SECRET; // whsec_…
const TOLERANCE_SECONDS = 300;

function verify(rawBody, header) {
    const parts = header.split(',');
    const t = parts.find((p) => p.startsWith('t='))?.slice(2);
    const signatures = parts.filter((p) => p.startsWith('v1=')).map((p) => p.slice(3));
    if (!t || signatures.length === 0) return false;
    // Refuse an old request replayed later.
    if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_SECONDS) return false;
    const expected = createHmac('sha256', SECRET).update(`${t}.`).update(rawBody).digest();
    return signatures.some((sig) => {
        const got = Buffer.from(sig, 'hex');
        return got.length === expected.length && timingSafeEqual(got, expected);
    });
}

const app = express();

app.post('/hooks/spark', express.raw({ type: 'application/json' }), (req, res) => {
    if (!verify(req.body, req.get('Spark-Signature') ?? '')) return res.sendStatus(400);
    const event = JSON.parse(req.body.toString('utf8'));
    // Skip a Spark-Delivery-Id you have already handled, then act on the event.
    res.sendStatus(200);
});

app.listen(3000);
```

Each attempt is signed when it is sent, so a retry carries a new `t`. A five-minute tolerance
leaves room for clock differences without accepting an old request replayed much later.

## Responding and retries

Respond with any `2xx` status within 10 seconds and the delivery is done. Tandem does not follow
redirects.

- **`3xx` or `410 Gone`** — the delivery fails at once and is not retried.
- **Any other status, a timeout, or a connection error** — Tandem tries again 1 minute after the
  first attempt fails, then 5 minutes, 30 minutes, and 2 hours after the next failures, then every
  6 hours. Retries stop 24 hours after the first attempt, and the delivery is marked failed.

Delivery is at least once: the same delivery can arrive more than once, for example when an
attempt is cut off partway. Every attempt of one delivery has the same `Spark-Delivery-Id`, so
skip one you have already handled. Deliveries can also arrive out of order, so a retried
`booking.created` can come after the `booking.rescheduled` that followed it.

## When deliveries keep failing

The webhook's status shows **Failing since** a date from its first failed attempt. Any
successful delivery clears it. If attempts are still failing more than 3 days after that date,
Tandem turns the webhook off: its status shows **Disabled (failing)**, and its alert email, if it
has one, gets a message titled `Tandem stopped sending to` and the endpoint's host. The alert is
sent from your organization's default email sender, so it needs one (see
[Choose a default sender](/docs/overview/email-domains#choose-a-default-sender)).

A turned-off webhook gets no new deliveries, and the retries it had waiting end as failed with
`Webhook turned off`. Events that happen while it is off are not sent later. Fix the endpoint,
then choose **Enable** from the webhook's menu. That starts the 3-day count over.

## Managing a webhook

Each webhook's menu (**⋮**) has:

- **Edit** — change the endpoint URL, the events, or the alert email.
- **Send Test** — sends a signed `ping` at once and shows the result under the URL, such as
  `Test: HTTP 200 in 84 ms` or `Test failed:` and the error. A test is not retried and is not
  listed under **Deliveries**.
- **Rotate Secret** — makes a new signing secret and shows it once. The old secret keeps working
  for 24 hours: requests carry a signature for each. Rotating again within those 24 hours ends
  the oldest secret at once.
- **Disable** or **Enable** — stops or restarts deliveries.
- **Delete** — removes the webhook. Its deliveries are no longer listed, and retries it had
  waiting are not sent.

## Deliveries

The **Deliveries** section lists one webhook's deliveries from the last 30 days, newest first;
pick the webhook above the table. Each row has the event, the status (**Delivered**,
**Pending**, or **Failed**), the last response's HTTP status code or error, the number of
attempts, and when the delivery was created. **Load More** fetches the next page.

Open a row to see the body sent, the first 1,024 characters of your endpoint's response, and the
times of the last attempt and, while it is pending, the next one.

**Resend** sends a delivery again with the same body, as a new delivery with a new id and a fresh
24 hours of retries. It is offered only while the webhook is on.

An error in place of a status code is one of:

| Shown                    | Meaning                                                                                          |
| ------------------------ | ------------------------------------------------------------------------------------------------ |
| `Interrupted`            | An attempt stopped before it got a response. Tandem tries again.                                 |
| `Gave up after 24 hours` | Retries ran out.                                                                                 |
| `Webhook turned off`     | The webhook was disabled or deleted before the delivery was sent, or this failure turned it off. |
| `Address not allowed`    | The host resolves to an address that isn't public.                                               |
| `Not an https address`   | The URL doesn't start with `https://`.                                                           |
| `Port not allowed`       | The URL names a port other than 443.                                                             |
| `Host not found`         | The host didn't resolve. Tandem tries again.                                                     |
| `Invalid address`        | The URL can't be parsed, or it includes a user name or password.                                 |

Other errors, such as a timeout, are shown as they were reported.

## Which addresses are allowed

An endpoint URL must:

- start with `https://`
- use port 443, or name no port
- not include a user name or password
- resolve only to public addresses: nothing private, loopback, link-local, carrier-grade NAT,
  multicast, or reserved

**Add Webhook** and **Save** check the address and refuse one that fails with
`This address can't receive webhooks` and the reason. Tandem checks the address again before
every attempt, and connects to the address it checked. If the host doesn't resolve, the attempt
is retried; any other failed check fails the delivery without a retry.

## Limits and defaults

| Limit                           | Value                           |
| ------------------------------- | ------------------------------- |
| Webhooks per calendar           | 5                               |
| Response time                   | 10 seconds                      |
| Retry window                    | 24 hours from the first attempt |
| Turned off after failing for    | 3 days                          |
| Old secret valid after rotating | 24 hours                        |
| Delivery log kept               | 30 days                         |
| Response stored                 | First 1,024 characters          |
| Endpoint URL length             | 2,048 characters                |

## Next steps

- [Booking messages](/docs/agents/scheduling/messages) — the emails and texts customers get.
- [Callback scheduling](/docs/agents/scheduling) — set a calendar's hours, capacity, and booking
  page.
