Browse documentation

Webhooks

Tell your own system when a booking is made, moved or cancelled, without it having to keep asking.

Add one

Open Settings, then Webhooks. Enter the address of your endpoint, choose which events you want, and press Add webhook. CalFree shows a signing secret once: copy it into your system. Only an encrypted copy is kept, and if you lose it you can roll a new one. Send test posts a sample message straight away and tells you what your endpoint answered.

You can have up to five endpoints. Each can listen to some events or all of them, and be turned off without being deleted. The address must start with https:// and point at a public server: addresses inside a private network, and anything with a username or password in it, are refused.

The events

  • booking.created: someone books, by any route (the booking page, the API, an agent over MCP, or after paying). Every person who joins a group session sends their own.
  • booking.rescheduled: a booking moves to a new time. The message carries previous_start and previous_end.
  • booking.cancelled: a booking is cancelled by the invitee or by you. cancelled_by says which.

What you receive

A POST with a JSON body. The booking object is the same one the REST API returns, with a few an extra field for who cancelled.

{
  "id": "evt_6f1c1d0e-9a54-4c7e-8f3a-2b8d4a7c91e0",
  "type": "booking.created",
  "created_at": "2030-01-01T09:30:12.000Z",
  "data": {
    "booking": {
      "id": "b3e1c9a2-…",
      "event_type_id": "5d0b…",
      "status": "confirmed",
      "start": "2030-01-07T15:00:00.000Z",
      "end": "2030-01-07T15:30:00.000Z",
      "invitee_name": "Sam Rivera",
      "invitee_email": "[email protected]",
      "invitee_time_zone": "Europe/London",
      "answers": {},
      "meeting_url": "https://meet.google.com/…",
      "created_via": "web",
      "cancel_reason": null,
      "cancelled_by": null,
      "created_at": "2030-01-01T09:30:12.000Z"
    },
    "event_type": { "id": "5d0b…", "title": "30 Minute Meeting", "slug": "30min", "duration_min": 30, "location_type": "meet" }
  }
}

These headers come with it:

  • CalFree-Event: the event type, as above.
  • CalFree-Delivery: the id of this message. It stays the same when the message is retried.
  • CalFree-Signature: the signature, explained next.
  • User-Agent: CalFree-Webhooks/1.0.

Check the signature

Anyone who knows your endpoint’s address could post to it, so check that a message really came from CalFree before acting on it. The header is t=<unix seconds>,v1=<hex>. The v1 value is an HMAC-SHA256, in hex, of the text <t>.<body> (the time, a full stop, then the raw request body exactly as received), keyed with your signing secret. Compute it and compare, and refuse the message if t is more than five minutes from now, so a recorded request can’t be replayed later.

Always verify the raw bytes of the body, before parsing the JSON: parsing and re-serialising can change them.

// Node
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(secret, header, rawBody) {
  const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
  const given = Buffer.from(v1, "hex");
  return given.length === expected.length && timingSafeEqual(given, expected);
}
# Python
import hashlib, hmac, time

def verify(secret: str, header: str, raw_body: bytes) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    if abs(time.time() - int(parts["t"])) > 300:
        return False
    signed = parts["t"].encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])

Answer quickly, and expect repeats

Answer with any 2xx status as soon as you have the message, and do the real work afterwards. CalFree waits ten seconds. Anything else counts as a failure: a 4xx or 5xx, a redirect (redirects are not followed), no answer, or a refused connection.

A failed message is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 16 hours, seven tries over a little more than a day. After the last one it is marked Failed and kept in the log. Because a message can arrive more than once (a retry, or a Resend), use the event’s id to ignore one you have already handled. Messages are not guaranteed to arrive in order: created_at says when each event happened.

The delivery log

Each endpoint lists its last twenty messages: the event, the answer your server gave, the number of tries, and the time of the next one. Messages are kept for 30 days. Resend sends a message again right away, as the same event. If you turn an endpoint off, messages still waiting for it are dropped and marked as failed.

For people running CalFree themselves

Messages go out from the same background pass as email: the npm run worker process, or the /api/cron/emails route called by a scheduler. A booking also starts a pass as soon as it is made, so most messages arrive within seconds. By default CalFree will only connect to public addresses, which stops a user pointing a webhook at your database or your cloud’s metadata service. If you want webhooks to reach something on your own network, set WEBHOOKS_ALLOW_PRIVATE=true; that also allows plain http://. Leave it off on a server where people other than you can sign up.