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. A signing secret
(whsec_…) is created for the endpoint. Managing webhooks needs the
workspace.manage permission, and a token or member limited to some
sites can't manage them.
| Option | API input | What it does |
|---|---|---|
| Endpoint URL | url |
Where we POST. It must be https, at most 2,000 characters, and resolve to a public address |
| Events | events |
The events to send to this endpoint. At least one |
| Enabled | enabled |
Off stops all deliveries except tests. Turning it back on resets the failure count |
| Rotate the signing secret | rotate_secret |
When editing: replaces the secret with a new one right away. The old one stops working |
Events#
| Event | Sent when |
|---|---|
alert.triggered |
An alert fired |
alert.resolved |
An alert recovered |
goal.completed_threshold |
A goal alert fired (sent along with alert.triggered, with the same data) |
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 |
import.failed |
An import failed |
site.created |
A site was added |
site.deleted |
A site was deleted |
member.added |
A member joined the workspace |
usage.threshold |
A usage alert fired (sent along with alert.triggered, with the same data) |
site_audit.completed |
A site audit finished |
experiment.ended |
An experiment ended, with or without a winner |
testimonial.received |
A testimonial arrived through a form, by hand or the API |
testimonial.approved |
A testimonial was approved |
testimonial.rejected |
A testimonial was rejected |
testimonial.trashed |
A testimonial was moved to the trash |
testimonial.restored |
A testimonial was restored from the trash |
testimonial.deleted |
A testimonial was deleted permanently |
testimonial_form.submitted |
A form was sent, whether it became a testimonial or private feedback |
testimonial_request.sent |
The first emails of a request went out |
testimonial_request.completed |
A request finished: every person answered, opted out or got every email |
testimonial_import.finished |
An import finished reading, or failed |
testimonial_sync.new_found |
An automatic sync stored new reviews |
Send test delivers a webhook.test event, also to a disabled
endpoint.
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" }
}
data depends on the event:
| Event | data |
|---|---|
alert.*, goal.completed_threshold, usage.threshold |
alert_id, name, kind, site_id, state, value, message, triggered_at, resolved_at |
site_audit.completed |
site_id, audit_id, status, score, pages_crawled, errors, warnings, notices, new_issues, fixed_issues |
experiment.ended |
site_id, experiment_id, name, path, end_reason, winner (id and name, or null) and variants (each with id, name, control, visits, conversions, conversion_rate, uplift, p_value, chance) |
testimonial.received |
site_id, testimonial (as in the testimonials operations of the reference, without the person's email), source, held_for_allowance |
testimonial.approved, testimonial.rejected, testimonial.restored |
site_id, testimonial, and approved_by or rejected_by |
testimonial.trashed |
site_id, testimonial_id, purge_at |
testimonial.deleted |
site_id, testimonial_id, reason (permanent or purged) |
uptime.down, uptime.up |
check and incident, as in the uptime operations of the reference |
export.ready |
The export, as in the exports operations of the reference |
import.completed, import.failed |
import_id, site_id, source, rows_imported, status |
site.created, site.deleted |
site_id, domain |
member.added |
email, role, joined_at |
testimonial_request.sent |
site_id, request_id, form_id, recipients_count |
testimonial_request.completed |
site_id, request_id, sent, opened, started, submitted |
testimonial_form.submitted, testimonial_import.finished, testimonial_sync.new_found |
Described on Forms and Automatic sync. Never a giver's email |
webhook.test |
message |
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
(after about 1 minute, then doubling, up to about 2 hours between
attempts). Test deliveries aren't retried. The delivery log shows each
delivery with its latest status, response (the first 2,000 characters),
duration and number of attempts, and you can Resend any delivery.
After 20 failed attempts in a row the endpoint is disabled and pending
retries stop. Fix it and enable it again.
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. Anyone can send your endpoint a request, so treat a
missing or malformed header as a failed check (never as an error), and
compare the signatures in constant time.
import crypto from 'node:crypto'
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(String(header).split(',').map((p) => p.split('=')))
if (!/^[0-9]+$/.test(parts.t) || !/^[0-9a-f]{64}$/.test(parts.v1)) return false
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, re, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in (header or "").split(",") if "=" in p)
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not re.fullmatch("[0-9]+", t) or not re.fullmatch("[0-9a-f]{64}", v1):
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return abs(time.time() - int(t)) < 300 and hmac.compare_digest(expected, v1)
require "openssl"
def verify(raw_body, header, secret)
parts = header.to_s.scan(/(?:\A|,)(t|v1)=([^,]*)/).to_h
t, v1 = parts.values_at("t", "v1")
return false unless t.to_s.match?(/\A[0-9]+\z/) && v1.to_s.match?(/\A[0-9a-f]{64}\z/)
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{t}.#{raw_body}")
(Time.now.to_i - t.to_i).abs < 300 && OpenSSL.secure_compare(expected, v1)
end
Use the raw body as received. Re-serializing the JSON changes it.
The same delivery can arrive more than once (a retry after a timeout),
each time with a fresh signature. Use the id in the body (also sent as
Privatus-Delivery) to ignore one you have already handled. A resend
from the delivery log is a new delivery with a new id and the same
data.
Security#
We only deliver to public addresses (never to private or internal
networks), from the User-Agent Privatus-Webhooks/1.0. Webhook payloads
never contain visitor data. Webhooks are available on every plan.
Tests and resends are limited to 60 per workspace an hour (Rate limits).