Concepts
Webhooks
Events Ordstage sends to your endpoint, their payloads, retries, and verifying signatures in Node, Python and PHP.
Webhooks tell your system when something changes in Ordstage, for example a booking made by the assistant, a
conversation handed to the team, a new contact or a lead's status, so you don't have to poll. An area's events are
sent while the business's API access includes it (its …:read scope) and its module or feature is on.
Setting up an endpoint
- In Settings → Developers → Webhooks, add an endpoint: a public
https://address on your server. - Choose the events to receive (none chosen: every event, including ones added later), and optionally limit booking events to some calendars.
- Copy the signing secret (it starts with
whsec_). It is shown once; keep it on your server like a password. - Press Send test. Ordstage sends a
pingevent so you can check your endpoint and its signature check.
Events
| Event | Sent when |
|---|---|
booking.created |
Sent when a booking is made from any source (the assistant, the team, another system). |
booking.confirmed |
Sent when the team approves a booking that waited for approval. |
booking.updated |
Sent when a booking's time, resource, details or status changes. |
booking.cancelled |
Sent when a booking is cancelled, by the customer, the team or a system. |
booking.conflict |
Sent when bookings from different sources overlap beyond a resource's capacity (for example a booking you pushed for a time that was already full). The team is alerted too. |
availability.closed |
Sent when time is closed for online booking across a calendar (scope: online). |
assistant.published |
Sent when an assistant's setup is published (from Studio or the API) and it starts answering with it. |
assistant.paused |
Sent when an assistant stops answering customers; conversations go to the team. |
assistant.resumed |
Sent when a paused assistant answers customers again. |
business.updated |
Sent when the business's name, website, logo, colours, timezone or language change, from Settings or the API. |
contact.created |
Sent when a contact is created anywhere: a new person messages the business, an order or booking is made, or the API. |
contact.updated |
Sent when a contact's details, tags or attributes change. |
contact.deleted |
Sent when a contact is deleted; contact is how it was. |
contact.merged |
Sent when a duplicate is merged into a contact: contact is the one kept, merged_contact_id the one removed. |
lead.created |
Sent when someone asks to be contacted (through the assistant) or a lead is added through the API. |
lead.updated |
Sent when a lead's details, note, assignee or status change. |
lead.status_changed |
Sent when a lead moves to another status. |
lead.deleted |
Sent when a lead is deleted; lead is how it was. |
conversation.handed_over |
Sent when a conversation moves from the assistant to the team (the assistant, a teammate or the API handed it over). |
conversation.returned_to_assistant |
Sent when the team gives a conversation back to the assistant to answer. |
conversation.closed |
Sent when a conversation is resolved or closed, from the Inbox or the API. |
conversation.reopened |
Sent when a resolved or closed conversation is opened again by the team or the API. |
conversation.updated |
Sent when a conversation's priority, assignee, team, labels or waiting status change. |
conversation.reply_sent |
Sent when a teammate's reply is sent from the Inbox or the API (not the assistant's own answers). |
member.invited |
Sent when an invitation is created, in Settings or by the API. |
member.invitation_revoked |
Sent when a pending invitation is revoked. |
member.role_changed |
Sent when a member's role changes. |
member.removed |
Sent when someone is removed from the team (their account stays). |
ping |
Sent when you press Send test on a webhook in Developers settings. |
The API reference shows every event's full payload schema under Webhooks.
What a delivery looks like
Each delivery is an HTTP POST with a JSON body:
POST /webhooks/ordstage HTTP/1.1
Content-Type: application/json
User-Agent: Ordstage-Webhooks/1
X-Ordstage-Event: booking.created
X-Ordstage-Delivery: 6f1c2e4a-8b3d-4c5e-9f70-1a2b3c4d5e6f
X-Ordstage-Timestamp: 1793260800
X-Ordstage-Signature: sha256=<hex digest>
{"id":"6f1c2e4a-8b3d-4c5e-9f70-1a2b3c4d5e6f","event":"booking.created","created_at":"2026-10-29T10:00:00+00:00","data":{"booking":{"id":"…","reference":"BK-7Q2M","status":"confirmed","…":"…"}}}| Header | Meaning |
|---|---|
X-Ordstage-Event |
The event name. |
X-Ordstage-Delivery |
The delivery id, also the body's id. The same on every retry of this delivery. |
X-Ordstage-Timestamp |
Unix time (seconds) when this attempt was signed. |
X-Ordstage-Signature |
sha256= followed by the hex HMAC-SHA256 signature (below). |
Answering and retries
- Answer with any
2xxstatus within 10 seconds. Do slow work after answering (for example on a queue). - Any other answer, a timeout or a connection error is retried after about 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, 3 hours, 6 hours and 12 hours (about a day in all), then the delivery is marked failed.
- An endpoint that fails 50 times in a row is paused. Fix it, then resume it in Settings → Developers.
- Redirects are not followed. Ordstage only connects to public addresses.
- The same delivery can arrive more than once, and deliveries can arrive out of order. Use the delivery
idto skip ones you've handled, and the object's own fields (for example a booking'sstatus) rather than the event order to decide its current state. When in doubt, read the object from the API.
Verifying signatures
Every delivery is signed with your endpoint's secret. Always verify the signature before trusting a delivery, and refuse old ones, so nobody can forge or replay events to your endpoint.
The signature is an HMAC-SHA256, keyed with the whole secret (including whsec_), of this exact string:
{X-Ordstage-Timestamp}.{X-Ordstage-Delivery}.{raw request body}hex-encoded and prefixed with sha256=. To verify a delivery:
- Read the raw body bytes before any JSON parsing (re-serialised JSON won't match).
- Compute the signature from the timestamp header, the delivery header and the raw body.
- Compare it with
X-Ordstage-Signaturein constant time. - Refuse deliveries whose timestamp is more than 5 minutes from your clock.
- Answer
2xx, then process the event (skipping delivery ids you've already handled).
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.ORDSTAGE_WEBHOOK_SECRET; // whsec_…
const TOLERANCE_SECONDS = 300;
export function verifyOrdstageSignature(secret, timestamp, deliveryId, rawBody, signature, now = Date.now() / 1000) {
const expected = "sha256=" + crypto.createHmac("sha256", secret)
.update(`${timestamp}.${deliveryId}.`)
.update(rawBody)
.digest("hex");
const given = Buffer.from(String(signature ?? ""));
const fresh = Math.abs(now - Number(timestamp)) <= TOLERANCE_SECONDS;
return fresh && given.length === expected.length && crypto.timingSafeEqual(given, Buffer.from(expected));
}
const app = express();
// express.raw keeps the exact bytes Ordstage signed.
app.post("/webhooks/ordstage", express.raw({ type: "application/json" }), (req, res) => {
const ok = verifyOrdstageSignature(SECRET, req.get("X-Ordstage-Timestamp"), req.get("X-Ordstage-Delivery"),
req.body, req.get("X-Ordstage-Signature"));
if (!ok) return res.sendStatus(400);
const event = JSON.parse(req.body.toString("utf8"));
res.sendStatus(200);
// handle event.event / event.data here, skipping event.id values you've already seen
});
app.listen(3000);import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
SECRET = os.environ["ORDSTAGE_WEBHOOK_SECRET"] # whsec_…
TOLERANCE_SECONDS = 300
def verify_ordstage_signature(secret, timestamp, delivery_id, raw_body, signature, now=None):
expected = "sha256=" + hmac.new(secret.encode("utf-8"), f"{timestamp}.{delivery_id}.".encode("utf-8") + raw_body,
hashlib.sha256).hexdigest()
try:
fresh = abs((now if now is not None else time.time()) - int(timestamp)) <= TOLERANCE_SECONDS
except (TypeError, ValueError):
return False
return fresh and hmac.compare_digest(expected, signature or "")
app = Flask(__name__)
@app.post("/webhooks/ordstage")
def ordstage_webhook():
raw = request.get_data() # the exact bytes Ordstage signed
if not verify_ordstage_signature(SECRET, request.headers.get("X-Ordstage-Timestamp"),
request.headers.get("X-Ordstage-Delivery"), raw,
request.headers.get("X-Ordstage-Signature")):
abort(400)
event = json.loads(raw)
# handle event["event"] / event["data"] here, skipping event["id"] values you've already seen
return "", 200<?php
const TOLERANCE_SECONDS = 300;
function verify_ordstage_signature(string $secret, string $timestamp, string $deliveryId, string $rawBody, string $signature, ?int $now = null): bool
{
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $deliveryId . '.' . $rawBody, $secret);
$fresh = ctype_digit($timestamp) && abs(($now ?? time()) - (int) $timestamp) <= TOLERANCE_SECONDS;
return $fresh && hash_equals($expected, $signature);
}
$secret = getenv('ORDSTAGE_WEBHOOK_SECRET'); // whsec_…
$raw = file_get_contents('php://input'); // the exact bytes Ordstage signed
$ok = verify_ordstage_signature(
$secret,
$_SERVER['HTTP_X_ORDSTAGE_TIMESTAMP'] ?? '',
$_SERVER['HTTP_X_ORDSTAGE_DELIVERY'] ?? '',
$raw,
$_SERVER['HTTP_X_ORDSTAGE_SIGNATURE'] ?? ''
);
if (!$ok) {
http_response_code(400);
exit;
}
$event = json_decode($raw, true);
http_response_code(200);
// handle $event['event'] / $event['data'] here, skipping $event['id'] values you've already seenGood practice
- Keep the endpoint fast: verify, store, answer
2xx, then process. - Treat webhooks as a signal. For anything important, read the current state from the API.
- Rotate a secret that may have leaked by deleting the endpoint and adding it again (the new endpoint gets a new secret).
- Use a separate endpoint (and secret) for your test workspace.