# Webhooks

> Send each inquiry as signed JSON to Zapier, Make, a CRM or your own server (paid plans, 5 per site). Payload, signature verification, idempotency, retries, destination rules and test sends.

URL: https://siteon.dev/docs/webhooks

Siteon is infrastructure for AI-built websites: it adds hosting, a CMS your client can edit, contact forms, analytics and control from AI agents (CLI and MCP) around static HTML built with AI or by hand, so it is ready to ship.

## What it does

Every time a form accepts an inquiry, a **signed JSON POST** goes to the URLs you registered. Connect it to a "catch webhook" trigger in Zapier or Make, a CRM's intake endpoint, or your own server.

| Item | Details |
|---|---|
| Plans | Paid plans only (Lite, Standard, Business). **Not available on the Free plan** (nothing is sent for a site whose subscription ended and is treated like Free) |
| Count | Up to 5 per site. Choose which forms each one sends (none chosen means all forms) |
| Destination | `https://` URLs only ([destination rules](#destination-rules)) |
| Success | A 2xx response within 10 seconds |
| Retries | On failure, retried after 2, 4, 8, 16, 32, 60 and 60 minutes; marked failed after 8 attempts in total (about 3 hours) |
| Managed from | The dashboard ("Settings → Advanced → Webhooks"), the CLI (`siteon webhooks`) and MCP (`webhooks_*`) ([CLI, MCP and API keys](https://siteon.dev/docs/cli-mcp)) |

Only the site owner can add, delete and test webhooks (staff can only see the list). Webhooks run separately from email and Slack notifications, and a failed webhook never loses the inquiry.

## Adding one

In the dashboard, open "Settings → Advanced → Webhooks", enter the destination URL and choose the forms. With the CLI:

```sh
siteon webhooks add https://hooks.example.com/siteon --site hanaya --form contact
```

**The signing secret (a string starting with `whsec_`) is shown only once, when you add the webhook.** Store it on the receiving side, for example in an environment variable. It can't be viewed again. If it leaks or you lose it, delete the webhook and add it again (you get a new secret).

## Payload

A `POST <your URL>` with `content-type: application/json` and this body:

```json
{
  "event": "form.submitted",
  "site": "hanaya",
  "form": "contact",
  "submission_id": "…",
  "submitted_at": "2026-10-04T09:30:00.000Z",
  "data": { "name": "Emma Wilson", "email": "emma@example.com", "message": "I'd like to order an autumn bouquet" },
  "attribution": {
    "first": { "utm_source": "google", "utm_medium": "cpc", "gclid": "…", "landing": "/", "referrer": "…", "at": "…" },
    "last": { "utm_source": "instagram", "landing": "/menu/", "at": "…" }
  },
  "test": false
}
```

| Key | Contents |
|---|---|
| `event` | `form.submitted` (`webhook.test` for test sends) |
| `site` / `form` | Site ID / form key |
| `submission_id` | Submission ID, the same ID as in the dashboard list |
| `submitted_at` | When it was accepted (ISO 8601, UTC) |
| `data` | Field values (one per `fields[].key` in schema.json) |
| `attribution` | Traffic source: `first` (first visit) and `last` (latest visit), each with `utm_*`, `gclid`, `fbclid`, landing page and referrer ([traffic sources](https://siteon.dev/docs/script#recording-traffic-sources-utm)) |
| `test` | `true` for test sends |

Headers:

| Header | Contents |
|---|---|
| `X-SiteKit-Signature` | `t=<Unix seconds>,v1=<signature>`. The signature is the hex of `HMAC-SHA256(secret, t + "." + body)` |
| `Idempotency-Key` | The submission ID (same as `submission_id`). It stays the same across retries |
| `X-SiteKit-Event` | Same value as `event` |
| `X-SiteKit-Delivery` | An ID per delivery (for troubleshooting) |

## Verifying the signature

The signature format is the same as Stripe's webhooks. On the receiving side:

1. Take `t` and `v1` from the header
2. Compute HMAC-SHA256 with your secret over `t + "." + the body exactly as received`, as hex, and check it equals `v1` (use a constant-time comparison)
3. Reject it if `t` is more than 5 minutes away from now (stops old requests being replayed)

**Compute it over the raw body string, before parsing it as JSON.** Parsing and re-serializing changes whitespace or key order, and the signature won't match.

Node.js:

```js
import crypto from "node:crypto";

// rawBody: the body exactly as received (string); header: the X-SiteKit-Signature value
export function verify(rawBody, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1 ?? "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

Python:

```python
import hashlib, hmac, time

def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t = int(parts.get("t", "0"))
    if not t or abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))
```

Use the whole secret, including `whsec_`, as the key.

## Idempotency (when the same inquiry arrives twice)

If a response is cut off, for example, the same inquiry can be delivered more than once. `Idempotency-Key` (= `submission_id`) is the same on every retry, so **skip IDs you've already processed** and still return 2xx. That keeps your CRM free of duplicates.

## Retries and failures

- The first attempt goes out right after the inquiry is accepted. On failure (anything but 2xx, over 10 seconds, or no connection), it's retried after 2, 4, 8, 16, 32, 60 and 60 minutes, and marked failed after 8 attempts in total
- **Redirects (3xx) are not followed and count as failures.** Register the final URL that doesn't redirect
- The response body is never read. To answer quickly, queue the work on your side and return 2xx right away
- Each attempt is built from the saved inquiry, so **if you delete an inquiry before a retry, it won't be sent again**
- The latest results are shown in the dashboard under "Settings → Advanced → Webhooks" and in `siteon webhooks list`

## Destination rules

The destination is checked when you add it and **again before every attempt**. These are refused and never sent to:

- Anything other than `https://` (such as `http://`), an explicit port other than the default 443, or a URL containing `user:pass@`
- IP addresses written directly (public IPs too)
- Names without a dot, `localhost`, `.local`, `.internal`, `.home.arpa`, `.test`, `.example`, `.invalid` and other internal or reserved names
- Siteon's own hosts (the API, the dashboard, and the shared hosting domain and its subdomains)

To deliver to an in-house server, give it a public name reachable over https from the internet.

## Test sends

Use the test button under "Settings → Advanced → Webhooks" in the dashboard, or `siteon webhooks test <id>` (MCP: `webhooks_test`), to send a **dummy signed with the real secret** to the saved URL and see the result (HTTP status).

- `event` is `webhook.test`, `test` is `true` and `submission_id` starts with `test_`. `data` is dummy data matching the target form's fields; `attribution` is empty
- It isn't saved as an inquiry and doesn't count toward any limit on submissions
- Once per minute per webhook (it also counts toward the site's daily test-send allowance)

On the receiving side, it's safest to verify the signature of anything with `test: true` and then skip your real processing (such as creating a CRM record).

## API key scopes

Scopes needed when using the CLI or MCP ([CLI, MCP and API keys](https://siteon.dev/docs/cli-mcp#api-keys)).

| Operation | Scope needed |
|---|---|
| List (`webhooks list` / `webhooks_list`) | `sites:read` |
| Add (`webhooks add` / `webhooks_add`) | Both `sites:write` and `submissions:read` (a webhook can send field values out) |
| Delete (`webhooks remove` / `webhooks_remove`) | `sites:write` |
| Test send (`webhooks test` / `webhooks_test`) | `forms:test` |
