# Webhooks

Get a signed HTTPS call when members change, requests are submitted or decided, or announcements go out.

Webhooks let Crew tell your own systems when something happens, instead of you asking the
[API](/crew/api) over and over. Crew sends a signed JSON `POST` to your HTTPS address within seconds of
the event.

## Adding a webhook

Super Users open **Settings → Integrations → Webhooks → Add webhook** and enter:

- the **URL**: a public `https://` address. Addresses with a username or password, IP addresses and
  internal hostnames aren't accepted.
- the **Events** to send (see below).

Crew creates a **signing secret** (starting with `whsec_`). Copy it into your receiving system; you
need it to check that deliveries really come from Crew. An organization can have up to 10 webhooks.

Click **Test** to send a `ping` event straight away, and **Deliveries** to see recent attempts, their
status code and how many tries they took.

## Events

| Event                    | Sent when                                                                          | `data`                                                                   |
| ------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| `employee.created`       | A member is added (by hand, import, automatically or by accepting an application). | The member (see below).                                                  |
| `employee.updated`       | A member's profile or role changes.                                                | The member.                                                              |
| `employee.deleted`       | A member is deleted.                                                               | `{ "id", "name" }`, or `{ "id", "erased": true }` after a GDPR deletion. |
| `request.submitted`      | A member submits a request.                                                        | `{ "id", "number", "type", "employeeId" }`                               |
| `request.decided`        | A request is approved or rejected.                                                 | `{ "id", "number", "type", "decision", "employeeId" }`                   |
| `announcement.published` | An announcement is posted.                                                         | `{ "id", "title", "departmentId" }`                                      |
| `ping`                   | You click **Test**.                                                                | `{ "message": "Hello from Crew" }`                                       |

The member object has `id`, `name`, `title`, `departmentId`, `unitId`, `managerId`,
`employmentStatus` and `discordId`. Like the API, webhooks never include contact details, bios,
notes, disciplinary records or leave reasons.

## What a delivery looks like

```http
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Crew-Webhooks/1
X-Crew-Event: request.decided
X-Crew-Signature: t=1793030400,v1=5d41402abc4b2a76b9719d911017c592...

{
  "id": "6a1f0d3e-7c2b-4f9a-8e5d-3b1c9a7e2f40",
  "event": "request.decided",
  "guildId": "123456789012345678",
  "occurredAt": "2026-10-26T14:00:00.000Z",
  "data": { "id": "b2e4f6a8-1c3d-4e5f-8a9b-0c1d2e3f4a5b", "number": "REQCREW000042", "type": "leave", "decision": "approved", "employeeId": "1001" }
}
```

`id` is unique per event: use it to ignore an event you've already handled (a retry can deliver the
same event twice).

## Checking the signature

Always check that a delivery came from Crew before trusting it:

1. Read `t` (a Unix time in seconds) and `v1` from the `X-Crew-Signature` header.
2. Compute **HMAC-SHA256** of `<t>.<raw request body>` using your signing secret, as lowercase hex.
3. Compare it with `v1` using a constant-time comparison.
4. Reject the delivery if `t` is more than about 5 minutes old, so a captured request can't be
   replayed later.

Use the **raw** body exactly as received, before any JSON parsing.

```js
// Node.js (Express): app.post("/crew-webhook", express.raw({ type: "application/json" }), handler)
import { createHmac, timingSafeEqual } from "node:crypto";

function verifyCrewWebhook(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false; // too old
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const given = Buffer.from(parts.v1 ?? "", "hex");
  const wanted = Buffer.from(expected, "hex");
  return given.length === wanted.length && timingSafeEqual(given, wanted);
}
```

## Responding, retries and pausing

- Answer with any **2xx** status within **10 seconds**. Do slow work after answering.
- Redirects aren't followed, and count as failures.
- A failed delivery is retried after about **1 minute, 5 minutes, 30 minutes, 2 hours and 12
  hours**: six attempts over roughly 15 hours. After that it's abandoned.
- After **10 failed deliveries in a row**, the webhook is **paused**. Fix your endpoint, then turn it
  back on in the webhook list.

Deliveries are kept for 7 days.

> [!WARNING]
> Webhooks send organization data to a system you control, and your organization is responsible for
> it. Treat the signing secret like a password. If it leaks, delete the webhook and add it again to
> get a new secret.

Source: https://docs.noyralabs.com/crew/webhooks
