# Webhooks for analytics events

> Receive signed webhooks when alerts fire, uptime changes, exports finish or sites change, and verify the HMAC-SHA256 signature in Node.js, Python or Ruby.

**Workspace settings → Webhooks → Add endpoint.** Enter an `https` URL and
choose the events. A **signing secret** (`whsec_…`) is created for the
endpoint.

## Events

| Event | Sent when |
|---|---|
| `alert.triggered` | An alert fired |
| `alert.resolved` | An alert recovered |
| `goal.completed_threshold` | A goal reached its completion threshold |
| `uptime.down` | An uptime check started failing |
| `uptime.up` | An uptime check recovered |
| `export.ready` | An export is ready to download |
| `import.completed` | An import finished |
| `site.created` | A site was added |
| `site.deleted` | A site was deleted |
| `member.added` | A member joined the workspace |
| `usage.threshold` | Usage crossed a share of the plan allowance |

**Send test** delivers a `webhook.test` event.

## Request

```http
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Privatus-Webhooks/1.0
Privatus-Event: uptime.down
Privatus-Delivery: whd_…
Privatus-Signature: t=1790000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

```json
{
  "id": "whd_…",
  "event": "uptime.down",
  "created_at": "2026-09-29T10:15:00Z",
  "workspace_id": "ws_7Q2K9XH3AB",
  "data": { "…": "event-specific fields" }
}
```

Respond with any `2xx` within 10 seconds. Anything else, or a timeout,
counts as a failure and is retried up to 8 times with exponential backoff
(from about a minute to a few hours). Test deliveries aren't retried. The delivery log shows
every attempt with its status and response, and you can **redeliver** any
delivery. After 20 consecutive failures the endpoint is disabled. Fix it
and re-enable it.

## Verify the signature

`Privatus-Signature` is `t=<unix time>,v1=<hex HMAC-SHA256>` where the HMAC
is computed with your signing secret over `"<t>.<raw request body>"`.
Reject requests whose signature doesn't match or whose `t` is more than
five minutes old.

```js
import crypto from 'node:crypto'

function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
  const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex')
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300
  return fresh && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
}
```

```python
import hashlib, hmac, time

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return abs(time.time() - int(parts["t"])) < 300 and hmac.compare_digest(expected, parts["v1"])
```

```ruby
def verify(raw_body, header, secret)
  parts = header.split(",").to_h { |p| p.split("=", 2) }
  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{parts['t']}.#{raw_body}")
  (Time.now.to_i - parts["t"].to_i).abs < 300 &&
    ActiveSupport::SecurityUtils.secure_compare(expected, parts["v1"])
end
```

Use the **raw** body as received. Re-serializing the JSON changes it.

## Security

We only deliver to public addresses (never to private or internal
networks), from the User-Agent `Privatus-Webhooks/1.0`. Webhooks are
available on Pro and above.
