Just A Good Guy Docs
Sign in

Developers

Webhooks

Get a signed HTTPS request on your server whenever a booking, call-back or message comes in.

For developers
On 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

EventSent whendata
booking.createdA booking is made, from any channel.a Booking
booking.rescheduledA booking moves to a new timea Booking
booking.cancelledA booking is cancelled, or marked as a no-showa Booking
callback.createdSomeone asks for a call-back through the receptionist (chat, phone or text) or the call-back formsee below
lead.createdA message or quote request comes in through the receptionist or the contact formsee below
customer.createdA new customer record was created (from a booking, chat, call, text or import).{ id, name, phone, email, source }
conversation.closedYou 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

HTTP
POST /your/webhook HTTP/1.1
Content-Type: application/json
User-Agent: JustAGoodGuy-Webhooks/1
X-JAGG-Signature: t=1793732400,v1=<64 hex characters>
JSON
{
  "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:

FieldWhat it is
idA unique event id. It stays the same when a delivery is retried, so use it to skip duplicates.
typeThe event name
createdWhen the event happened
dataThe event's details

callback.created data:

JSON
{ "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:

JSON
{ "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 2xx status 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 id to 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:

Text
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:

  1. Read the raw request body, exactly as it arrived. Don't parse and re-serialise the JSON first; that changes the bytes.
  2. Split the header on , and take t and v1.
  3. Compute HMAC-SHA256(secret, t + "." + body) as hex.
  4. Compare it with v1 using a constant-time comparison.
  5. Reject the request if t is 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

Node (Express)
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

Python (Flask)
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 "", 200

PHP

PHP
<?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:

Text
v1=cd83919399dd5fbe165dce8318ade93acc1f323e914fd435b0c1b2dcdab4c4e3

Your 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.

Search the docs