Webhook destinations
Send every form submission to a URL you control, signed and retried, with a delivery log you can inspect.
Email tells you a lead arrived. A webhook destination puts that lead into whatever system you actually work in — your CRM, your database, a Slack channel, or an automation tool like Zapier.
Each time someone submits a form, PageFork sends a signed POST to a URL
you choose, retries it if the receiver is down, and records the outcome
so you can see exactly what happened.
This is the most technical feature in PageFork. If you just want leads in your inbox or a spreadsheet, email notifications and CSV export are simpler. If you want them in another tool without writing code, start with Zapier, Make, and n8n.
Adding a destination
In the site editor, open the Forms tab and find Destinations. Click Add destination and fill in:
- Name — anything that helps you recognise it later (“Zapier leads”, “CRM sync”).
- Webhook URL — where submissions get sent. Must be
https://and publicly reachable. - Form — a specific form, or All forms on the site.
When you save, PageFork shows a signing secret exactly once. Copy it now and store it somewhere safe — it is encrypted afterwards and cannot be shown again. If you lose it, use Rotate secret to issue a new one.
”No forms detected on this site yet”
The create dialog warns when PageFork doesn’t yet know of any forms on the site. It’s a warning, not a block — you can still create the destination. It usually means the site hasn’t been published yet, or the form couldn’t be tied to a form id with confidence.
”unknown form”
A destination scoped to a form the site doesn’t have will never fire, so the list flags it. Re-create it against an existing form, or against all forms.
Testing it
Send test delivers a sample payload using your form’s real field names with placeholder values.
That detail matters more than it sounds. Tools that consume webhooks build their field mapping from the first request they receive, so a test carrying invented names would teach the receiver a mapping that silently breaks the moment a real lead arrives. PageFork refuses to send a test rather than guess: if it doesn’t know your field names yet, the button is blocked and tells you to publish the site first.
For the most complete check, submit your own live form once. That exercises the whole path — the real endpoint, spam checks, the queue and the delivery — and gives the receiving tool a genuine payload to map.
The delivery log
Recent deliveries shows each attempt with its status:
| Status | Meaning |
|---|---|
pending | Queued or in flight |
success | Receiver returned a 2xx |
failed | Attempt failed; more retries may follow |
dead | Given up — no further automatic attempts |
Deliveries that have finished unsuccessfully can be re-fired with Retry. Test deliveries can’t be retried — send a fresh one instead.
Delivery records are kept for 30 days. They’re a debugging window, not an archive — Submissions inbox is where your leads actually live, and nothing there is affected by a webhook failing.
Retries and auto-disable
- Up to 5 attempts per delivery, with exponential backoff starting at 5 seconds.
5xxand429are retried. Any other non-2xx is treated as permanent and stops immediately — a404means the URL is wrong, and retrying won’t fix it.- Each attempt times out after 15 seconds. Return a
2xxquickly and do your slow work afterwards. - After 50 consecutive failures the destination is disabled automatically so a dead endpoint doesn’t retry forever. Fix the receiver, then re-enable it.
What PageFork sends
A JSON body, version 1:
{
"specVersion": 1,
"event": "form.submission.created",
"idempotencyKey": "sub_abc123:dest_xyz789",
"deliveryId": "del_123",
"createdAt": "2026-07-31T09:15:00.000Z",
"data": {
"submission": {
"id": "sub_abc123",
"siteId": "site_123",
"formId": "contact",
"createdAt": "2026-07-31T09:14:59.000Z",
"preview": "Ada — ada@example.com",
"fields": { "name": "Ada", "email": "ada@example.com" }
},
"site": { "id": "site_123", "name": "Acme", "subdomain": "acme" },
"destination": { "id": "dest_xyz789" }
}
}
fields holds your form’s own field names — whatever you named the
inputs. Test deliveries use "event": "form.destination.test" and the
same field names with placeholder values.
Headers on every request:
| Header | Purpose |
|---|---|
X-PageFork-Signature | HMAC of the body, v1=<hex> |
X-PageFork-Timestamp | Unix seconds used in the signature |
X-PageFork-Delivery-Id | This delivery attempt |
X-PageFork-Idempotency-Key | Stable per submission + destination |
Use the idempotency key to make your handler safe to call twice. A retry after a timeout can deliver the same submission again, and the key stays the same across those attempts.
Verifying the signature
The signature is an HMAC-SHA256 over timestamp + "." + rawBody, using
your signing secret. Hash the raw request body, not a re-serialised
version of the parsed JSON — re-encoding changes the bytes and the
signature won’t match.
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody, headers, secret) {
const timestamp = headers['x-pagefork-timestamp'];
const expected =
'v1=' +
createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`, 'utf8')
.digest('hex');
const received = headers['x-pagefork-signature'];
const a = Buffer.from(expected);
const b = Buffer.from(received);
return a.length === b.length && timingSafeEqual(a, b);
}
Reject requests whose timestamp is far from your own clock — a few minutes of tolerance is typical — so an old captured request can’t be replayed.
What PageFork will not send to
For safety, webhook URLs must be:
- HTTPS. Plain
http://is rejected. - Publicly reachable. Hostnames resolving to private, loopback, or internal addresses are refused, both when you save and again on every delivery. A self-hosted receiver needs a public hostname or a tunnel.
- Free of credentials. URLs with a username or password are rejected; use the signature instead.
Redirects are not followed. A signed POST is delivered to the URL
you configured and nowhere else, so a 301 at your endpoint reads as a
failure rather than being silently re-sent elsewhere. Configure the
final URL directly.
Response bodies are stored with the delivery record for debugging, truncated to 2 KB and scrubbed of your secret and URL.
Limits
- 10 destinations per site.
- 5 enabled webhook destinations per site.
- Payload capped at 256 KB.
Rotating the secret
Rotate secret issues a new signing secret and shows it once, in place. The old secret stops working immediately, so deploy the new one to your receiver promptly — deliveries signed with the new secret will fail verification until you do.