# Form submission webhooks: real-time delivery and signatures | Creght

> Register form submission webhooks on Creght: request format, HMAC-SHA256 verification of X-Creght-Signature, retries and idempotency, and a complete Func example that stores inquiries in a table.

Overview

- [Creght API for AI](/api.md)

Discoverability

- [How to optimize llms.txt](/api/optimize-llms-txt.md)

Site configuration

- [Configure talizen.config.ts](/api/talizen-config.md)
- [Implement domain-based locale routing](/api/domain-locale-routing.md)

Backend

- [Calling external APIs on the server and managing the cache](/api/ssr-external-api-cache.md)
- [Build site backend workflows with Func](/api/func-backend.md)
- [JSON tables: definition, reads, and queries](/api/func-json-tables.md)
- [Uploads: signed direct upload and Func-generated files](/api/func-assets-upload.md)
- [Timeouts and streaming responses](/api/func-timeout-streaming.md)
- [Integrate Alipay PC Web Payment with Func](/api/func-alipay-payment.md)
- [Form submission webhooks: real-time delivery and signatures](/api/form-webhook.md)

Integrations

- [Send Email and Verification Codes with Integrations](/api/func-email-integration.md)
- [Take Alipay payments with an integration](/api/func-alipay-integration.md)
- [Take Stripe payments with an integration](/api/func-stripe-integration.md)
- [Call OpenAI models with an integration](/api/func-ai-integration.md)
- [Add text to speech with an integration](/api/func-tts-integration.md)

Auth

- [Require a Verified Email to Sign Up](/api/auth-verified-registration.md)
- [Reset and Change Passwords](/api/auth-password-reset.md)
- [Sign In From a Func](/api/auth-func-login.md)
- [Query users from a Func](/api/func-user-directory.md)

On this page

- [Register a webhook](#register)
- [Request format](#request)
- [Verify the signature](#verify)
- [Receive with a Creght Func](#func)
- [Retries and idempotency](#retry)
- [Troubleshooting](#debug)
- [Limits](#limits)

Backend/Form 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.

Copy Markdown link

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.

**Scope**

Registering and managing webhooks, the request format, signature verification, retries and idempotency, and receiving with a Func. If you don't need real time, you can also poll incrementally with the MCP tool `form_submissions`; both return exactly the same submission shape.

## 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 |

> **`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"
  }
}
```

| 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](/api/func-backend.md#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.

> Full page index: [/llms.txt](/llms.txt)
