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
| 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 asidin the body.Spark-Event— the event, such asbooking.created, orpingfor a test.Spark-Signature—t=and the time of the attempt in Unix seconds, thenv1=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/…"
}
}idandtype— the delivery's id and its event. Treat ids as opaque strings.createdAt— when Tandem created the delivery, in UTC.data.kind—scheduledfor a picked time,asapfor As soon as possible.data.status—confirmed, orcancelledon abooking.cancelledevent.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 aredata.notesanddata.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.
3xxor410 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
pingat once and shows the result under the URL, such asTest: HTTP 200 in 84 msorTest 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 — the emails and texts customers get.
- Callback scheduling — set a calendar's hours, capacity, and booking page.