FormPilot Webhooks Reference
Verify and consume FormPilot submission webhooks.
Status: Backend foundation and customer management UX implemented in Pivot 04A-1 and Pivot 04A-2.
This is the canonical reference for FormPilot outbound webhooks. In-product webhook help should link here instead of duplicating the payload and signature contract.
For no-code automation setup, see the Zapier Catch Hook setup guide.
Supported Events
FormPilot supports one subscribable business event in Pivot 04A:
submission.created- sent when a public form receives a new quote request.
FormPilot also supports one admin-triggered diagnostic event:
webhook.test- sent only when a workspace admin clicks Send test.
Consumers should process only submission.created for production automations.
Ignore payloads where test is true.
Delivery Semantics
Webhook delivery is at least once. Retries can cause the same event to be
delivered more than once, so consumers must deduplicate by event_id.
A delivery is considered successful only when the endpoint returns an HTTP 2xx
response within 10 seconds. Network errors, timeouts, and non-2xx responses
are recorded as failed deliveries.
Failed submission.created deliveries are retried with bounded backoff. Failed
webhook.test deliveries are single-attempt diagnostics: they are not retried,
do not increment endpoint failure counts, and do not auto-disable the endpoint.
Headers
Each delivery includes these headers:
| Header | Description |
|---|---|
FormPilot-Webhook-Id | Unique delivery event id. Use this for dedupe. |
FormPilot-Webhook-Timestamp | Unix timestamp in seconds. |
FormPilot-Webhook-Signature | HMAC SHA-256 signature, formatted as v1=<hex>. |
Content-Type | application/json. |
The signature is computed over:
<timestamp>.<raw request body>Use the raw request body exactly as received. Parsing and re-stringifying JSON before verification can change whitespace or key order and break verification.
Verification Example
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyFormPilotWebhook({
rawBody,
timestamp,
signature,
secret,
now = Math.floor(Date.now() / 1000),
}: {
rawBody: string;
timestamp: string;
signature: string;
secret: string;
now?: number;
}) {
const sentAt = Number(timestamp);
if (!Number.isFinite(sentAt) || Math.abs(now - sentAt) > 300) {
return false;
}
const expected =
"v1=" +
createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const expectedBuffer = Buffer.from(expected);
const receivedBuffer = Buffer.from(signature);
return (
expectedBuffer.length === receivedBuffer.length &&
timingSafeEqual(expectedBuffer, receivedBuffer)
);
}submission.created
submission.created is sent after the public submission transaction succeeds.
Example shape:
{
"event_id": "evt_123",
"event_type": "submission.created",
"created_at": "2026-06-01T12:00:00.000Z",
"workspace_id": "org_123",
"test": false,
"form": {
"id": "form_123",
"title": "Pressure washing quote",
"slug": "pressure-washing-quote"
},
"submission": {
"id": "sub_123",
"submitted_at": "2026-06-01T12:00:00.000Z"
},
"answers": {
"field_abc": "Test Customer",
"field_def": "Driveway pressure washing"
},
"field_labels": {
"field_abc": "Customer name",
"field_def": "Service type"
},
"attachments": [
{
"upload_id": "upload_123",
"field_id": "field_photo",
"original_name": "driveway.jpg",
"content_type": "image/jpeg",
"size_bytes": 182400,
"kind": "image"
}
],
"summary": {
"status": "not_requested",
"text": null
}
}Payload constraints:
answersare keyed by the real form field ids for that workspace form.field_labelsmaps those ids to the field labels shown to the customer.- Attachments include metadata only, not private file URLs.
- Payloads do not include raw IP addresses, user agents, cookies, auth headers, or FormPilot private upload URLs.
webhook.test
webhook.test uses the same endpoint and HMAC signing path as live deliveries,
but it is a diagnostic event. It does not create a real submission, increment
usage, trigger AI summaries, record analytics, send email, or fan out to other
webhook endpoints.
Example shape:
{
"event_id": "evt_test_123",
"event_type": "webhook.test",
"created_at": "2026-06-01T12:00:00.000Z",
"workspace_id": "org_123",
"endpoint_id": "wh_123",
"test": true,
"form": {
"id": "form_test",
"title": "Sample quote form",
"slug": "sample-quote-form"
},
"submission": {
"id": "sub_test",
"submitted_at": "2026-06-01T12:00:00.000Z"
},
"answers": {
"customer_name": "Test Customer",
"service_type": "Driveway pressure washing",
"urgency": "This week",
"message": "This is a FormPilot test delivery."
},
"field_labels": {
"customer_name": "Customer name",
"service_type": "Service type",
"urgency": "Urgency",
"message": "Message"
},
"attachments": [],
"summary": {
"status": "not_requested",
"text": null
}
}The test payload uses readable fake field ids. Production automations should map
fields from live submission.created payloads for the actual form.
Versioning
FormPilot webhook payload changes should be additive. Existing fields should not be renamed, removed, or change type without a new versioned contract.