# Webhooks

> Events Ordstage sends to your endpoint, their payloads, retries, and verifying signatures in Node, Python and PHP.

Source: https://www.ordstage.com/docs/webhooks/

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

1. In **Settings → Developers → Webhooks**, add an endpoint: a public `https://` address on your server.
2. Choose the events to receive (none chosen: every event, including ones added later), and optionally limit
   booking events to some calendars.
3. Copy the **signing secret** (it starts with `whsec_`). It is shown once; keep it on your server like a password.
4. Press **Send test**. Ordstage sends a `ping` event 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](https://www.ordstage.com/docs/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:

```http
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 `2xx` status 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 `id` to
  skip ones you've handled, and the object's own fields (for example a booking's `status`) 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:

```text
{X-Ordstage-Timestamp}.{X-Ordstage-Delivery}.{raw request body}
```

hex-encoded and prefixed with `sha256=`. To verify a delivery:

1. Read the **raw body bytes** before any JSON parsing (re-serialised JSON won't match).
2. Compute the signature from the timestamp header, the delivery header and the raw body.
3. Compare it with `X-Ordstage-Signature` in **constant time**.
4. Refuse deliveries whose timestamp is more than 5 minutes from your clock.
5. Answer `2xx`, then process the event (skipping delivery ids you've already handled).

```js [Node.js]
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);
```

```python [Python]
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 [PHP]
<?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 seen
```

## Good 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.
