Webhook delivery
Forward accepted submissions to your backend, verify every request with HMAC, and design the receiver for retries and duplicate delivery.
When to use it
Use a webhook when a successful FormDex submission must create a CRM lead, start an internal workflow, update your own database, notify a helpdesk, or trigger another backend service. Email notifications do not require a webhook.
The signing secret belongs only on the receiving server. Never put it in browser JavaScript, React client components, HTML, a public repository, or a mobile application.
Platforms
Configure FormDex
POST.FORMDEX_WEBHOOK_SECRET.Authentication
FormDex signs timestamp + "." + exactRawRequestBody with HMAC-SHA256. Verify before parsing or changing the JSON. Reject timestamps older than five minutes and compare signatures in constant time.
X-FormDex-Event: submission.created
X-FormDex-Delivery-Id: delivery-id
X-FormDex-Timestamp: 1791624000
X-FormDex-Signature: sha256=<hex-hmac>Event contract
The payload object contains the accepted submitted fields, including enabled dynamic fields. When files were accepted, attachments contains safe metadata only; bytes and public download links are never sent. Treat new keys as forward-compatible additions and use event plus version when routing events.
{
"event": "submission.created",
"version": 1,
"formId": "your-form-id",
"submissionId": "submission-id",
"createdAt": "2026-10-10T09:19:00.706Z",
"payload": {
"fullName": "Aarav Sharma",
"email": "[email protected]",
"message": "Please contact me",
"customField": "Any enabled dynamic field is included here"
},
"attachments": [
{ "fileName": "brief.pdf", "mimeType": "application/pdf", "sizeBytes": 245760 }
]
}Node.js / Express
import crypto from "node:crypto";
import express from "express";
const app = express();
app.post("/webhooks/formdex", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = req.body.toString("utf8");
const timestamp = req.get("x-formdex-timestamp") || "";
const received = req.get("x-formdex-signature") || "";
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!timestamp || !Number.isFinite(age) || age > 300) {
return res.status(401).send("Stale webhook");
}
const digest = crypto
.createHmac("sha256", process.env.FORMDEX_WEBHOOK_SECRET)
.update(timestamp + "." + rawBody)
.digest("hex");
const expected = "sha256=" + digest;
const valid = received.length === expected.length && crypto.timingSafeEqual(
Buffer.from(received), Buffer.from(expected)
);
if (!valid) return res.status(401).send("Invalid signature");
const event = JSON.parse(rawBody);
// Deduplicate with x-formdex-delivery-id, persist work, then return quickly.
res.status(204).end();
});Next.js / React frameworks
A React browser component is not a webhook receiver and cannot protect the signing secret. In Next.js, use a route handler; in other React stacks use the framework’s server or serverless-function layer.
import crypto from "node:crypto";
export async function POST(request) {
const rawBody = await request.text();
const timestamp = request.headers.get("x-formdex-timestamp") || "";
const received = request.headers.get("x-formdex-signature") || "";
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!timestamp || !Number.isFinite(age) || age > 300) {
return new Response("Stale webhook", { status: 401 });
}
const expected = "sha256=" + crypto
.createHmac("sha256", process.env.FORMDEX_WEBHOOK_SECRET)
.update(timestamp + "." + rawBody)
.digest("hex");
const valid = received.length === expected.length && crypto.timingSafeEqual(
Buffer.from(received), Buffer.from(expected)
);
if (!valid) return new Response("Invalid signature", { status: 401 });
const event = JSON.parse(rawBody);
return new Response(null, { status: 204 });
}PHP / WordPress
Put this in a small custom plugin, not a theme file. Store the secret in server configuration rather than WordPress content or frontend settings.
add_action('rest_api_init', function () {
register_rest_route('my-site/v1', '/formdex', [
'methods' => 'POST',
'permission_callback' => '__return_true',
'callback' => function (WP_REST_Request $request) {
$raw_body = $request->get_body();
$timestamp = $request->get_header('x-formdex-timestamp');
$received = $request->get_header('x-formdex-signature');
if (!$timestamp || abs(time() - intval($timestamp)) > 300) {
return new WP_REST_Response(['error' => 'Stale webhook'], 401);
}
$digest = hash_hmac(
'sha256',
$timestamp . '.' . $raw_body,
getenv('FORMDEX_WEBHOOK_SECRET')
);
$expected = 'sha256=' . $digest;
if (!hash_equals($expected, $received ?: '')) {
return new WP_REST_Response(['error' => 'Invalid signature'], 401);
}
$event = json_decode($raw_body, true, 512, JSON_THROW_ON_ERROR);
// Deduplicate by X-FormDex-Delivery-Id and enqueue durable local work.
return new WP_REST_Response(null, 204);
},
]);
});Reliability
2xx response only after the event is durably accepted.X-FormDex-Delivery-Id; the same event can be retried.4xx configuration errors as permanent; temporary failures and timeouts may be retried.