> ## Documentation Index
> Fetch the complete documentation index at: https://docs.clustr-ai.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Organization webhooks

> Get signed events for every workspace of your organization at one URL

Clustr calls your server when something happens in any of your workspaces, so your systems
hear about it right away. You get one webhook URL per organization. These calls need the
`exports:manage` scope.

> [!NOTE]
>
> The organization webhook sends short events that tell you what happened; you fetch the
> data with the API. To receive the full results of one job, give that import a
> `webhook_url` (see [Webhook Callbacks](https://docs.clustr-ai.com/webhook.md)).

## Set the URL

```bash theme={null}
curl -X PUT https://api.clustr-ai.com/api/org/v1/webhook \
  -H "Authorization: Bearer $CLUSTR_ORG_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://hooks.example.com/clustr" }'
```

The URL must be `https` on a public host. The first call returns the signing `secret`
(it starts with `whsec_`). It is shown once. A later `PUT` changes the URL and keeps the
secret.

| Call | What it does |
| - | - |
| `GET /api/org/v1/webhook` | The URL and when the secret last changed. |
| `PUT /api/org/v1/webhook` | Set or change the URL. |
| `POST /api/org/v1/webhook/rotate-secret` | New secret, shown once. Deliveries from then on use it. |
| `GET /api/org/v1/webhook/deliveries` | Recent deliveries: status, attempts, last HTTP status. `limit` defaults to 50. |

## Events

| `type` | When |
| - | - |
| `job.completed` | A job of one of your workspaces finished. |
| `job.failed` | A job failed. |
| `tenant.deleted` | You deleted a workspace. |
| `person.erased` | An [erasure](https://docs.clustr-ai.com/enterprise/erasure.md) finished. `data` has `erasureId`, `gdprSubject` and the number of `tenants`. |

Each call is a `POST` with a JSON body:

```json theme={null}
{
  "id": "6f1c0a52-3c6e-5a52-9d57-0c3d1f0f0a11",
  "type": "job.completed",
  "createdAt": "2026-10-13T09:30:00Z",
  "data": { "tenantId": "...", "externalId": "client-1234", "jobId": "...", "status": "completed" }
}
```

`id` is the event id. The same event always has the same `id`, also in the
`X-Clustr-Event-Id` header. Store the ids you have handled and ignore repeats.

## Check the signature

Every call carries `X-Clustr-Signature: t=<unix seconds>,v1=<hex>`. `v1` is the
HMAC-SHA256, keyed with your secret, of the text `<t>.<raw request body>`.

Always do three things:

1. Compute it on the raw bytes exactly as you received them.
2. Compare in constant time.
3. Reject a `t` more than five minutes old.

```python Python theme={null}
import hmac, hashlib, time

def valid(secret: str, header: str, raw_body: bytes) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    if abs(time.time() - int(parts["t"])) > 300:
        return False
    signed = parts["t"].encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])
```

```javascript Node theme={null}
import crypto from "node:crypto";
import express from "express";

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

const app = express();
// express.raw keeps the body as the bytes Clustr sent.
app.post("/clustr", express.raw({ type: "application/json" }), (req, res) => {
  if (!valid(process.env.CLUSTR_WEBHOOK_SECRET, req.get("X-Clustr-Signature") ?? "", req.body)) {
    return res.sendStatus(401);
  }
  const event = JSON.parse(req.body.toString("utf8"));
  // Ignore event.id values you have already handled.
  res.sendStatus(204);
});
```

## Retries

Answer with any `2xx` within 10 seconds. Anything else, or no answer, is retried up to 8
times. The wait starts at 30 seconds, then 1, 2 and 4 minutes and so on, and is at most
6 hours. After the last try the delivery is marked `failed` and stays in
`GET /api/org/v1/webhook/deliveries`. Redirects are not followed.

## In MCP

Organization admins have `org_get_webhook`, `org_set_webhook`, `org_rotate_webhook_secret`
and `org_list_webhook_deliveries`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.