# Send events to other apps with webhooks

Webhooks tell Zapier, Make, n8n or your own app the moment a client replies, documents come in or a time is booked, with a signed message.

For developers · Updated October 11, 2026 · https://userookery.com/help/webhooks/

A webhook sends a short message from Rookery to an address you choose, the moment something happens. Use it to start a Zap, a Make scenario, an n8n workflow or your own code when a client replies, documents come in or someone books a time.

Webhooks come with the Pro and Team plans, with the API. Only owners and admins can set them up.

## Add a webhook

1. Go to **Settings → Integrations → Webhooks**.
2. Under **Send to**, paste the address your other app gave you. It must start with `https://`.
3. Add a **Description**, like "Zapier: new client replies", so you know what it's for later.
4. Under **Send these events**, tick what it should hear about.
5. If you have a private inbox, choose **From which inboxes**: **Team inboxes**, or **Only my private inbox**.
6. Click **Add webhook**.
7. Copy the **Signing secret** now. It starts with `whsec_`, and it won't be shown again.

Then open the webhook and click **Send test event** to check it arrives. The answer shows straight away, and in the log.

## The events

| Event | When it's sent |
|---|---|
| `ask.completed` | Everything you asked a client for is in (document requests today) |
| `client.replied` | Someone writes back on a conversation, by email or on a secure page |
| `conversation.assigned` | A conversation is given to someone, or to nobody |
| `turn.changed` | Whose turn it is changes: `ours`, `theirs` or `done` |
| `draft.approved` | A person approves a helper's or an agent's draft |
| `message.sent` | An email goes out |
| `booking.created` | Someone books a time with you |
| `booking.cancelled` | A booking is cancelled |
| `scout.held` | Scout holds a likely scam |
| `webhook.test` | You click **Send test event** |

## What a message looks like

Each message is a `POST` with a small JSON body. It has ids and a one-line summary. It never has the email's words, a note, a file or an attachment. To read more, use the ids with the [API](https://userookery.com/help/api-keys/).

```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**, so your app can skip one it has already handled.
- `version` changes only if an event changes in a way that could break your app. New fields can be added at any time.

## Who hears about private mail

A webhook for **Team inboxes** hears about team inboxes only, never anyone's private inbox. A webhook for **Only my private inbox** hears about yours, and only you can see it, its log and what it sent.

## Check that a message came from Rookery

Every message is signed the [Standard Webhooks](https://www.standardwebhooks.com/) way, with three headers:

- `webhook-id`: the event's id
- `webhook-timestamp`: when it was sent, in seconds
- `webhook-signature`: `v1,` and a signature. Sometimes there are two, with a space between them

To check it, sign `webhook-id.webhook-timestamp.body` with HMAC-SHA256, using the secret after `whsec_` (decoded from base64) as the key. Accept the message 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 for you. 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(" ")
    )
```

## Change the secret

Open the webhook and click **Rotate secret**. Copy the new one. For the next 24 hours every message is signed with both secrets, so you can update your app without missing anything. After that, only the new one works.

## Retries and the delivery log

- Any `2xx` answer counts as delivered. Answer quickly, within 10 seconds, 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. That's about a day in all.
- Rookery doesn't follow redirects. Use the final address.
- An answer of `410 Gone` means "stop sending". Rookery turns the webhook off and emails you.
- If a webhook fails for a whole day without one success, Rookery pauses it and emails you. Nothing more is sent until you click **Resume**.

The webhook's page has a **Delivery log** of the last 30 days. Filter it by **Failed** or **Delivered**, or by event. Open a row to see each try, what your app answered and exactly what was sent. **Resend** sends that event again, now.

You can **Pause** a webhook any time, and **Delete** it when you're done. Deleting can't be undone.

## Recipes

### Zapier

1. Make a Zap with **Webhooks by Zapier** as the trigger, and choose **Catch Hook**.
2. Copy the address Zapier shows, and add it in Rookery as a webhook.
3. In Rookery, click **Send test event**. Back in Zapier, click **Test trigger** and pick the request.
4. Use `type` and `data.summary` in your Zap. To act only on replies, add a **Filter** step: `type` exactly matches `client.replied`.

### Make

1. Make a scenario that starts with **Webhooks → Custom webhook**, and click **Add** to make a webhook.
2. Copy its address and add it in Rookery.
3. Click **Re-determine data structure** in Make, then **Send test event** in Rookery.
4. Add a **Router** with a filter on `type` for each event you handle.

### n8n

1. Add a **Webhook** node. Set **HTTP Method** to `POST` and **Respond** to "Immediately".
2. Copy the **Production URL** (it must be https) and add it in Rookery.
3. Activate the workflow, then click **Send test event** in Rookery.
4. To check signatures, add a **Code** node with the Node.js check above, using the raw body.

## Good to know

- Rookery only sends to public `https://` addresses. It won't send to private networks or to an address that points to one.
- Each webhook can have its own events. Add as many as you need.
- Every change to a webhook is in the audit log, with who made it.
