# Webhooks

Rookery tells your app when something happens, with a small signed POST. Events, verification, retries and the delivery log.

Rookery for developers · Updated October 11, 2026 · https://userookery.com/developers/webhooks/

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](https://userookery.com/help/webhooks/).

## Add a webhook

1. Go to **Settings → Integrations → Webhooks**.
2. Under **Send to**, paste your app's address. It must be a public `https://` address.
3. Add a **Description**, and under **Send these events**, tick what it should hear about.
4. Under **Which events**, choose **Live**, or **Sandbox** for a test endpoint that hears only the [sandbox](https://userookery.com/developers/sandbox/).
5. If you have a private inbox, choose **From which inboxes**: **Team inboxes**, or **Only my private inbox**.
6. Click **Add webhook**, then copy the **Signing secret**. It starts with `whsec_` and is shown once.
7. 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`](https://userookery.com/developers/reference/#event-ask-completed) | Everything you asked a client for is in (documents today) |
| [`client.replied`](https://userookery.com/developers/reference/#event-client-replied) | Someone wrote back on a conversation |
| [`conversation.assigned`](https://userookery.com/developers/reference/#event-conversation-assigned) | A conversation was given to someone, or to nobody |
| [`turn.changed`](https://userookery.com/developers/reference/#event-turn-changed) | Whose turn it is changed: yours, theirs, or done |
| [`draft.approved`](https://userookery.com/developers/reference/#event-draft-approved) | A person approved a helper's or an agent's draft |
| [`message.sent`](https://userookery.com/developers/reference/#event-message-sent) | An email went out |
| [`booking.created`](https://userookery.com/developers/reference/#event-booking-created) | Someone booked a time with you |
| [`booking.cancelled`](https://userookery.com/developers/reference/#event-booking-cancelled) | A booking was cancelled |
| [`scout.held`](https://userookery.com/developers/reference/#event-scout-held) | Scout held a likely scam |
| [`webhook.test`](https://userookery.com/developers/reference/#event-webhook-test) | Sent when you press Send test event |

Each event's fields, with an example, are in the [event reference](https://userookery.com/developers/reference/#webhook-events). The full schemas are in the [OpenAPI spec](https://userookery.com/developers/openapi.json), under `webhooks`.

## What Rookery sends

Every event has the same envelope. `data` is thin on purpose: ids to look things up with the [API](https://userookery.com/developers/reference/), 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.

```json
{
  "id": "evt_4pj6sy63n8spf4h9",
  "type": "client.replied",
  "version": 1,
  "created_at": "2026-10-13T09:14:02.118Z",
  "workspace": "wsp_0qadwjbmg1dw6wws",
  "livemode": true,
  "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"
  }
}
```

- `id` is the same on every try and on a **Resend**. Keep the ids you've handled and skip repeats.
- `version` changes 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.
- `livemode` is `false` for events from the [sandbox](https://userookery.com/developers/sandbox/), which go only to test endpoints, and `true` for everything else. Live events never go to a test endpoint.
- 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](https://www.standardwebhooks.com/) 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**

```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**

```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 `2xx` within 10 seconds.** Any `2xx` counts 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 Gone` means "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.
