Skip to content

Signed Webhooks

Webhooks send JSON to your app whenever this form accepts a real, non-spam submission. Configure up to three endpoints per form in Forms → your form → Integrations. Webhooks are included on every plan. If setup is marked unavailable, webhook delivery has not been enabled for this environment yet.

  1. Choose Add webhook. Give it a name and enter a public HTTPS URL on port 443.
  2. Choose All form fields, Selected fields, or Link only. The preview uses sample data. “All form fields” includes fields you add in the future.
  3. Choose Create webhook. It starts paused. Reveal or copy its signing secret, install it in your receiver’s server environment, and deploy or restart the receiver so it uses the secret.
  4. Choose Send test to check your saved settings. This sends synthetic data without creating a submission, consuming submission usage, or sending an email.
  5. Choose Enable integration when ready. A successful test is useful, but is not required to enable delivery. Save any edited settings before testing them.

Use exact HTML name attributes for selected fields, one per line. Repeated values are arrays. Attachments, control fields, IP addresses and internal security metadata are excluded. Dashboard links require sign-in. Keep signing secrets in server-side secret storage, never in your form HTML or browser JavaScript.

URLs containing credentials, IP literals, internal hostnames, fragments, or nonstandard ports are rejected. Redirects are not followed. Use the final public endpoint URL.

Version 1 JSON Schema

{
"schema_version": 1,
"id": "out_sub_example",
"type": "submission.accepted",
"timestamp": "2026-09-04T12:00:00.000Z",
"data": {
"form": { "id": "form_example", "name": "Website contact" },
"submission": {
"id": "sub_example",
"fields": { "email": "casey@example.com", "message": "Hello!" }
},
"url": "https://html.contact/app/submissions/sub_example"
}
}

Tests use integration.test, a synthetic submission ID, and a link to the form’s Integrations tab. The maximum request body is 256 KiB. An oversized payload fails without sending; use selected fields or link-only mode if needed. An event’s body is frozen before its first attempt and reused for retries. Treat visitor field values as untrusted input.

Requests follow Standard Webhooks. Headers are:

HeaderMeaning
webhook-idDelivery ID for this endpoint, stable across retries
webhook-timestampCurrent attempt’s Unix timestamp in seconds
webhook-signatureOne or more space-separated v1,<base64 signature> values

Remove the whsec_ prefix and base64-decode the remaining secret into the HMAC key. Sign the exact bytes of webhook-id + "." + webhook-timestamp + "." + raw request body using HMAC-SHA256. Compare signatures in constant time. Accept any matching v1 signature, and reject timestamps more than five minutes in the past or future. Keep your server clock synchronized.

Do not parse and reserialize the JSON before verification. Use a maintained Standard Webhooks verifier, or adapt the Node and Python reference implementations in the repository’s examples/webhooks/ directory. With the Node helper:

import { verifyWebhook } from "./verify.mjs";
export async function POST(request) {
const rawBody = new Uint8Array(await request.arrayBuffer());
let event;
try {
event = verifyWebhook(rawBody, request.headers, process.env.WEBHOOK_SECRET);
} catch {
return new Response("Invalid signature", { status: 400 });
}
// Atomically store/enqueue the event with a unique constraint on its verified delivery ID.
// A previously accepted ID should return 2xx without processing it again.
await enqueueOnce(request.headers.get("webhook-id"), event);
return new Response(null, { status: 204 });
}

enqueueOnce is your application’s durable queue/database operation. Set a request-size limit in your HTTP server before reading the body. Acknowledge promptly after durable acceptance; process slower work asynchronously. The JSON id identifies the event; webhook-id identifies its delivery to this endpoint. One event can have different deliveries for different endpoints. Deduplicate by the verified webhook-id to preserve that distinction. Delivery is at least once and ordering is not guaranteed.

Open Manage → Rotate. New requests carry signatures for both the new and previous keys for 24 hours. Update your receiver during that window. Another routine rotation is blocked until the overlap expires. Compromised secret? replaces it immediately and removes the previous key without an overlap.

Rotation does not resend events. Retries use the current signing keys and a fresh request timestamp, with the same delivery ID, event ID and raw body.

Recent deliveries show the last 50 jobs for the form, including synthetic tests. Details shows individual attempts and safe status codes. “Delivered” means the receiver returned a successful HTTP response; it does not prove your downstream job completed.

Timeouts, network failures, HTTP 408, HTTP 429, and 5xx responses are retryable. Other responses end the automatic cycle. Authentication, missing-endpoint and revoked-destination errors may require attention. There are up to eight automatic attempts within 24 hours. Scheduling uses backoff and respects Retry-After; recovery after a Queue interruption may wait for the ten-minute reconciliation cycle.

A failed delivery with a retained payload and unchanged, available destination can be retried manually up to three times. Each Retry once, available in its row or Details, requests one attempt. Owner-triggered tests and retries share a limit of five per minute and thirty per hour. Integration tests and retries do not consume form submission usage.

If the destination shows Needs attention, delivery has stopped. Open Manage, inspect the failed test or delivery, repair the receiver’s secret or endpoint, and choose Save paused. Send a new test, then enable the integration. Saving the repaired configuration starts a new revision; older deliveries cannot be retried against that changed configuration.

Payload snapshots are encrypted and retained for seven days. Delivery metadata is retained for thirty days. Pausing or changing a destination cancels pending deliveries; it does not replay old submissions. Pausing/deleting a form, deleting or marking a submission as spam, or disconnecting a Slack grant prevents later delivery. A request already accepted by a receiver cannot be recalled. Removing a destination removes its delivery history.