BackendForm submission webhooks: real-time delivery and signatures

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:

ToolParametersReturns
form_webhook_createproject_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_listproject_idThe project's webhooks, each with its last 10 deliveries
form_webhook_deleteproject_id, webhook_id{ deleted: true }; deliveries waiting to retry are dropped
form_webhook_testproject_id, webhook_idSends one test event synchronously and returns its status code, error, and the start of the response

secret is 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"
  }
}
FieldMeaning
eventform.submitted (a real submission) or form.test (a test event from form_webhook_test)
event_idThe id of this delivery; it stays the same across retries
submission.idThe submission id. Deduplicate on it.
submission.form_urlThe full URL of the page the form was submitted from, including the query, so utm_* parameters are here
submission.fieldsField key → string value. Multi-select values are comma-joined; files are accessible URLs
submission.countryCountry 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:

HeaderMeaning
X-Creght-EventSame as event
X-Creght-Event-IdSame as event_id
X-Creght-Webhook-IdSame as webhook_id
X-Creght-TimestampSend time, unix seconds
X-Creght-Signaturev1=<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 Gone stops 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

  1. After registering, call form_webhook_test and check status_code: 0 means no response was received (unreachable, timed out, or the address was refused); 401 usually means the signature check failed.
  2. If a real submission didn't arrive, call form_webhook_list and look at recent_deliveries: status (pending means waiting to retry), attempts, error, and response. 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.

Render diagnostics