Guides · Reference

Webhooks

Subscribe a URL to events with POST /businesses/:businessId/webhooks (OWNER session or a key with webhooks:manage):

{ "url": "https://example.com/chale", "events": ["booking.confirmed", "booking.cancelled", "payment.received"], "description": "POS sync" }

The response includes the endpoint secret (whsec_…) once. URLs must be https on a public host (no redirects are followed).

Events

booking.held · booking.confirmed · booking.rescheduled · booking.cancelled · booking.completed · booking.no_show · booking.expired · payment.received · payment.refunded · handoff.requested · subscription.updated · ping (from test).

Every delivery is a POST with a JSON envelope: { "id": "evt_…", "type": "booking.confirmed", "createdAt": "…", "businessId": "…", "data": { "booking": { … }, "by": { "kind": "user" } } } and the headers Chale-Event-Id, Chale-Event-Type, Chale-Signature.

Verifying the signature

Chale-Signature: t=<unix seconds>,v1=<hex> where v1 = HMAC-SHA256(secret, "<t>.<raw body>"). Reject when |now − t| > 300 s, compare in constant time, and use the raw body bytes (before any JSON parsing).

TypeScript

import { createHmac, timingSafeEqual } from 'node:crypto';

export function verify(secret: string, rawBody: Buffer, header: string): boolean {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=') as [string, string]));
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
  const expected = createHmac('sha256', secret).update(`${parts.t}.`).update(rawBody).digest('hex');
  return expected.length === parts.v1?.length && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}

Python

import hmac, hashlib, time

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

PHP

function verify(string $secret, string $rawBody, string $header): bool {
    parse_str(str_replace(',', '&', $header), $parts);
    if (abs(time() - (int) $parts['t']) > 300) return false;
    $expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
    return hash_equals($expected, $parts['v1'] ?? '');
}

Delivery, retries, replay

Answer 2xx within 10 seconds. Anything else is retried on the ladder 1 min → 5 min → 30 min → 2 h → 12 h (six attempts in all); after that the delivery is DEAD and visible at GET /webhooks/:id/deliveries. POST /deliveries/:id/replay sends it again now (also for DEAD). Deliveries can arrive more than once — treat Chale-Event-Id as your idempotency key. POST /webhooks/:id/rotate issues a new secret (shown once); POST /webhooks/:id/test sends a ping.