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) |
| 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) |
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:
siteon webhooks add https://hooks.example.com/siteon --site hanaya --form contactThe 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:
{
"event": "form.submitted",
"site": "hanaya",
"form": "contact",
"submission_id": "…",
"submitted_at": "2026-10-04T09:30:00.000Z",
"data": { "name": "Emma Wilson", "email": "[email protected]", "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) |
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:
- Take
tandv1from the header - Compute HMAC-SHA256 with your secret over
t + "." + the body exactly as received, as hex, and check it equalsv1(use a constant-time comparison) - Reject it if
tis 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:
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:
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 ashttp://), an explicit port other than the default 443, or a URL containinguser:pass@ - IP addresses written directly (public IPs too)
- Names without a dot,
localhost,.local,.internal,.home.arpa,.test,.example,.invalidand 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).
eventiswebhook.test,testistrueandsubmission_idstarts withtest_.datais dummy data matching the target form's fields;attributionis 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).
| 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 |