Skip to content
Siteon
Contents Webhooks

Docs

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.

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.

ItemDetails
PlansPaid 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)
CountUp to 5 per site. Choose which forms each one sends (none chosen means all forms)
Destinationhttps:// URLs only (destination rules)
SuccessA 2xx response within 10 seconds
RetriesOn failure, retried after 2, 4, 8, 16, 32, 60 and 60 minutes; marked failed after 8 attempts in total (about 3 hours)
Managed fromThe 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 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:

{
  "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
}
KeyContents
eventform.submitted (webhook.test for test sends)
site / formSite ID / form key
submission_idSubmission ID, the same ID as in the dashboard list
submitted_atWhen it was accepted (ISO 8601, UTC)
dataField values (one per fields[].key in schema.json)
attributionTraffic source: first (first visit) and last (latest visit), each with utm_*, gclid, fbclid, landing page and referrer (traffic sources)
testtrue for test sends

Headers:

HeaderContents
X-SiteKit-Signaturet=<Unix seconds>,v1=<signature>. The signature is the hex of HMAC-SHA256(secret, t + "." + body)
Idempotency-KeyThe submission ID (same as submission_id). It stays the same across retries
X-SiteKit-EventSame value as event
X-SiteKit-DeliveryAn 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:

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 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).

OperationScope 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

This page is also available as Markdown. For AI agents, pass llms.txt (contents) or llms-full.txt (full text).