# Guide to SavvyCal Webhooks: Features and Best Practices

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

| Feature | Details |
| --- | --- |
| Configuration | **Settings > Integrations** in SavvyCal, or `POST /v1/webhooks` in the REST API |
| Method and format | `POST`, `application/json` |
| Signature header | `x-savvycal-signature` |
| Header format | `sha256=<uppercase hex>` in the docs' example, bare hex in the docs' code sample |
| Scheme | HMAC-SHA256, hex |
| Signed content | The request body |
| Secret | One per webhook, shown alongside the webhook in the integration settings |
| Replay window | None. No timestamp is signed |
| Other headers | `x-savvycal-webhook-id` (`wh_...`), `User-Agent: SavvyCal Webhooks (https://savvycal.com)` |
| Delivery id | `id` in the body (`payload_...`) |
| Transport | HTTPS |
| Retries | Non-`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.

| Event | Emitted when |
| --- | --- |
| `event.created` | Someone schedules a new event through a scheduling link, or an organizer approves an event that required approval |
| `event.requested` | Someone schedules an event through a link that requires approval |
| `event.approved` / `event.declined` | An organizer approves or declines a requested event |
| `event.rescheduled` | An existing event is rescheduled |
| `event.changed` | An event is updated in any way, with details of the change |
| `event.canceled` | An existing event is canceled |
| `event.checkout.pending` | Someone books a paid event and checkout is still pending |
| `event.checkout.expired` | Someone books a paid event without paying, and checkout expires |
| `event.checkout.completed` | Someone books a paid event and checkout succeeds |
| `event.attendee.added` | An attendee is added to an existing event, as a guest or as a group event attendee |
| `event.attendee.canceled` | An individual cancels from a group event |
| `event.attendee.rescheduled` | An individual reschedules their response to a group event |
| `poll.response.created` / `poll.response.updated` | Someone creates or updates a response to a poll |
| `workflow.action.triggered` | An action is triggered in a workflow |

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

```json
{
  "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.

```javascript
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:

```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](/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](/webhooks/guides/implement-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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## 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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle SavvyCal webhooks reliably in minutes.