Gareth Wilson Gareth Wilson

Guide to SavvyCal Webhooks: Features and Best Practices

Published


SavvyCal webhooks tell your systems when something changes in a SavvyCal account: a meeting is booked, rescheduled or canceled, an organizer approves or declines a request, a paid booking's checkout completes or expires, someone answers a poll, or a workflow action fires. They are how bookings reach your CRM, billing or onboarding flows as they happen.

This guide covers how SavvyCal webhooks are registered and signed, the contradiction in SavvyCal's signature docs and how to handle it, the event types and their payloads, and the best practices for running a SavvyCal receiver in production.

What are SavvyCal webhooks?

A SavvyCal webhook is a URL registered on an account. When an event happens, SavvyCal sends it an HTTP POST with a JSON body. Each webhook has its own secret, and SavvyCal uses it to sign every request body with HMAC-SHA256, sending the result in the x-savvycal-signature header.

Every body has the same envelope: type names the event, id identifies the delivery, occurred_at says when the action happened, version gives the payload version, and payload holds the data, usually the full event (meeting) object.

SavvyCal webhook features

FeatureDetails
ConfigurationSettings > Integrations in SavvyCal, or POST /v1/webhooks in the REST API
Method and formatPOST, application/json
Signature headerx-savvycal-signature
Header formatsha256=<uppercase hex> in the docs' example, bare hex in the docs' code sample
SchemeHMAC-SHA256, hex
Signed contentThe request body
SecretOne per webhook, shown alongside the webhook in the integration settings
Replay windowNone. No timestamp is signed
Other headersx-savvycal-webhook-id (wh_...), User-Agent: SavvyCal Webhooks (https://savvycal.com)
Delivery idid in the body (payload_...)
TransportHTTPS
RetriesNon-2xx responses are retried with exponential backoff

SavvyCal doesn't document a response timeout, the number of retries or how long they continue, and it publishes no source IP list. The signature is the way to authenticate a delivery.

Common events

SavvyCal documents 16 event types.

EventEmitted when
event.createdSomeone schedules a new event through a scheduling link, or an organizer approves an event that required approval
event.requestedSomeone schedules an event through a link that requires approval
event.approved / event.declinedAn organizer approves or declines a requested event
event.rescheduledAn existing event is rescheduled
event.changedAn event is updated in any way, with details of the change
event.canceledAn existing event is canceled
event.checkout.pendingSomeone books a paid event and checkout is still pending
event.checkout.expiredSomeone books a paid event without paying, and checkout expires
event.checkout.completedSomeone books a paid event and checkout succeeds
event.attendee.addedAn attendee is added to an existing event, as a guest or as a group event attendee
event.attendee.canceledAn individual cancels from a group event
event.attendee.rescheduledAn individual reschedules their response to a group event
poll.response.created / poll.response.updatedSomeone creates or updates a response to a poll
workflow.action.triggeredAn action is triggered in a workflow

Here is SavvyCal's event.created schema example, trimmed:

{
  "id": "payload_7RDQF0G5VT8PAV4ERN90B5Q27Y",
  "type": "event.created",
  "occurred_at": "2024-01-15T10:30:00Z",
  "version": "2024-01-15",
  "payload": {
    "id": "event_01KKGY70MFPCECSQ6KM6FMDPC3",
    "summary": "30 Minute Meeting with John Doe",
    "state": "confirmed",
    "start_at": "2024-01-20T14:00:00Z",
    "end_at": "2024-01-20T14:30:00Z",
    "duration": 30,
    "url": "https://savvycal.com/events/event_01KKGY70MFPCECSQ6KM6FMDPC3",
    "payment": { "amount_total": 1000, "state": "paid", "url": "https://dashboard.stripe.com/..." },
    "created_at": "2024-01-15T10:30:00Z"
  }
}

The event object also carries the organizer, conferencing details, the scheduling link, location, cancellation and rescheduling fields, and custom metadata. Its state is one of confirmed, canceled, awaiting_reschedule, awaiting_checkout, checkout_expired, awaiting_approval, declined or tentative.

Not every event puts the event object directly in payload:

  • event.changed sends payload.event and payload.previous_attributes, which holds the old values of the fields that changed.
  • event.attendee.added sends payload.event and payload.attendee.
  • poll.response.* sends the poll response, with poll, slots and the respondent's details.
  • workflow.action.triggered sends workflow, rule, action and event.

Setting up SavvyCal webhooks

  1. In SavvyCal, go to Settings > Integrations and click the + icon next to Webhooks.
  2. Enter your endpoint URL and save the webhook.
  3. Copy the webhook's signing secret from its configuration and store it server-side, for example as SAVVYCAL_WEBHOOK_SECRET.

You can also manage webhooks through the REST API with POST /v1/webhooks. Each webhook has its own secret, so if you register more than one, keep track of which secret belongs to which x-savvycal-webhook-id.

If you use Hookdeck, create a SavvyCal source, set the webhook's secret on it, and register the Hookdeck source URL in SavvyCal. Hookdeck's SavvyCal source accepts the signature with or without the sha256= prefix, in upper or lower case.

Securing SavvyCal webhooks

SavvyCal's docs disagree with themselves about the header. The example request shows x-savvycal-signature: sha256=6CAA4DEF..., a prefixed, uppercase hex digest. The Elixir sample computes Base.encode16() of the HMAC, which is uppercase hex with no prefix, and compares it with the whole header, which only works if the header has no prefix. A receiver that copies either one exactly breaks if the other is what arrives.

Accept both: strip an optional sha256= prefix, then compare the hex case-insensitively in constant time.

const crypto = require('crypto');
const express = require('express');

function verifySavvyCalSignature(rawBody, signatureHeader, secret) {
  if (!signatureHeader) return false;

  const received = signatureHeader.trim().replace(/^sha256=/i, '').toLowerCase();
  if (!/^[0-9a-f]{64}$/.test(received)) return false;

  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');

  const a = Buffer.from(received, 'hex');
  const b = Buffer.from(expected, 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

app.post('/webhooks/savvycal', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.SAVVYCAL_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifySavvyCalSignature(req.body, req.get('x-savvycal-signature'), secret)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Route on event.type and deduplicate on event.id. Then process asynchronously.
  res.sendStatus(200);
});

The same check in Python:

import hashlib
import hmac
import re

HEX64 = re.compile(r"[0-9a-f]{64}")


def verify_savvycal_signature(raw_body: bytes, signature_header, secret: str) -> bool:
    if not signature_header:
        return False
    received = signature_header.strip()
    if received.lower().startswith("sha256="):
        received = received[len("sha256="):]
    received = received.lower()
    if not HEX64.fullmatch(received):
        return False
    expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(received.encode("utf-8"), expected.encode("utf-8"))


# In FastAPI: raw_body = await request.body()
# verify_savvycal_signature(raw_body, request.headers.get("x-savvycal-signature"),
#                           os.environ["SAVVYCAL_WEBHOOK_SECRET"])

To check your verifier, use the secret savvycal-test-secret and this exact body:

{"type":"event.canceled","id":"payload_7RDQF0G5VT8PAV4ERN90B5Q27Y","occurred_at":"2024-01-15T10:30:00Z","version":"2024-01-15","payload":{"id":"event_01KKGY70MFPCECSQ6KM6FMDPC3","state":"canceled"}}

Both of these headers should verify:

sha256=3B1B0CA1337235055D9752D900A496E2EFD0496FAECB8D3C9FD9BE23F3FE5F79
3b1b0ca1337235055d9752d900a496e2efd0496faecb8d3c9fd9be23f3fe5f79

Make SavvyCal webhooks production-ready. Hookdeck Event Gateway verifies the x-savvycal-signature header in either documented format, and durably queues every booking, checkout and poll event before your service sees it.

SavvyCal webhook limitations and pain points

Two signature formats in one page

The Problem: Verification fails on every delivery even though the secret is right.

Why It Happens: The example request uses sha256= and uppercase hex, and the sample code expects a bare digest. Most HMAC libraries return lowercase hex, so a straight string comparison with an uppercase header fails too.

Workarounds:

  • Strip an optional sha256= prefix before comparing.
  • Lowercase the received value, or compare decoded bytes, so case doesn't matter.
  • Test your verifier with both forms of the test signature above.

How Hookdeck Can Help: Hookdeck's SavvyCal source handles both the prefixed and bare forms and compares hex case-insensitively, so you don't have to guess which one SavvyCal sends.

Retries without a documented limit

The Problem: You can't tell how long SavvyCal will keep trying, or when an event is gone for good.

Why It Happens: SavvyCal says only that a non-2xx response is retried with exponential backoff. It doesn't document the timeout, the number of attempts or the total retry window.

Workarounds:

  • Respond 200 as soon as the request is verified and stored.
  • Deduplicate on the body's id, because a retry can follow a timeout your server didn't notice.
  • After an outage, reconcile recent bookings with GET /v1/events in the SavvyCal REST API.

How Hookdeck Can Help: Hookdeck accepts each delivery as soon as it arrives and stores it, then retries delivery to your service on a schedule you control and lets you replay anything that failed.

No built-in replay protection

The Problem: A captured request still verifies if it is sent again later.

Why It Happens: The signature covers only the body, and SavvyCal defines no timestamp header or tolerance window. The body's occurred_at is signed along with everything else, but nothing in SavvyCal's scheme says how old it may be.

Workarounds:

  • Store the id of every processed delivery and skip repeats.
  • If you want a freshness check, compare occurred_at with the current time in your own receiver. Retries arrive later than the original attempt, and SavvyCal doesn't say how late, so keep any window generous.

How Hookdeck Can Help: Hookdeck's deduplication can drop repeated deliveries with the same id before they reach your service.

The payload shape depends on the event

The Problem: Code that reads payload.start_at works for bookings and breaks on other events.

Why It Happens: Most event types send the event object as payload, but event.changed, event.attendee.added, poll.response.* and workflow.action.triggered wrap it or send something else.

Workarounds:

  • Switch on type before reading payload.
  • For the wrapped events, read the event object from payload.event.

How Hookdeck Can Help: Hookdeck filters and transformations can route each type to its own destination, or reshape a payload before it reaches your service.

Best practices

Verify against the raw body

The signature covers the exact bytes SavvyCal sent. In Express, mount express.raw() on the webhook route and parse after verification. In a Next.js route handler, read await request.text(). In FastAPI, read await request.body().

Deduplicate on the body's id

The body's id (payload_...) identifies the delivery. The x-savvycal-webhook-id header identifies the webhook and is the same on every request, so it is not a deduplication key. See our guide to webhook idempotency.

Use previous_attributes for changes

event.changed tells you which fields changed and what they were. Use it to decide whether a change matters, for example a new start time, instead of diffing against your own copy.

Handle paid bookings by state

A paid booking produces more than one event: event.checkout.pending when it is booked, then event.checkout.completed or event.checkout.expired. Use the event's state and payment.state (awaiting_checkout or paid) to decide whether a booking is confirmed, rather than relying on the order in which deliveries arrive.

Acknowledge fast and process asynchronously

SavvyCal recommends responding quickly with 200 and processing the body in a job queue. See why to process webhooks asynchronously.

Conclusion

SavvyCal signs every webhook body with HMAC-SHA256 using a per-webhook secret, in x-savvycal-signature. Its docs show the value both with and without a sha256= prefix and in uppercase hex, so strip the prefix and compare case-insensitively. Switch on type before reading payload, deduplicate on the body's id, and acknowledge quickly, since the retry schedule isn't documented.

Hookdeck Event Gateway sits in front of your receiver, checks every SavvyCal signature in either format, and holds each event until your service has processed it.

Get started with Hookdeck for free and handle SavvyCal webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.