For the complete documentation index, see llms.txt. Any page on this site can be fetched as Markdown by appending .md to its URL.

Callback scheduling

Webhooks

Send a calendar's booking events to your own systems as signed HTTPS requests, with retries and a delivery log.

This page is being rolled out. If Scheduling isn't in your sidebar, it isn't enabled for your organization yet — contact support.

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.
  • 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

EventLabel in the appSent
booking.createdBookedWhen a callback is booked
booking.rescheduledRescheduledWhen a callback moves to a new time, by the customer or by you
booking.cancelledCancelledWhen a callback is cancelled, by the customer or by you
booking.dueDueAt 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.
  • 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:

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

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).

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:

ShownMeaning
InterruptedAn attempt stopped before it got a response. Tandem tries again.
Gave up after 24 hoursRetries ran out.
Webhook turned offThe webhook was disabled or deleted before the delivery was sent, or this failure turned it off.
Address not allowedThe host resolves to an address that isn't public.
Not an https addressThe URL doesn't start with https://.
Port not allowedThe URL names a port other than 443.
Host not foundThe host didn't resolve. Tandem tries again.
Invalid addressThe 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

LimitValue
Webhooks per calendar5
Response time10 seconds
Retry window24 hours from the first attempt
Turned off after failing for3 days
Old secret valid after rotating24 hours
Delivery log kept30 days
Response storedFirst 1,024 characters
Endpoint URL length2,048 characters

Next steps

On this page