Skip to main content

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
}
}
FieldTypeDescription
event_typestringAlways form.submission.created.
form_idstringUUID of the published form.
form_titlestringTitle set when the form was published.
submission_idstringUUID of this submission.
submitted_atstringISO 8601 timestamp of when the form was submitted.
dataobjectKey-value pairs of the submitted field values. Keys match the field IDs in the form spec.

Headers

Every delivery includes these headers:

HeaderExampleDescription
Content-Typeapplication/jsonAlways JSON.
User-AgentFormidable/1.0Identifies the sender.
Webhook-IDmsg_ABC123def456Unique event ID. Use for deduplication.
Webhook-Timestamp1741600245Unix timestamp in seconds when the delivery was created.
Webhook-Signaturev1,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

  1. Read the Webhook-ID, Webhook-Timestamp, and the raw request body (as a string, not parsed JSON).
  2. Construct the signed content: {Webhook-ID}.{Webhook-Timestamp}.{raw_body}
  3. Base64-decode the signing secret you received when creating the endpoint.
  4. Compute HMAC-SHA256 over the signed content using the decoded secret.
  5. Base64-encode the result.
  6. Compare it to the value after the v1, prefix in the Webhook-Signature header.

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

SymptomCauseFix
"Invalid URL" error when creating endpointURL must use HTTPS.Use https:// (or http://localhost for testing).
Delivery times outEndpoint took longer than 5 seconds to respond.Optimize your handler or return 200 immediately and process asynchronously.
Signature mismatchVerification 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 endpointCustom auth headers are missing or incorrect.Custom headers cannot be read back after creation. Re-enter the correct values by editing the endpoint.