문서
문서 둘러보기

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.

Markdown으로 보기

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.

The Webhooks page with endpoints, the events they receive and recent deliveries.
The Webhooks page with endpoints, the events they receive and recent deliveries.
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#

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" }
}

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.

js
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))
}
python
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)
ruby
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).