Webhooks
Rookery tells your app when something happens, with a small signed POST. Events, verification, retries and the delivery log.
A webhook is Rookery calling your app: when a client replies, documents come in or someone books a time, Rookery sends a small, signed POST to an address you choose. Your app doesn't have to keep asking.
Webhooks come with the Pro and Team plans, with the API. Owners and admins set them up. For Zapier, Make and n8n, see Webhooks in Help.
Add a webhook
- Go to Settings → Integrations → Webhooks.
- Under Send to, paste your app's address. It must be a public
https://address. - Add a Description, and under Send these events, tick what it should hear about.
- If you have a private inbox, choose From which inboxes: Team inboxes, or Only my private inbox.
- Click Add webhook, then copy the Signing secret. It starts with
whsec_and is shown once. - Open the webhook and click Send test event. Rookery sends
webhook.test, and shows your app's answer straight away.
The events
| Event | When it's sent |
|---|---|
ask.completed | Everything you asked a client for is in (documents today) |
client.replied | Someone wrote back on a conversation |
conversation.assigned | A conversation was given to someone, or to nobody |
turn.changed | Whose turn it is changed: yours, theirs, or done |
draft.approved | A person approved a helper's or an agent's draft |
message.sent | An email went out |
booking.created | Someone booked a time with you |
booking.cancelled | A booking was cancelled |
scout.held | Scout held a likely scam |
webhook.test | Sent when you press Send test event |
Each event's fields, with an example, are in the event reference. The full schemas are in the OpenAPI spec, under webhooks.
What Rookery sends
Every event has the same envelope. data is thin on purpose: ids to look things up with the API, a one-line summary, and a url to the conversation when there is one. It never has an email's words, a note, a file or an attachment.
{
"id": "evt_4pj6sy63n8spf4h9",
"type": "client.replied",
"version": 1,
"created_at": "2026-10-13T09:14:02.118Z",
"workspace": "wsp_0qadwjbmg1dw6wws",
"data": {
"conversation_id": "cnv_zys19vqj63sb1z4v",
"message_id": "msg_7h2kq9m3x1c8v5bn",
"mailbox_id": "mbx_d56zb3gp9g8qj3qt",
"secure": false,
"summary": "Dana Wu replied",
"url": "https://app.userookery.com/c/cnv_zys19vqj63sb1z4v"
}
}
idis the same on every try and on a Resend. Keep the ids you've handled and skip repeats.versionchanges only when an event changes in a way that could break your app. New fields can appear at any time, so ignore ones you don't know.- Events can arrive out of order. Use
created_at, or read the conversation with the API for its current state.
Private inboxes
A webhook for Team inboxes hears about team inboxes, and about the workspace as a whole (bookings outside a conversation, test events). It never hears about anyone's private inbox.
A webhook for Only my private inbox hears about its maker's own private inboxes and nothing else. Only its maker can see it, its log and what it sent. This matches API keys, which never see someone else's private mail.
Check the signature
Every event is signed the Standard Webhooks way, with three headers:
| Header | What it is |
|---|---|
webhook-id | The event's id, the same as id in the body |
webhook-timestamp | When this try was sent, in seconds |
webhook-signature | v1, and a base64 signature. For 24 hours after a rotation there are two, separated by a space |
To check it, sign webhook-id.webhook-timestamp.body with HMAC-SHA256, keyed with your secret after whsec_ (decoded from base64). Accept the event if your result matches any of the signatures, and refuse a timestamp more than 5 minutes old. Always use the raw body, before any JSON parsing.
The standardwebhooks library for your language does all of this. Without it:
Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
export function fromRookery(secret, headers, rawBody) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest();
return headers["webhook-signature"].split(" ").some((part) => {
const [version, signature] = part.split(",");
const given = Buffer.from(signature ?? "", "base64");
return version === "v1" && given.length === expected.length && timingSafeEqual(given, expected);
});
}
Python
import base64, hashlib, hmac, time
def from_rookery(secret: str, headers: dict, raw_body: bytes) -> bool:
msg_id = headers["webhook-id"]
timestamp = headers["webhook-timestamp"]
if abs(time.time() - int(timestamp)) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
return any(
part.startswith("v1,") and hmac.compare_digest(part[3:], expected)
for part in headers["webhook-signature"].split(" ")
)
To change the secret, open the webhook and click Rotate secret. Both secrets sign every event for the next 24 hours, so you can switch over without missing anything.
Answering, retries and backoff
- Answer
2xxwithin 10 seconds. Any2xxcounts as delivered. Put the event on your own queue and do the slow work afterwards. - Anything else is tried again after about 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, 3 hours, 6 hours and 12 hours, each give or take 20%. That's about a day in all, then Rookery stops trying that event.
- Redirects aren't followed. Use the final address.
410 Gonemeans "stop". Rookery turns the webhook off and emails its owner.- A day of failures pauses it. If a webhook fails for a whole day without one success, Rookery pauses it and emails its owner. Nothing more is sent until someone clicks Resume.
The delivery log and Resend
Each webhook's page has a Delivery log of the last 30 days. Filter it by Failed or Delivered, or by event. Open a delivery to see every try, the status code and what your app answered, and exactly what was sent.
Resend sends that event again, now, with the same id. Use it after fixing a bug in your app; if you skip ids you've handled, a Resend of one you did handle is harmless.
Good to know
- Rookery only sends to public
https://addresses. It refuses private networks, and addresses whose name points to one, both when you add the webhook and before every send. - Each webhook has its own events. Add as many as you need, like one for Zapier and one for your own app.
- Every change to a webhook is in the audit log, with who made it.