Payload & Signature Verification
Technical reference for receiving and verifying Formidable webhook deliveries.
Payload Format
Every webhook delivery sends a JSON body with this structure:
{
"event_type": "form.submission.created",
"form_id": "550e8400-e29b-41d4-a716-446655440000",
"form_title": "Loan Application",
"submission_id": "660e8400-e29b-41d4-a716-446655440000",
"submitted_at": "2026-03-09T15:30:45.123Z",
"data": {
"full_name": "Jane Doe",
"annual_income": 85000
}
}
| Field | Type | Description |
|---|---|---|
event_type | string | Always form.submission.created. |
form_id | string | UUID of the published form. |
form_title | string | Title set when the form was published. |
submission_id | string | UUID of this submission. |
submitted_at | string | ISO 8601 timestamp of when the form was submitted. |
data | object | Key-value pairs of the submitted field values. Keys match the field IDs in the form spec. |
Headers
Every delivery includes these headers:
| Header | Example | Description |
|---|---|---|
Content-Type | application/json | Always JSON. |
User-Agent | Formidable/1.0 | Identifies the sender. |
Webhook-ID | msg_ABC123def456 | Unique event ID. Use for deduplication. |
Webhook-Timestamp | 1741600245 | Unix timestamp in seconds when the delivery was created. |
Webhook-Signature | v1,K7gNU3sdo+OL... | HMAC-SHA256 signature. See verification below. |
If you configured custom headers on the endpoint (such as Authorization), those are included as well.
Standard Webhooks
Formidable follows the Standard Webhooks specification for signing and header conventions. Any Standard Webhooks-compatible library can verify Formidable deliveries.
Signature Verification
Verify the Webhook-Signature header to confirm that a delivery came from Formidable and was not tampered with.
Algorithm
- Read the
Webhook-ID,Webhook-Timestamp, and the raw request body (as a string, not parsed JSON). - Construct the signed content:
{Webhook-ID}.{Webhook-Timestamp}.{raw_body} - Base64-decode the signing secret you received when creating the endpoint.
- Compute HMAC-SHA256 over the signed content using the decoded secret.
- Base64-encode the result.
- Compare it to the value after the
v1,prefix in theWebhook-Signatureheader.
Node.js / TypeScript
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyWebhook(
secret: string, // base64-encoded signing secret
webhookId: string, // Webhook-ID header
timestamp: string, // Webhook-Timestamp header
body: string, // raw request body (string)
signature: string, // Webhook-Signature header
): boolean {
const signedContent = `${webhookId}.${timestamp}.${body}`;
const secretBytes = Buffer.from(secret, "base64");
const expected = createHmac("sha256", secretBytes)
.update(signedContent)
.digest("base64");
// Remove "v1," prefix
const received = signature.startsWith("v1,") ? signature.slice(3) : signature;
// Constant-time comparison to prevent timing attacks
const a = Buffer.from(expected);
const b = Buffer.from(received);
return a.length === b.length && timingSafeEqual(a, b);
}
Python
import hmac
import hashlib
import base64
def verify_webhook(
secret: str, # base64-encoded signing secret
webhook_id: str, # Webhook-ID header
timestamp: str, # Webhook-Timestamp header
body: str, # raw request body (string)
signature: str, # Webhook-Signature header
) -> bool:
signed_content = f"{webhook_id}.{timestamp}.{body}"
secret_bytes = base64.b64decode(secret)
expected = hmac.new(
secret_bytes,
signed_content.encode("utf-8"),
hashlib.sha256,
).digest()
expected_b64 = base64.b64encode(expected).decode("utf-8")
# Remove "v1," prefix
received = signature.removeprefix("v1,")
return hmac.compare_digest(expected_b64, received)
Timestamp Tolerance
Reject deliveries where Webhook-Timestamp is more than 5 minutes old to prevent replay attacks. This is not enforced server-side. Your verification logic should check:
const now = Math.floor(Date.now() / 1000);
const age = now - parseInt(timestamp, 10);
if (age > 300) {
throw new Error("Webhook timestamp too old");
}
Delivery Behavior
- Method: POST.
- Timeout: 5 seconds. Your endpoint must respond within this window.
- Protocol: HTTPS required. Localhost URLs (
http://localhost:*) are allowed for testing. - Response capture: the first 1024 characters of your endpoint's response body are stored for debugging. View them in the activity detail dialog.
- No automatic retry: failed deliveries are not retried automatically. Use the manual retry button in the workflow editor.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| "Invalid URL" error when creating endpoint | URL must use HTTPS. | Use https:// (or http://localhost for testing). |
| Delivery times out | Endpoint took longer than 5 seconds to respond. | Optimize your handler or return 200 immediately and process asynchronously. |
| Signature mismatch | Verification used re-serialized JSON instead of the raw body. | Always verify against the raw request body string, not JSON.stringify(JSON.parse(body)). |
| 401 or 403 from your endpoint | Custom auth headers are missing or incorrect. | Custom headers cannot be read back after creation. Re-enter the correct values by editing the endpoint. |