Webhooks
Webhooks deliver every non-spam submission to your own server as a signed HTTP POST, so you can sync data, trigger workflows, or fan out to other systems in real time.
Availability
Webhooks are a Pro and above feature. On the Free plan the Webhooks tab is read-only and no deliveries are sent. See Plans & top-ups for what each tier includes and how to upgrade.
How it works
Webhooks are configured per form. In the dashboard, open the form, go to the Webhooks tab, and add an HTTPS URL. When you create the webhook a signing secret (prefixed whsec_) is shown once — copy it and store it securely, because it is never displayed again. You use that secret to verify that incoming requests really came from Formcrest.
After that, every non-spam submission to the form triggers a JSON POST to your URL. Delivery happens off the response hot path: the visitor who submitted the form is never blocked waiting for your endpoint, and a slow or failing endpoint never slows down submission. Submissions that are filtered as spam do not produce a webhook — see Spam protection for how filtering works.
URL requirements
The endpoint URL must be HTTPS and resolve to a public host. URLs that point at private, loopback, or link-local addresses (for example localhost, 127.0.0.1, 10.0.0.0/8, 192.168.0.0/16, or 169.254.0.0/16) are rejected to prevent server-side request forgery (SSRF). For local development, use a public tunnel that terminates HTTPS.
Request headers
Every delivery includes these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Formcrest-Webhook/1.0 |
X-Formcrest-Webhook-Id | The ID of the webhook that produced this delivery. |
X-Formcrest-Delivery-Id | A unique ID for this delivery attempt. Use it to deduplicate retries. |
X-Formcrest-Timestamp | Unix time in seconds at the moment the request was signed and sent. |
X-Formcrest-Signature | sha256=<hex> — the HMAC-SHA256 signature described below. |
Request body
The body is a JSON object. The shape is stable:
{
"event": "submission.created",
"submission": {
"id": "sub_1a2b3c4d5e",
"form_id": "abc123",
"created_at": "2026-06-03T10:21:44Z",
"ip": "203.0.113.7",
"country": "US",
"user_agent": "Mozilla/5.0 (...)",
"referer": "https://example.com/contact",
"fields": {
"email": "jane@example.com",
"message": "Hello from the docs"
}
}
}The fields object contains exactly the form fields the visitor submitted (control fields such as the honeypot are stripped), so its keys depend on your form. The surrounding metadata fields are always present, though country, referer, and similar values may be null when they cannot be determined.
Verifying the signature
Always verify the signature before trusting a payload. Sign and compare over the raw request body bytes — do not re-serialize the parsed JSON first, because any reformatting changes the bytes and breaks the signature. The steps are:
- Read the
X-Formcrest-TimestampandX-Formcrest-Signatureheaders. - Reject the request if
|now - timestamp| > 5 minutes. This is replay protection. - Build the signed string
timestamp + "." + rawBodyand computeHMAC-SHA256over it with your signing secret, then hex-encode the result. - Strip the
sha256=prefix from the header value and compare it to your computed hex digest using a constant-time comparison. Reject the request if they differ.
Constant-time comparison matters: comparing with == can leak information through timing. Each language's standard library provides a safe primitive, shown below.
Node.js
Capture the raw body before any JSON parser consumes it. In Express, the raw buffer is available with express.raw().
const crypto = require("crypto");
function verify(rawBody, headers, secret) {
const timestamp = headers["x-formcrest-timestamp"];
const signature = headers["x-formcrest-signature"]; // "sha256=<hex>"
if (!timestamp || !signature) return false;
// Replay protection: reject if older/newer than 5 minutes.
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(timestamp)) > 300) return false;
const expected =
"sha256=" +
crypto
.createHmac("sha256", secret)
.update(timestamp + "." + rawBody)
.digest("hex");
const a = Buffer.from(signature);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Python
In Flask, read the raw body with request.get_data() before touching request.json.
import hashlib
import hmac
import time
def verify(raw_body: bytes, headers, secret: str) -> bool:
timestamp = headers.get("X-Formcrest-Timestamp")
signature = headers.get("X-Formcrest-Signature") # "sha256=<hex>"
if not timestamp or not signature:
return False
# Replay protection: reject if older/newer than 5 minutes.
if abs(int(time.time()) - int(timestamp)) > 300:
return False
signed = timestamp.encode() + b"." + raw_body
digest = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
expected = "sha256=" + digest
return hmac.compare_digest(expected, signature)PHP
Read the raw body with file_get_contents("php://input") and the headers from $_SERVER.
<?php
function verify(string $rawBody, array $headers, string $secret): bool {
$timestamp = $headers["X-Formcrest-Timestamp"] ?? null;
$signature = $headers["X-Formcrest-Signature"] ?? null; // "sha256=<hex>"
if (!$timestamp || !$signature) {
return false;
}
// Replay protection: reject if older/newer than 5 minutes.
if (abs(time() - (int) $timestamp) > 300) {
return false;
}
$digest = hash_hmac("sha256", $timestamp . "." . $rawBody, $secret);
$expected = "sha256=" . $digest;
return hash_equals($expected, $signature);
}Reliability and retries
Respond with any 2xx status to acknowledge a delivery. Any non-2xx status, or a network-level error such as a timeout or refused connection, is treated as a failure and retried with exponential backoff.
The retry schedule, measured from the original attempt, is:
| Attempt | When |
|---|---|
| 1 (initial) | immediately |
| 2 | +1 minute |
| 3 | +5 minutes |
| 4 | +30 minutes |
| 5 | +2 hours |
| 6 | +12 hours |
That is up to 6 attempts in total. If the delivery still has not succeeded after the final attempt, it is dead-lettered and no longer retried. Because retries can deliver the same submission more than once, make your handler idempotent by keying on X-Formcrest-Delivery-Id or the submission id.
Auto-disable
To protect your endpoint and ours, a webhook that accumulates 10 consecutive failed submissions is automatically disabled. While disabled it sends no deliveries. Fix the endpoint, then re-enable the webhook from the dashboard. A single successful delivery resets the failure counter.
Delivery history
The Webhooks tab shows recent delivery history for each webhook, including the response status and whether an attempt succeeded, failed, or was dead-lettered. Use it to debug a misbehaving endpoint before re-enabling a webhook that auto-disabled.
Related
- Submit API reference — how submissions are created in the first place.
- Error codes — the catalog returned by the submit endpoint.
- Plans & top-ups — which plans include webhooks.