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#
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
{
"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.
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))
}
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"])
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.