Webhooks
Webhooks allow you to automatically send form submission data to external services, APIs, or your own backend whenever a new submission is received.
Use cases: CRM integrations, custom workflows, database syncing, internal tools, automation, and more.
- Go to your Form Settings
- Navigate to Actions → Webhook
- Enter your webhook endpoint URL
- Click Save
- Optional: copy your signing secret and configure it in your receiving application
- Click Test & Save
- Forminit sends a validation request to your webhook URL
- If your endpoint returns HTTP
200, the webhook is saved and activated
Your webhook endpoint must return HTTP 200 for the Test & Save step to succeed.
If the endpoint returns a non-200 response, times out, or cannot be reached, Forminit will not activate the webhook. Fix the endpoint and try Test & Save again.
Once activated, Forminit will automatically send a POST request to your endpoint URL whenever a new form submission is received.
Event Types
Section titled “Event Types”Forminit currently sends the following production webhook event:
| Event | Description |
|---|---|
form.submitted | Sent when a new form submission is received |
Webhook Payload
Section titled “Webhook Payload”When a form is submitted, Forminit sends a POST request to your webhook URL with a JSON payload containing the submission data.
Example Payload
Section titled “Example Payload”{
"event": "form.submitted",
"formName": "contact-form",
"id": "xK9mLpQr2vNtYw8z",
"submissionStatus": "Open",
"data": {
"message": "I'm interested in learning more about your enterprise solutions.",
"sender": {
"email": "james.wilson@example.co.uk",
"firstName": "James",
"lastName": "Wilson",
"phone": "+442071234567",
"company": "Acme Ltd",
"position": "Product Manager"
},
"plan": "Enterprise",
"budget": 50000,
"preferred-date": "2026-02-15",
"file-proposal": {
"file": "https://files.forminit.com/abc123/project-proposal.pdf",
"size": 245000,
"name": "project-proposal.pdf"
},
"file-attachments": [
{
"file": "https://files.forminit.com/abc123/requirements.pdf",
"size": 128400,
"name": "requirements.pdf"
},
{
"file": "https://files.forminit.com/abc123/timeline.xlsx",
"size": 45200,
"name": "timeline.xlsx"
}
],
"submissionDate": "05-01-2026 14:30",
"submissionDateISO": "2026-01-05T14:30:00+00:00",
"submission_info": {
"ip": "203.0.113.42",
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"referer": "https://example.co.uk/contact",
"sdk_version": "0.2.1",
"location": {
"country": {
"name": "United Kingdom",
"iso": "GB"
},
"city": {
"name": "London"
},
"geo": {
"lat": 51.5074,
"lng": -0.1278
},
"timezone": "Europe/London"
}
}
}
}
Payload Structure
Section titled “Payload Structure”Top-Level Fields
Section titled “Top-Level Fields”| Field | Type | Description |
|---|---|---|
event | string | Event type. Currently form.submitted for production webhooks |
formName | string | Name of the form that received the submission |
id | string | Unique submission identifier |
submissionStatus | string | Current status of the submission, such as Open, In Progress, or Done |
data | object | Contains all submitted form data and metadata |
Data Object
Section titled “Data Object”The data object contains all form field values using their field names as keys.
| Field | Type | Description |
|---|---|---|
sender | object | Submitter information such as email, name, phone, or company |
{field-name} | varies | Values from form fields such as text, number, select, radio, or checkbox |
file-{name} | object / array | File upload data for single or multiple files |
submissionDate | string | Human-readable submission date |
submissionDateISO | string | ISO 8601 formatted submission timestamp |
submission_info | object | Technical metadata about the submission |
Sender Object
Section titled “Sender Object”| Field | Type | Description |
|---|---|---|
email | string | Submitter’s email address |
firstName | string | Submitter’s first name |
lastName | string | Submitter’s last name |
fullName | string | Submitter’s full name, if collected as a single field |
phone | string | Phone number |
company | string | Company or organization |
position | string | Job title or position |
address | string | Street address |
city | string | City name |
country | string | Country value submitted with the form |
File Object
Section titled “File Object”Single file uploads return an object. Multiple file uploads return an array of objects.
| Field | Type | Description |
|---|---|---|
file | string | Public URL to download the file |
name | string | Original filename |
size | number | File size in bytes |
Uploaded files are not sent as binary content in the webhook request body.
File fields contain file metadata and file URLs. The webhook signature covers the JSON webhook body, including those file URLs, not the binary file content itself.
Submission Info Object
Section titled “Submission Info Object”| Field | Type | Description |
|---|---|---|
ip | string | Submitter’s IP address |
user_agent | string | Browser user agent string |
referer | string | Page URL where the form was submitted |
sdk_version | string | Forminit SDK version, if using the SDK |
location | object | Geolocation data derived from IP |
Location Object
Section titled “Location Object”| Field | Type | Description |
|---|---|---|
country.name | string | Country name |
country.iso | string | ISO 3166-1 alpha-2 country code |
city.name | string | City name |
geo.lat | number | Latitude coordinate |
geo.lng | number | Longitude coordinate |
timezone | string | IANA timezone identifier |
Webhook Validation
Section titled “Webhook Validation”Before a webhook is activated, Forminit validates the webhook URL from the backend.
The flow is:
- You enter the webhook URL
- You click Save
- Forminit stores the webhook URL
- You click Test & Save
- Forminit sends a validation request to the saved webhook URL
- Your endpoint must return HTTP
200 - If the response is
200, Forminit activates the webhook
The validation request is sent by Forminit’s backend, not from your browser.
This is important because:
- The frontend should not generate webhook signatures
- Browser CORS rules may block direct webhook tests
- Validation and production deliveries should use the same backend delivery path
- Timeout, retry, and logging behaviour should stay consistent
If signed webhooks are enabled, the validation request includes the same Forminit signature headers used for normal webhook deliveries:
Forminit-Webhook-Id: wh_01KVTW54BTA7A86N94MGHSBCHY
Forminit-Webhook-Timestamp: 1782239629
Forminit-Webhook-Signature: v1=a6f4cf2fae5065c5216bb209a60ca6c3066eb354cf12f74d8c400d7c108e7b81
Your endpoint should verify the signature and return HTTP 200 when the request is accepted.
If your endpoint returns anything other than HTTP 200, the webhook will not be activated.
Signed Webhooks
Section titled “Signed Webhooks”Signed webhooks help your server verify that an incoming webhook request was sent by Forminit and that the request body was not changed after it was signed.
This feature is optional. Existing webhook receivers can continue to accept Forminit webhook requests without verifying the signature.
If you need stronger security for your webhook endpoint, store the signing secret in your application and verify each request before processing the payload.
Signed webhooks apply to generic Forminit webhooks. Slack and Discord webhook notifications are not affected.
How Signed Webhooks Work
Section titled “How Signed Webhooks Work”When a signing secret is available for your generic webhook, Forminit adds these headers to every webhook request:
Forminit-Webhook-Id: wh_01KVTW54BTA7A86N94MGHSBCHY
Forminit-Webhook-Timestamp: 1782239629
Forminit-Webhook-Signature: v1=a6f4cf2fae5065c5216bb209a60ca6c3066eb354cf12f74d8c400d7c108e7b81
Header Reference
Section titled “Header Reference”| Header | Description |
|---|---|
Forminit-Webhook-Id | Unique ID for the webhook delivery event. Use this to prevent duplicate processing. |
Forminit-Webhook-Timestamp | Unix timestamp in seconds when the webhook request was sent. |
Forminit-Webhook-Signature | HMAC-SHA256 signature of the webhook request. The current version is v1. |
The webhook ID is different from the submission ID in the payload.
idin the payload identifies the form submission.Forminit-Webhook-Ididentifies the webhook delivery event.
This distinction is useful when Forminit retries the same webhook event.
Signing Secret
Section titled “Signing Secret”The signing secret is a private key shared between Forminit and your webhook receiver.
Example:
whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Use the complete whsec_... value as your HMAC key.
Do not remove the whsec_ prefix. Do not Base64 decode the secret. Treat it as a normal UTF-8 string.
Store the secret in an environment variable or secret manager:
FORMINIT_WEBHOOK_SECRET=whsec_your_secret
Do not hard-code the secret in your source code, expose it in frontend JavaScript, or send it back in a webhook response.
Signature Format
Section titled “Signature Format”Forminit signs the following content:
v1.{webhook_id}.{timestamp}.{raw_request_body}
More precisely, the signed content is built from:
- The UTF-8 bytes of this prefix:
v1.{webhook_id}.{timestamp}.
- Followed by the exact raw request body bytes sent by Forminit.
Forminit webhook bodies are JSON and are sent as UTF-8.
This means these two implementation styles are equivalent for Forminit webhook requests:
hmac.update(`v1.${webhookId}.${timestamp}.`, 'utf8').update(rawBody);
hash_hmac('sha256', "v1.{$webhookId}.{$timestamp}.{$rawBody}", $secret);
The safest approach is to verify the exact raw request body bytes before parsing JSON.
Forminit then creates an HMAC-SHA256 digest using your webhook signing secret.
The final signature header uses this format:
v1={hmac_sha256_hex_digest}
Example:
Forminit-Webhook-Signature: v1=a6f4cf2fae5065c5216bb209a60ca6c3066eb354cf12f74d8c400d7c108e7b81
The signature value is lowercase hexadecimal.
Forminit sends a single v1 signature. Multiple signatures are not sent during secret regeneration because the previous secret becomes invalid immediately.
Verifying a Webhook Request
Section titled “Verifying a Webhook Request”Your webhook endpoint should verify the signature before parsing JSON or creating side effects.
Recommended verification flow:
- Read the raw request body before JSON parsing.
- Read the
Forminit-Webhook-Id,Forminit-Webhook-Timestamp, andForminit-Webhook-Signatureheaders. - Reject the request if any required header is missing.
- Reject timestamps older or newer than five minutes.
- Build the signed content as
v1.{webhook_id}.{timestamp}.followed by the raw request body bytes. - Calculate the HMAC-SHA256 signature with your signing secret.
- Compare the expected signature with the received signature using a constant-time comparison.
- Check whether the webhook ID was already processed.
- Parse the JSON payload and process the event.
The raw body matters. If your framework parses and re-serializes the JSON body before verification, the signature may not match.
Implementation Guarantees and Edge Cases
Section titled “Implementation Guarantees and Edge Cases”These details are useful when building your webhook receiver.
Raw Body Encoding
Section titled “Raw Body Encoding”Webhook request bodies are JSON encoded as UTF-8.
Always verify the raw request body before parsing or re-serializing JSON. Even small formatting changes, such as whitespace, escaping, or key order, can change the signature.
Secret Format
Section titled “Secret Format”Use the complete whsec_... value as the HMAC-SHA256 key.
Do not remove the whsec_ prefix. Do not Base64 decode the secret. Treat it as a normal UTF-8 string.
Signature Version
Section titled “Signature Version”The current signature version is v1.
Your receiver should check that the signature header matches this format:
v1={64 lowercase hex characters}
Timestamp Tolerance
Section titled “Timestamp Tolerance”The recommended timestamp tolerance is five minutes.
Rejecting old timestamps helps reduce replay risk, but timestamp validation does not replace idempotency. You should still store processed Forminit-Webhook-Id values if your webhook creates side effects.
File Uploads
Section titled “File Uploads”Uploaded files are not sent as binary content in the webhook request body.
File fields contain file metadata and file URLs. The signature covers the JSON webhook body, including those file URLs, not the binary file content itself.
Source IP Allowlisting
Section titled “Source IP Allowlisting”Signature verification is the recommended way to verify webhook authenticity.
Forminit does not currently document fixed webhook source IP ranges for allowlisting. If you use firewall rules, keep signature verification enabled as the primary trust mechanism.
Example: Node.js
Section titled “Example: Node.js”This example uses only built-in Node.js modules.
import { createHmac, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';
const secret = process.env.FORMINIT_WEBHOOK_SECRET;
if (!secret) {
throw new Error('FORMINIT_WEBHOOK_SECRET is required.');
}
function verifyForminitWebhook(rawBody, headers) {
const webhookId = headers['forminit-webhook-id'] ?? '';
const timestamp = headers['forminit-webhook-timestamp'] ?? '';
const signature = headers['forminit-webhook-signature'] ?? '';
const match = signature.match(/^v1=([a-f0-9]{64})$/);
if (!/^wh_[A-Za-z0-9_-]+$/.test(webhookId)) {
return null;
}
if (!/^\d+$/.test(timestamp)) {
return null;
}
if (!match) {
return null;
}
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (age > 300) {
return null;
}
const expected = createHmac('sha256', secret)
.update(`v1.${webhookId}.${timestamp}.`, 'utf8')
.update(rawBody)
.digest();
const received = Buffer.from(match[1], 'hex');
if (expected.length !== received.length) {
return null;
}
return timingSafeEqual(expected, received) ? webhookId : null;
}
createServer(async (request, response) => {
if (request.method !== 'POST' || request.url !== '/webhooks/forminit') {
response.writeHead(404).end();
return;
}
const chunks = [];
for await (const chunk of request) {
chunks.push(chunk);
}
const rawBody = Buffer.concat(chunks);
const webhookId = verifyForminitWebhook(rawBody, request.headers);
if (!webhookId) {
response.writeHead(401, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ error: 'Invalid webhook.' }));
return;
}
const payload = JSON.parse(rawBody.toString('utf8'));
if (payload.event === 'form.submitted') {
// Process the verified submission here.
}
response.writeHead(200, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ received: true, webhook_id: webhookId }));
}).listen(3000);
Example: Express
Section titled “Example: Express”Use express.raw() on the webhook route. express.json() must not parse this request before signature verification.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const secret = process.env.FORMINIT_WEBHOOK_SECRET;
if (!secret) {
throw new Error('FORMINIT_WEBHOOK_SECRET is required.');
}
function verifyForminitWebhook(rawBody, request) {
const webhookId = request.get('Forminit-Webhook-Id') ?? '';
const timestamp = request.get('Forminit-Webhook-Timestamp') ?? '';
const signatureHeader = request.get('Forminit-Webhook-Signature') ?? '';
const match = signatureHeader.match(/^v1=([a-f0-9]{64})$/);
if (!/^wh_[A-Za-z0-9_-]+$/.test(webhookId)) {
return null;
}
if (!/^\d+$/.test(timestamp)) {
return null;
}
if (!match) {
return null;
}
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (age > 300) {
return null;
}
const expected = crypto
.createHmac('sha256', secret)
.update(`v1.${webhookId}.${timestamp}.`, 'utf8')
.update(rawBody)
.digest();
const received = Buffer.from(match[1], 'hex');
if (expected.length !== received.length) {
return null;
}
return crypto.timingSafeEqual(expected, received) ? webhookId : null;
}
app.post(
'/webhooks/forminit',
express.raw({ type: 'application/json' }),
(request, response) => {
const rawBody = request.body;
const webhookId = verifyForminitWebhook(rawBody, request);
if (!webhookId) {
return response.status(401).json({ error: 'Invalid webhook.' });
}
const payload = JSON.parse(rawBody.toString('utf8'));
if (payload.event === 'form.submitted') {
// Process the verified submission here.
}
return response.json({
received: true,
webhook_id: webhookId,
});
},
);
// Add express.json() after the webhook route if needed for other routes.
app.use(express.json());
app.listen(3000);
If your app expects larger JSON webhook bodies, you can set your own body limit:
express.raw({ type: 'application/json', limit: '1mb' });
The limit above is an application-level Express setting, not a Forminit webhook payload limit.
Example: Next.js Route Handler
Section titled “Example: Next.js Route Handler”// app/api/webhooks/forminit/route.ts
import crypto from 'node:crypto';
import { NextResponse } from 'next/server';
const secret = process.env.FORMINIT_WEBHOOK_SECRET;
function verifyForminitWebhook(
rawBody: Buffer,
headers: Headers,
): string | null {
if (!secret) {
throw new Error('FORMINIT_WEBHOOK_SECRET is required.');
}
const webhookId = headers.get('Forminit-Webhook-Id') ?? '';
const timestamp = headers.get('Forminit-Webhook-Timestamp') ?? '';
const signatureHeader = headers.get('Forminit-Webhook-Signature') ?? '';
const match = signatureHeader.match(/^v1=([a-f0-9]{64})$/);
if (!/^wh_[A-Za-z0-9_-]+$/.test(webhookId)) {
return null;
}
if (!/^\d+$/.test(timestamp)) {
return null;
}
if (!match) {
return null;
}
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (age > 300) {
return null;
}
const expected = crypto
.createHmac('sha256', secret)
.update(`v1.${webhookId}.${timestamp}.`, 'utf8')
.update(rawBody)
.digest();
const received = Buffer.from(match[1], 'hex');
if (expected.length !== received.length) {
return null;
}
return crypto.timingSafeEqual(expected, received) ? webhookId : null;
}
export async function POST(request: Request) {
const rawBody = Buffer.from(await request.arrayBuffer());
const webhookId = verifyForminitWebhook(rawBody, request.headers);
if (!webhookId) {
return NextResponse.json(
{ error: 'Invalid webhook.' },
{ status: 401 },
);
}
const payload = JSON.parse(rawBody.toString('utf8'));
if (payload.event === 'form.submitted') {
// Process the verified submission here.
}
return NextResponse.json({
received: true,
webhook_id: webhookId,
});
}
Example: Laravel
Section titled “Example: Laravel”Add the secret to your .env file:
FORMINIT_WEBHOOK_SECRET=whsec_your_secret
Add it to config/services.php:
'forminit' => [
'webhook_secret' => env('FORMINIT_WEBHOOK_SECRET'),
],
Then add a receiver to routes/api.php:
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::post('/webhooks/forminit', function (Request $request) {
$rawBody = $request->getContent();
$webhookId = (string) $request->header('Forminit-Webhook-Id', '');
$timestamp = (string) $request->header('Forminit-Webhook-Timestamp', '');
$signature = (string) $request->header('Forminit-Webhook-Signature', '');
$secret = (string) config('services.forminit.webhook_secret');
if ($secret === '') {
return response()->json(['error' => 'Webhook secret is not configured.'], 500);
}
if (
!preg_match('/^wh_[A-Za-z0-9_-]+$/', $webhookId)
|| !ctype_digit($timestamp)
|| !preg_match('/^v1=([a-f0-9]{64})$/', $signature, $matches)
) {
return response()->json(['error' => 'Malformed webhook headers.'], 400);
}
if (abs(time() - (int) $timestamp) > 300) {
return response()->json(['error' => 'Webhook timestamp expired.'], 401);
}
$expected = hash_hmac(
'sha256',
"v1.{$webhookId}.{$timestamp}.{$rawBody}",
$secret
);
if (!hash_equals($expected, $matches[1])) {
return response()->json(['error' => 'Invalid webhook signature.'], 401);
}
try {
$payload = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return response()->json(['error' => 'Invalid JSON body.'], 400);
}
if (($payload['event'] ?? null) === 'form.submitted') {
// Process the verified submission here.
}
return response()->json([
'received' => true,
'webhook_id' => $webhookId,
]);
});
Example: Python Flask
Section titled “Example: Python Flask”import hashlib
import hmac
import json
import os
import re
import time
from flask import Flask, jsonify, request
app = Flask(__name__)
secret = os.environ["FORMINIT_WEBHOOK_SECRET"]
def verify_forminit_webhook(raw_body):
webhook_id = request.headers.get("Forminit-Webhook-Id", "")
timestamp = request.headers.get("Forminit-Webhook-Timestamp", "")
signature = request.headers.get("Forminit-Webhook-Signature", "")
signature_match = re.fullmatch(r"v1=([a-f0-9]{64})", signature)
if not re.fullmatch(r"wh_[A-Za-z0-9_-]+", webhook_id):
return None
if not timestamp.isdigit() or signature_match is None:
return None
if abs(int(time.time()) - int(timestamp)) > 300:
return None
signed_content = f"v1.{webhook_id}.{timestamp}.".encode() + raw_body
expected = hmac.new(
secret.encode(),
signed_content,
hashlib.sha256,
).hexdigest()
return webhook_id if hmac.compare_digest(expected, signature_match.group(1)) else None
@app.post("/webhooks/forminit")
def receive_forminit_webhook():
raw_body = request.get_data(cache=False, as_text=False)
webhook_id = verify_forminit_webhook(raw_body)
if not webhook_id:
return jsonify(error="Invalid webhook."), 401
try:
payload = json.loads(raw_body)
except (UnicodeDecodeError, json.JSONDecodeError):
return jsonify(error="Invalid JSON body."), 400
if payload.get("event") == "form.submitted":
# Process the verified submission here.
pass
return jsonify(received=True, webhook_id=webhook_id)
Example: Cloudflare Workers
Section titled “Example: Cloudflare Workers”This example works in server-side Web Fetch API environments such as Cloudflare Workers. Store the secret in the FORMINIT_WEBHOOK_SECRET binding.
const encoder = new TextEncoder();
function hexToBytes(hex) {
return Uint8Array.from(hex.match(/.{2}/g), (byte) => Number.parseInt(byte, 16));
}
function joinBytes(left, right) {
const output = new Uint8Array(left.length + right.length);
output.set(left);
output.set(right, left.length);
return output;
}
async function verifyForminitWebhook(rawBody, headers, secret) {
if (!secret) {
throw new Error('FORMINIT_WEBHOOK_SECRET is required.');
}
const webhookId = headers.get('Forminit-Webhook-Id') ?? '';
const timestamp = headers.get('Forminit-Webhook-Timestamp') ?? '';
const signature = headers.get('Forminit-Webhook-Signature') ?? '';
const match = signature.match(/^v1=([a-f0-9]{64})$/);
if (!/^wh_[A-Za-z0-9_-]+$/.test(webhookId)) {
return null;
}
if (!/^\d+$/.test(timestamp)) {
return null;
}
if (!match) {
return null;
}
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (age > 300) {
return null;
}
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify'],
);
const prefix = encoder.encode(`v1.${webhookId}.${timestamp}.`);
const valid = await crypto.subtle.verify(
'HMAC',
key,
hexToBytes(match[1]),
joinBytes(prefix, rawBody),
);
return valid ? webhookId : null;
}
export default {
async fetch(request, env) {
const rawBody = new Uint8Array(await request.arrayBuffer());
const webhookId = await verifyForminitWebhook(
rawBody,
request.headers,
env.FORMINIT_WEBHOOK_SECRET,
);
if (!webhookId) {
return Response.json({ error: 'Invalid webhook.' }, { status: 401 });
}
const payload = JSON.parse(new TextDecoder().decode(rawBody));
if (payload.event === 'form.submitted') {
// Process the verified submission here.
}
return Response.json({
received: true,
webhook_id: webhookId,
});
},
};
Regenerating the Signing Secret
Section titled “Regenerating the Signing Secret”You can regenerate your webhook signing secret from the webhook settings.
Regenerating the secret immediately invalidates the previous secret. Webhook deliveries may fail until the new secret is configured in your receiving application.
After regenerating the secret:
- Copy the new signing secret from Forminit
- Update
FORMINIT_WEBHOOK_SECRETin your receiving application - Redeploy or restart your application if required
- Click Test & Save
- Confirm that your endpoint returns HTTP
200
There is no grace period. The previous secret stops working immediately.
Forminit sends a single v1 signature with each webhook request. It does not send multiple signatures for old and new secrets during rotation.
Retry Policy
Section titled “Retry Policy”If your endpoint returns a non-2xx status code or times out, Forminit will retry the webhook delivery with exponential backoff.
For retries of the same webhook event:
Forminit-Webhook-Idremains the sameForminit-Webhook-Timestampis updatedForminit-Webhook-Signatureis recalculated- The request body remains the same
Use Forminit-Webhook-Id to make your webhook receiver idempotent.
For example, if your endpoint already processed a webhook ID, return a 2xx response without processing it again. This prevents duplicate side effects such as creating the same record twice, sending the same email twice, or running the same workflow twice.
Return a 2xx response as soon as possible. If your webhook needs to perform slow work, such as calling another API, sending emails, or processing files, save the verified event first and move the slow work to a queue.
Webhook timeout and retry-attempt limits may depend on Forminit’s delivery configuration. If you need strict delivery timing guarantees for your own infrastructure, design your endpoint to acknowledge verified webhook requests quickly.
Security Checklist
Section titled “Security Checklist”- Store the signing secret in an environment variable or secret manager
- Never expose the signing secret in frontend code
- Never send the signing secret back in a webhook response
- Verify the raw request body before parsing JSON
- Reject timestamps outside the five-minute tolerance
- Use a constant-time comparison for signatures
- Store processed
Forminit-Webhook-Idvalues to avoid duplicate processing - Return a
2xxresponse quickly and move slow work to a queue - Do not log the signing secret, signature header, or full request body
- Do not create side effects before signature verification
Was this page helpful?
Thanks for your feedback.