Webhooks

Get a signed HTTPS request at your own URL when something happens on your site.

Set up an endpoint

Open Site settings, go to the Data tab and find Webhooks. Add the URL, pick the triggers and copy the signing secret. It is shown once; you can rotate it later. Webhooks are available on the Starter plan and up. Each site can have up to 10 endpoints, and endpoints belong to one site.

Triggers

  • event.received: a custom event you named (up to 20 names per endpoint). One request per event.
  • goal.converted: a goal was completed, once per session and goal. Includes the traffic source of the visit. Choose all goals or specific ones.
  • alert.triggered: one of your traffic alerts fired.
  • uptime.down and uptime.up: your site stopped or started answering.

Events and goals are picked up about once a minute, so expect a delay of 30 to 90 seconds.

Payload

JSON endpoints receive a body that follows the Standard Webhooks format. The text field is a one-line summary in English.

{
  "id": "whd_1234",
  "type": "goal.converted",
  "created_at": "2026-10-27T10:15:00Z",
  "site": { "id": "5b1f0c2e-...", "domain": "example.com" },
  "data": {
    "goal": { "id": "...", "name": "Signup", "type": "pageview", "target": "/thanks" },
    "pathname": "/thanks",
    "occurred_at": "2026-10-27T10:14:41.000Z",
    "source": {
      "channel": "Organic Search",
      "referrer_domain": "google.com",
      "utm_source": null,
      "utm_medium": null,
      "utm_campaign": null
    }
  },
  "text": "Goal \"Signup\" converted on example.com (from Organic Search)."
}

Payloads never contain visitor ids, session ids, IP addresses or cities. Event properties are the ones you sent yourself. Payloads are capped at 64 KB; if properties do not fit they are left out and truncated is set to true.

Slack and Discord

Choose the Slack or Discord format to post the text summary to an incoming webhook URL instead of the JSON envelope.

Headers and signature

  • webhook-id: the delivery id. Stays the same across retries, so you can use it to ignore duplicates.
  • webhook-timestamp: unix seconds of this attempt.
  • webhook-signature: v1, followed by the base64 HMAC-SHA256 of id.timestamp.body, keyed with your secret (base64-decoded, without the whsec_ prefix).

Verify a request

Any Standard Webhooks library works. In Node.js without a library:

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

// secret: the whsec_... value shown once when you created the endpoint
// rawBody: the request body exactly as received (a string, not parsed JSON)
export function verify(secret, headers, rawBody) {
  const id = headers["webhook-id"];
  const ts = headers["webhook-timestamp"];
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // replay window

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${ts}.${rawBody}`).digest("base64");

  return headers["webhook-signature"].split(" ").some((part) => {
    const [version, sig] = part.split(",");
    if (version !== "v1") return false;
    const a = Buffer.from(sig);
    const b = Buffer.from(expected);
    return a.length === b.length && timingSafeEqual(a, b);
  });
}

Retries and disabling

Answer with any 2xx status within 10 seconds. Anything else counts as a failure and is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 8 hours, about 11 hours in total. Redirects are not followed.

A 410 response disables the endpoint at once. After 25 failures in a row with no success in 24 hours the endpoint is disabled and the site owner gets an email. Switch it back on in the settings once the URL works again. Open Deliveries on an endpoint to see the last 50 attempts and retry failed ones.

Limits

  • Custom events: at most 500 deliveries per hour per endpoint. Events over the limit are counted in the settings but not sent.
  • Only public https URLs. Addresses on private networks are blocked.
  • Events from a subdomain site go to the endpoints of that subdomain site.

Self-hosting

Set WEBHOOKS_ALLOW_PRIVATE=1 to allow endpoints on your own network and plain http. See the self-hosting guide.