Developers
Webhooks
Get a signed HTTPS request on your server whenever a booking, call-back or message comes in.
For developersOn this page
Set one up
In More → Developers, add a webhook:
- URL. Must start with
https://. - Events. Pick the events you want, or
*for all of them.
You get a signing secret that starts with whsec_. It's shown in full once, so store it with your other secrets. You'll use it to check the signature on every request.
The same page shows your recent deliveries: the event, whether it was delivered, how many tries it took, and the status code your server sent back. Only the business owner can manage webhooks.
Events
| Event | Sent when | data |
|---|---|---|
booking.created | A booking is made, from any channel. | a Booking |
booking.rescheduled | A booking moves to a new time | a Booking |
booking.cancelled | A booking is cancelled, or marked as a no-show | a Booking |
callback.created | Someone asks for a call-back through the receptionist (chat, phone or text) or the call-back form | see below |
lead.created | A message or quote request comes in through the receptionist or the contact form | see below |
customer.created | A new customer record was created (from a booking, chat, call, text or import). | { id, name, phone, email, source } |
conversation.closed | You marked a conversation resolved in the Inbox. | { id, channel, outcomes, customerId } |
Bookings created in your dashboard send booking.created too, so your systems see every booking.
What a request looks like
POST /your/webhook HTTP/1.1
Content-Type: application/json
User-Agent: JustAGoodGuy-Webhooks/1
X-JAGG-Signature: t=1793732400,v1=<64 hex characters>{
"id": "evt_1b3d5f7h9k1m3p5r",
"type": "booking.created",
"created": "2026-11-03T19:00:00.000Z",
"data": {
"id": "bkg_8f3k2m9q1x7w4r6t",
"status": "confirmed",
"source": "web_chat",
"start": "2026-11-05T15:00:00.000Z",
"end": "2026-11-05T15:45:00.000Z",
"timezone": "America/New_York",
"service": { "id": "svc_2n4p6r8t0v1x3z5b", "name": "Haircut" },
"staff": { "id": "stf_9h7f5d3b1z0x8v6t", "name": "Dana" },
"customer": { "id": "cus_4k6m8p0r2t4v6x8z", "name": "Sam Lee", "phone": "+15555550123", "email": null },
"priceCents": 4500,
"notes": null
}
}Every request has the same envelope:
| Field | What it is |
|---|---|
id | A unique event id. It stays the same when a delivery is retried, so use it to skip duplicates. |
type | The event name |
created | When the event happened |
data | The event's details |
callback.created data:
{ "id": "cbk_3j5l7n9q1s3u5w7y", "customer": { "name": "Sam Lee", "phone": "+15555550123" }, "windowStart": "2026-11-03T21:00:00.000Z", "windowEnd": "2026-11-03T23:00:00.000Z", "reason": "Quote for a new water heater" }lead.created data:
{ "id": "led_6c8e0g2j4l6n8q0s", "customer": { "name": "Sam Lee", "phone": "+15555550123", "email": null }, "details": "Leaking tap in the kitchen", "source": "site_form", "pageUrl": "https://example.com/contact" }source is site_form for a contact form on the business's site and widget for the widget's message form. Requests the receptionist takes in a conversation have details only.
Responding and retries
- Answer with any
2xxstatus within 10 seconds. Do slow work after you've answered. - Anything else (another status, a timeout, a connection error) counts as a failure, and the delivery is tried again later.
- Each delivery is tried up to 8 times. The wait after each failed try doubles: 2, 4, 8, 16, 32, 64, then 128 minutes. After the 8th failure it's marked failed and not tried again.
- Deliveries are sent within about 15 seconds of the event.
- Events can arrive out of order, and occasionally more than once. Use
idto skip duplicates, and the booking's own fields (not the order of arrival) to decide what's current.
Verify the signature
Every request has an X-JAGG-Signature header:
X-JAGG-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256>v1 is the HMAC-SHA256 of the string <t>.<raw body>, keyed with your whsec_ secret, in lowercase hex. To check it:
- Read the raw request body, exactly as it arrived. Don't parse and re-serialise the JSON first; that changes the bytes.
- Split the header on
,and taketandv1. - Compute
HMAC-SHA256(secret, t + "." + body)as hex. - Compare it with
v1using a constant-time comparison. - Reject the request if
tis more than a few minutes away from your clock, so an old request can't be replayed. The examples use 5 minutes.
t is set again on each retry, so a retried delivery has a fresh timestamp and signature.
Node
import crypto from 'node:crypto';
import express from 'express';
const SECRET = process.env.JAGG_WEBHOOK_SECRET; // whsec_...
export function verifyJaggSignature(rawBody, header, secret, toleranceSec = 300) {
if (!header) return false;
const parts = Object.fromEntries(
header.split(',').map((kv) => {
const i = kv.indexOf('=');
return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()];
}),
);
const t = Number(parts.t);
if (!Number.isInteger(t) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(parts.v1, 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
// express.raw keeps the body as bytes, so the signature can be checked.
app.post('/webhooks/jagg', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8');
if (!verifyJaggSignature(raw, req.get('X-JAGG-Signature'), SECRET)) return res.status(400).send('Bad signature');
const event = JSON.parse(raw);
res.sendStatus(200); // answer first, then do the work
if (event.type === 'booking.created') {
console.log('New booking', event.data.id, event.data.start);
}
});
app.listen(3000);Python
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
SECRET = os.environ["JAGG_WEBHOOK_SECRET"] # whsec_...
def verify_jagg_signature(raw_body: bytes, header: str | None, secret: str, tolerance: int = 300) -> bool:
if not header:
return False
parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
try:
t = int(parts["t"])
v1 = parts["v1"]
except (KeyError, ValueError):
return False
if abs(time.time() - t) > tolerance:
return False
signed = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
app = Flask(__name__)
@app.post("/webhooks/jagg")
def jagg_webhook():
raw = request.get_data() # the raw bytes, before any JSON parsing
if not verify_jagg_signature(raw, request.headers.get("X-JAGG-Signature"), SECRET):
abort(400)
event = json.loads(raw)
if event["type"] == "booking.created":
print("New booking", event["data"]["id"], event["data"]["start"])
return "", 200PHP
<?php
$secret = getenv('JAGG_WEBHOOK_SECRET'); // whsec_...
function verify_jagg_signature(string $rawBody, ?string $header, string $secret, int $tolerance = 300): bool {
if (!$header) return false;
$parts = [];
foreach (explode(',', $header) as $kv) {
$pair = explode('=', trim($kv), 2);
if (count($pair) === 2) $parts[$pair[0]] = $pair[1];
}
if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) return false;
$t = (int) $parts['t'];
if (abs(time() - $t) > $tolerance) return false;
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1']);
}
$raw = file_get_contents('php://input'); // the raw body
$header = $_SERVER['HTTP_X_JAGG_SIGNATURE'] ?? null;
if (!verify_jagg_signature($raw, $header, $secret)) {
http_response_code(400);
exit('Bad signature');
}
$event = json_decode($raw, true);
if ($event['type'] === 'booking.created') {
error_log('New booking ' . $event['data']['id']);
}
http_response_code(200);Test vector
Use this to check your code without a live request. With the secret whsec_test_secret, the timestamp 1793732400 and the body {"id":"evt_test","type":"booking.created"}, the signature is:
v1=cd83919399dd5fbe165dce8318ade93acc1f323e914fd435b0c1b2dcdab4c4e3Your function should accept it when you skip the timestamp check (the timestamp is in the past).
Good practice
- Check the signature before you trust anything in the body.
- Answer fast, then do the work in a queue or background job.
- Make your handler safe to run twice for the same
id. - To get the latest state of a booking, call
GET /v1/bookings/{id}. - If your endpoint is down for a long time, deliveries give up after the 8th try. Use the REST API to catch up.