Form submission webhooks: real-time delivery and signatures
POST every site form submission to your https URL: register through MCP, verify the HMAC-SHA256 signature, get automatic retries, and receive with a Func in another Creght project that stores submissions idempotently.
Every time a site form receives a submission, Creght immediately sends a POST to the https URL you registered, signed with HMAC-SHA256. The receiver can be any https service, or a Func in another Creght project.
Register a webhook
Webhooks are managed through the Creght MCP server; there is no editor UI yet. Once an AI assistant or a local script is connected to MCP, it calls these four tools:
| Tool | Parameters | Returns |
|---|---|---|
form_webhook_create | project_id; url (https only); form_ids (optional, form ids or keys; omit it to cover every form in the project, including ones created later) | webhook (id, url, form_ids, secret_prefix, created_at) and secret |
form_webhook_list | project_id | The project's webhooks, each with its last 10 deliveries |
form_webhook_delete | project_id, webhook_id | { deleted: true }; deliveries waiting to retry are dropped |
form_webhook_test | project_id, webhook_id | Sends one test event synchronously and returns its status code, error, and the start of the response |
secretis returned only once, at creation. Afterwards only its first characters (secret_prefix) are shown, for checking. If you lose it, delete the webhook and create a new one. Each project can have up to 10 webhooks.
Request format
Each delivery is a POST with Content-Type: application/json:
{
"event": "form.submitted",
"event_id": "1287",
"webhook_id": "12",
"project_id": "p9k3n5y5hbbm",
"submission": {
"id": "3f9k2x",
"form_id": "8a1b2c",
"form_key": "contact",
"form_name": "Contact us",
"created_at": "2026-09-26T11:56:57.123456Z",
"form_url": "https://example.com/contact?utm_source=google&utm_campaign=q3",
"fields": { "name": "Jane", "email": "jane@example.com", "message": "Quote for 500 units" },
"ua": "Mozilla/5.0 …",
"country": "US"
}
}
| Field | Meaning |
|---|---|
event | form.submitted (a real submission) or form.test (a test event from form_webhook_test) |
event_id | The id of this delivery; it stays the same across retries |
submission.id | The submission id. Deduplicate on it. |
submission.form_url | The full URL of the page the form was submitted from, including the query, so utm_* parameters are here |
submission.fields | Field key → string value. Multi-select values are comma-joined; files are accessible URLs |
submission.country | Country code resolved from the submitter's IP (ISO 3166-1 alpha-2); absent when it can't be resolved. The IP itself is never sent |
Headers:
| Header | Meaning |
|---|---|
X-Creght-Event | Same as event |
X-Creght-Event-Id | Same as event_id |
X-Creght-Webhook-Id | Same as webhook_id |
X-Creght-Timestamp | Send time, unix seconds |
X-Creght-Signature | v1=<hex>, see the next section |
Verify the signature
hex = HMAC-SHA256(secret, "<X-Creght-Timestamp>.<raw request body>")
X-Creght-Signature = "v1=" + hex
- Verify against the raw bytes. Parsing the body as JSON and serializing it again can change the bytes, and the signature will no longer match.
- The timestamp is part of the signed input, so it can't be altered either. Also reject requests older than 5 minutes; that stops replays of intercepted requests.
- In environments like Node, compare signatures in constant time (for example
crypto.timingSafeEqual).
Receive with a Creght Func
The receiver can be a Func in another Creght project, at https://<site domain>/func/<key>. For example, inquiries from a site project can be pushed to an operations project whose Func writes them into a table. Read headers with ctx.request.headers.get() and the raw body with ctx.request.arrayBuffer(); see Reading the request.
Store the secret in the receiving project's environment variables (Backend → Env), for example as CREGHT_WEBHOOK_SECRET:
export async function main(input, ctx) {
const ts = ctx.request.headers.get("x-creght-timestamp") || ""
const sig = ctx.request.headers.get("x-creght-signature") || ""
const body = new Uint8Array(await ctx.request.arrayBuffer())
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
ctx.response.status(400)
return { error: "stale request" }
}
const prefix = new TextEncoder().encode(ts + ".")
const msg = new Uint8Array(prefix.length + body.length)
msg.set(prefix)
msg.set(body, prefix.length)
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(process.env.CREGHT_WEBHOOK_SECRET),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
)
const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, msg))
const hex = Array.from(mac, (b) => b.toString(16).padStart(2, "0")).join("")
if (sig !== "v1=" + hex) {
ctx.response.status(401)
return { error: "bad signature" }
}
const evt = JSON.parse(new TextDecoder().decode(body))
if (evt.event === "form.test") return { ok: true }
const s = evt.submission
const existing = await ctx.db.query("leads", { where: { submission_id: s.id }, limit: 1 })
if (existing.total === 0) {
await ctx.db.insert("leads", {
submission_id: s.id,
form: s.form_key,
name: s.fields.name,
email: s.fields.email,
message: s.fields.message,
page: s.form_url,
country: s.country,
submitted_at: s.created_at,
})
}
return { ok: true }
}
- Func URLs don't require login by default, so the signature check is the only gate. Don't skip it.
- Each delivery uses one Func call from the receiving project's quota.
- The receiving project needs a reachable site domain.
Retries and idempotency
- A 2xx response counts as success. Each request times out after 10 seconds.
- Otherwise it retries after 30 seconds, 2 minutes, 10 minutes, 1 hour, and 6 hours, up to 6 attempts. Returning
410 Gonestops retries immediately. - 4xx responses are retried too: if the receiver is misconfigured, later retries still arrive once it's fixed.
- The same submission can arrive more than once (for example when you processed it but the response timed out). Handle it idempotently by
submission.id. - Redirects are not followed. If the URL changes, delete the webhook and create a new one.
Troubleshooting
- After registering, call
form_webhook_testand checkstatus_code:0means no response was received (unreachable, timed out, or the address was refused);401usually means the signature check failed. - If a real submission didn't arrive, call
form_webhook_listand look atrecent_deliveries:status(pendingmeans waiting to retry),attempts,error, andresponse. Delivery records are kept for 30 days.
Limits
- Only https URLs are allowed, and they can't point to private, loopback, or cloud metadata addresses.
- Up to 10 webhooks per project.
- Covers forms on Creght sites only; forms from the legacy visual editor don't trigger webhooks.
- For now webhooks are managed only through MCP: there is no editor UI and no CLI command.
