# Webhooks

:::note
A webhook is the same event stream as [events](/guides/units-api/events), pushed to your server. Webhook routes are for people only (a signed-in user or a personal token with manage access); a service principal cannot call them.
:::

A Workload can have **up to 5 webhooks**. Each has an `https` URL on a public host, an `event_types` filter (a list, or all) and a state: `active`, `failing` or `disabled`. Creating one returns `{webhook, secret}`: the signing secret, `whsec_` and 43 characters, is **shown once** (and again only by `rotate-secret`). A new webhook starts with the events after the moment you create it. `disabled_reason` is `failing_72h` when it was disabled by repeated failure, otherwise `null`.

| Route | Does |
|---|---|
| `GET`, `POST .../units/webhooks` | list, create |
| `GET`, `PATCH`, `DELETE .../units/webhooks/{webhook_id}` | read, change, delete |
| `POST .../units/webhooks/{webhook_id}/rotate-secret` | new secret; the old one keeps signing for 24 hours |
| `POST .../units/webhooks/{webhook_id}/test` | sends a `webhook.ping` |
| `POST .../units/webhooks/{webhook_id}/enable` | re-enable a disabled webhook |
| `GET .../units/webhooks/{webhook_id}/deliveries` | attempts, newest first: time, event range (`from_seq`, `to_seq`), status code, latency, `error`, first 4 KiB of your answer, next attempt (kept 30 days). `limit` 1 to 100 (default 50), `before` pages back |
| `GET .../units/webhooks/metrics` | per webhook over the last `window_hours` (1 to 720, default 24): `succeeded`, `failed`, `success_rate`, average, p95 and max latency |

`test` answers with the attempt itself (status, latency, error) and is limited to 6 a minute per webhook. It works on a disabled webhook too and does not move the cursor. Changing the `url` or `event_types` with `PATCH` keeps delivering from the same cursor; `event_types: []` means every type.

**curl**

```bash
W="$ZAROZ_API/projects/$PROJECT/service-instances/$SERVICE/units/webhooks"
curl -sS -X POST "$W" -H "$AUTH" -H "Content-Type: application/json" \
  -d '{"url": "https://hooks.example.com/zaroz", "event_types": ["unit.exited", "unit.failed"]}'
curl -sS -X POST "$W/<webhook.id from the create response>/test" -H "$AUTH"             # sends webhook.ping
```

**TypeScript**

```ts
const W = `${UNITS}/webhooks`;
const hook = await call("POST", W, { url: "https://hooks.example.com/zaroz", event_types: ["unit.exited", "unit.failed"] });
console.log(hook.body.secret);                                    // whsec_...: shown once
await call("POST", `${W}/${hook.body.webhook.id}/test`);                  // sends webhook.ping
```

**Python**

```python
W = f"{UNITS}/webhooks"
_, hook = call("POST", W, {"url": "https://hooks.example.com/zaroz", "event_types": ["unit.exited", "unit.failed"]})
print(hook["secret"])                                             # whsec_...: shown once
call("POST", f"{W}/{hook['webhook']['id']}/test")                            # sends webhook.ping
```


## What you receive

A `POST` of `{webhook_id, delivery_id, events: [...]}`: **at most 50 events or 256 KiB**, in `seq` order, **at least once**. The next batch is sent only after your `2xx` (within 10 seconds), so order is the promise. Deduplicate on `seq`.

Headers: `Zaroz-Signature: v1=<hex>`, `Zaroz-Timestamp` (Unix seconds), `Zaroz-Webhook-Id`, `Zaroz-Delivery-Id`.

## Verify the signature

The signature is `HMAC-SHA256(secret, "{timestamp}.{raw body}")` in hex. During a rotation the header holds **two** `v1=` values, comma separated: accept either. Refuse a timestamp more than 5 minutes away. Verify the **raw bytes** you received, not a re-serialised body.

**curl**

```bash
# the signed string is "<Zaroz-Timestamp>.<raw body>"; compare against each v1= value
printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$WHSEC" -hex
```

**TypeScript**

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(secret: string, timestamp: string, rawBody: string, header: string): boolean {
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;   // refuse > 5 min off
  const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
  return header.split(",").some((part) => {
    const sig = part.trim().replace(/^v1=/, "");
    return sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
  });
}
```

**Python**

```python
import hashlib, hmac, time

def verify(secret: str, timestamp: str, raw_body: bytes, header: str) -> bool:
    if abs(time.time() - int(timestamp)) > 300:      # refuse more than 5 minutes off
        return False
    signed = timestamp.encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(p.strip().removeprefix("v1="), expected) for p in header.split(","))
```


## Retries and disabling

Failed deliveries retry after 5 s, 30 s, 2 min, 10 min, 30 min, 1 h, then hourly. A webhook failing for 72 hours becomes `disabled` (`failing_72h`) and the organization owners are notified; its cursor stays. After `enable`, delivery resumes from the cursor if the events are still kept, else from the oldest kept event, preceded by an `events.gap {from_seq, to_seq}` event so you know you missed some.

## What the platform refuses to call

The target is resolved once per attempt and every address is checked: loopback, private, link-local, carrier-grade NAT, multicast, unique-local IPv6 and the platform's own ranges are refused. On create or update the answer is an `invalid-field-value` refusal with `address_not_allowed: ...` in `details`; later, at delivery time, the delivery log shows `address_not_allowed` as the `error`. The other `error` values are `dns`, `connect`, `timeout`, `http_status`, `redirect_not_followed`, `invalid_url` and `transport`. A sixth webhook is refused with `409 business-rule-violation` (`too_many_webhooks` in `details`). Redirects are not followed. Only the first 4 KiB of your response is read.
