Destinations

Webhook destinations

Send leads to a URL you control as signed JSON, retried automatically, with a delivery log you can inspect.

A webhook destination puts a lead into whatever system you build or run yourself: your own server, your database, an internal tool, or an automation tool such as n8n.

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 destination. If you want leads in a spreadsheet, an inbox or your mailing list, use Google Sheets, email, Kit or Mailchimp. For other apps without code, use the Zapier or Make app. All of them are listed in the destinations overview.

Webhook destinations are part of the Pro plan. See Basic and Pro plans.

Adding a destination

In the site editor, open Settings and go to the Forms tab. Under Destinations, click Add, choose Webhook, and fill in:

  • Name — anything that helps you recognise it later (“CRM sync”, “n8n leads”).
  • Form scope — a specific form, or All forms on the site.
  • Triggers — when it fires. See below.
  • HTTPS URL — where leads get sent. Must be https:// and publicly reachable.

Only team owners and admins can add or change destinations.

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.

Choosing when it fires

Open a destination and you’ll find its Triggers, as chips:

  • New submission — fire when a new submission arrives. On by default.
  • → Stage — one chip per stage in the site’s pipeline, such as → Contacted or → Won. Fire when a lead enters that stage.

They combine freely. A destination with New submission off and → Won on is a clean “tell my accounting system about closed deals” integration that ignores raw leads entirely.

A destination with every trigger off never fires, and the panel says so rather than leaving you to work it out from an empty delivery log.

Stage triggers fire on the move itself — the stage bar on the lead or a drag on the board. Rapid changes each deliver; there’s no debounce, so a lead dragged through three columns sends three requests.

Three things never reach a webhook:

  • Leads that didn’t come from a form. Leads added by hand, imported or created through Zapier or Make don’t fire destinations, on creation or on later stage changes.
  • Bulk changes. Moving many leads at once from the list is silent.
  • Anything other than a stage change. Priority, owner, follow-ups, tags, notes and corrections stay inside PageFork.

If you add a stage to your pipeline later, open the destination and switch its chip on if you want it. If you delete a stage, its chip is removed from every destination.

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:

StatusMeaning
pendingQueued or in flight
successReceiver returned a 2xx
skippedNothing needed sending. Rare for webhooks; common for mailing-list destinations when a submission has no email address
failedAttempt failed; more retries may follow
deadGiven 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 — the Leads page 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.
  • 5xx and 429 are retried. Any other non-2xx is treated as permanent and stops immediately — a 404 means the URL is wrong, and retrying won’t fix it.
  • Each attempt times out after 15 seconds. Return a 2xx quickly and do your slow work afterwards.
  • After 50 consecutive failures the destination is disabled automatically so a dead endpoint doesn’t retry forever, and the team’s owners and admins are emailed. Fix the receiver, then click Enable.

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.

Stage change payloads

A stage trigger sends the same envelope with a different event and one extra key:

{
  "specVersion": 1,
  "event": "form.submission.status_changed",
  "idempotencyKey": "sub_abc123:dest_xyz789:evt_456",
  "data": {
    "submission": { "…": "as above" },
    "site": { "…": "as above" },
    "destination": { "id": "dest_xyz789" },
    "change": { "from": "contacted", "to": "won" }
  }
}

change is present only on form.submission.status_changed. It’s an additive field within specVersion 1, so a receiver written before stage triggers existed keeps working — switch on event and ignore what you don’t handle.

from and to are stage keys: short lowercase identifiers created from the stage’s name when the stage was added. A key never changes afterwards, even if you rename the stage, so it’s safe to match on. The event name still says status_changed for compatibility.

The idempotency key carries the transition’s own id, so each stage change is distinct while its retries share a key.

fields always carries what the visitor originally submitted, even on a stage change sent long afterwards, and even if someone has since corrected a value. Webhook payloads deliberately never rewrite history — otherwise your system and PageFork would quietly disagree about what a lead said. Corrected values reach you through the CSV export and the lead itself.

Headers on every request:

HeaderPurpose
X-PageFork-SignatureHMAC of the body, v1=<hex>
X-PageFork-TimestampUnix seconds used in the signature
X-PageFork-Delivery-IdThis delivery attempt
X-PageFork-Idempotency-KeyStable 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.

”Managed by Zapier” and “Managed by Make”

When you use the Zapier or Make app, each active Zap or scenario shows up in the destinations list as a webhook marked Managed by Zapier or Managed by Make.

They are ordinary webhook deliveries underneath, so the delivery log and Retry work the same way. But they belong to the other tool: you can’t edit them, rotate their secret or delete them here. Turn the Zap off, or delete the webhook in Make, and the entry disappears. Revoking the API key that created them removes them too.

They have their own limit of 10 per site and don’t count towards the limits above.

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.

Next