- Docs
- Webhooks
Receive and verify PageFox webhooks
Verify the signature, handle every event in the catalog, and plan for the real retry behaviour.
Signed with HMAC-SHA256 · one endpoint per organization · owner-managed
- POST
- your HTTPS endpoint
- X-PageFox-Event
- lead.hot
- X-PageFox-Delivery
- evt_lead_hot_example
- X-PageFox-Signature
- t=<unix seconds>,v1=<hex hmac>
- body
- { id, type, created_at, org_id, data }
What a delivery looks like.
PageFox POSTs a JSON body to the single HTTPS endpoint your organization has configured, for each event type that endpoint subscribes to. Every delivery carries three headers.
Every body has the same five-key envelope: id, type, created_at, org_id, and a per-event data object.
| Header | Contains |
|---|---|
| X-PageFox-Event | The event type, identical to the body's type. |
| X-PageFox-Delivery | A UUID unique to this delivery, identical to the body's id. Use it to make your handler idempotent. |
| X-PageFox-Signature | t=<unix seconds>,v1=<hex hmac> |
Verify the signature.
Compute HMAC-SHA256 over the string `${t}.${rawBody}` using your signing secret, hex-encode it, and compare it to the v1 value in constant time. Two details decide whether this works:
- Use the raw request bytes. Re-serializing the parsed JSON changes key order and whitespace, which changes the signature. Capture the body as a string or buffer before your framework parses it.
- Bound the timestamp yourself. PageFox stamps
tbut does not enforce a window on your behalf. The reference implementation rejects anything more than 300 seconds away from your clock in either direction; that value is the recommended default, not a PageFox-side rule.
A verification failure is a rejection, never a retry-worthy error on your side: respond 400 and drop the delivery.
import { createHmac, timingSafeEqual } from 'node:crypto'
// rawBody MUST be the exact bytes PageFox sent. Do not re-serialize the
// parsed JSON: key order and whitespace would change the signature.
export function verifyPageFoxSignature(secret, signatureHeader, rawBody, toleranceSeconds = 300) {
const parts = new Map(
signatureHeader.split(',').map((pair) => {
const eq = pair.indexOf('=')
return [pair.slice(0, eq).trim(), pair.slice(eq + 1).trim()]
}),
)
const timestamp = parts.get('t')
const signature = parts.get('v1')
if (!timestamp || !signature) return false
const ts = Number(timestamp)
if (!Number.isFinite(ts)) return false
if (Math.abs(Math.floor(Date.now() / 1000) - ts) > toleranceSeconds) return false
const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex')
const a = Buffer.from(expected, 'hex')
const b = Buffer.from(signature, 'hex')
return a.length === b.length && timingSafeEqual(a, b)
}What the payloads deliberately omit.
- Person-level enrichment never ships unless your organization holds the contact-reveal entitlement, which is not available yet. A
lead.*payload keeps company-level enrichment such as industry and employee count, and simply has no person block. - A metering-locked company is redacted, not partially named. On
lead.*you receivecompany_locked: truewithcompanyandenriched_datanulled. Acompany.*event for a locked company is not sent at all: its entire content would be the identity. - Demo websites emit nothing. Demo data never reaches your endpoint.
{
"id": "evt_lead_hot_locked_example",
"type": "lead.hot",
"created_at": "2026-08-21T11:15:00.000Z",
"org_id": "9f1d2c3b-4a5e-4f60-8712-0c9a1b2d3e4f",
"data": {
"id": "lead_example_1",
"name": "Jane Visitor",
"email": "jane@northwind.example",
"phone": null,
"company": null,
"company_locked": true,
"score": 88,
"classification": "hot",
"priority": "high",
"status": "new",
"source_url": "https://example.com/pricing",
"referrer": "https://www.google.com/",
"suggested_action": "Reply while they are still on the pricing page.",
"enriched_data": null,
"created_at": "2026-08-20T14:00:00.000Z",
"updated_at": "2026-08-21T11:15:00.000Z"
}
}Event catalog.
lead.hot
Lead: High priority. A lead reached (or was upgraded to) High priority.
{
"id": "evt_lead_hot_example",
"type": "lead.hot",
"created_at": "2026-08-21T11:15:00.000Z",
"org_id": "9f1d2c3b-4a5e-4f60-8712-0c9a1b2d3e4f",
"data": {
"id": "lead_example_1",
"name": "Jane Visitor",
"email": "jane@northwind.example",
"phone": null,
"company": "Northwind Example",
"company_locked": false,
"score": 88,
"classification": "hot",
"priority": "high",
"status": "new",
"source_url": "https://example.com/pricing",
"referrer": "https://www.google.com/",
"suggested_action": "Reply while they are still on the pricing page.",
"enriched_data": {
"industry": "Software",
"employee_count": 84
},
"created_at": "2026-08-20T14:00:00.000Z",
"updated_at": "2026-08-21T11:15:00.000Z"
}
}lead.warm
Lead: Medium priority. A lead reached (or was upgraded to) Medium priority.
{
"id": "evt_lead_warm_example",
"type": "lead.warm",
"created_at": "2026-08-21T11:15:00.000Z",
"org_id": "9f1d2c3b-4a5e-4f60-8712-0c9a1b2d3e4f",
"data": {
"id": "lead_example_1",
"name": "Jane Visitor",
"email": "jane@northwind.example",
"phone": null,
"company": "Northwind Example",
"company_locked": false,
"score": 88,
"classification": "warm",
"priority": "medium",
"status": "new",
"source_url": "https://example.com/pricing",
"referrer": "https://www.google.com/",
"suggested_action": "Reply while they are still on the pricing page.",
"enriched_data": {
"industry": "Software",
"employee_count": 84
},
"created_at": "2026-08-20T14:00:00.000Z",
"updated_at": "2026-08-21T11:15:00.000Z"
}
}visitor.hot
Visitor worth calling. Somebody worth calling is on the site, and they have not left an address.
{
"id": "evt_visitor_hot_example",
"type": "visitor.hot",
"created_at": "2026-08-21T11:15:00.000Z",
"org_id": "9f1d2c3b-4a5e-4f60-8712-0c9a1b2d3e4f",
"data": {
"previous_plan": "growth",
"new_plan": "business"
}
}lead.captured
Lead captured. A visitor became an identified lead for the first time.
{
"id": "evt_lead_captured_example",
"type": "lead.captured",
"created_at": "2026-08-21T11:15:00.000Z",
"org_id": "9f1d2c3b-4a5e-4f60-8712-0c9a1b2d3e4f",
"data": {
"id": "lead_example_1",
"name": "Jane Visitor",
"email": "jane@northwind.example",
"phone": null,
"company": "Northwind Example",
"company_locked": false,
"score": 88,
"classification": "hot",
"priority": "high",
"status": "new",
"source_url": "https://example.com/pricing",
"referrer": "https://www.google.com/",
"suggested_action": "Reply while they are still on the pricing page.",
"enriched_data": {
"industry": "Software",
"employee_count": 84
},
"created_at": "2026-08-20T14:00:00.000Z",
"updated_at": "2026-08-21T11:15:00.000Z"
}
}agent.visited
AI agent visited. A verified AI agent or an AI-referred human visit was recorded.
{
"id": "evt_agent_visited_example",
"type": "agent.visited",
"created_at": "2026-08-21T11:15:00.000Z",
"org_id": "9f1d2c3b-4a5e-4f60-8712-0c9a1b2d3e4f",
"data": {
"website_id": "00000000-0000-4000-8000-000000000000",
"visitor_id": "visitor_example_1",
"conversation_id": null,
"actor_type": "verified_agent",
"ai_class": "assistant_fetch",
"confidence": "high",
"decision": "allow",
"evidence_source": "signature"
}
}quota.warning
Tracking quota warning. The daily tracking-event quota crossed 80% or 100%.
{
"id": "evt_quota_warning_example",
"type": "quota.warning",
"created_at": "2026-08-21T11:15:00.000Z",
"org_id": "9f1d2c3b-4a5e-4f60-8712-0c9a1b2d3e4f",
"data": {
"website_id": "00000000-0000-4000-8000-000000000000",
"threshold": 80,
"used": 8000,
"quota": 10000,
"day": "2026-08-21",
"website_breakdown": [
{
"websiteId": "00000000-0000-4000-8000-000000000000",
"eventsCount": 8000
}
]
}
}crawl.failed
Crawl failed. A website crawl job failed.
{
"id": "evt_crawl_failed_example",
"type": "crawl.failed",
"created_at": "2026-08-21T11:15:00.000Z",
"org_id": "9f1d2c3b-4a5e-4f60-8712-0c9a1b2d3e4f",
"data": {
"website_id": "00000000-0000-4000-8000-000000000000",
"failure_reason": "Crawl timed out after 300s"
}
}subscription.changed
Subscription changed. The org’s billing plan changed.
{
"id": "evt_subscription_changed_example",
"type": "subscription.changed",
"created_at": "2026-08-21T11:15:00.000Z",
"org_id": "9f1d2c3b-4a5e-4f60-8712-0c9a1b2d3e4f",
"data": {
"previous_plan": "growth",
"new_plan": "business"
}
}company.identified
Company identified. A company was identified on your site for the first time.
{
"id": "evt_company_identified_example",
"type": "company.identified",
"created_at": "2026-08-21T11:15:00.000Z",
"org_id": "9f1d2c3b-4a5e-4f60-8712-0c9a1b2d3e4f",
"data": {
"id": "company_example_1",
"name": "Northwind Example",
"domain": "northwind.example",
"industry": "Software",
"employee_count": 84,
"location": "Toronto, CA",
"heat_band": "hot",
"pages_viewed": 8,
"page_url": "https://example.com/pricing",
"dashboard_url": "https://pagefox.co/dashboard/leads?view=companies&companyId=company_example_1"
}
}company.returned
Company returned. A previously identified company came back for a new qualifying visit.
{
"id": "evt_company_returned_example",
"type": "company.returned",
"created_at": "2026-08-21T11:15:00.000Z",
"org_id": "9f1d2c3b-4a5e-4f60-8712-0c9a1b2d3e4f",
"data": {
"id": "company_example_1",
"name": "Northwind Example",
"domain": "northwind.example",
"industry": "Software",
"employee_count": 84,
"location": "Toronto, CA",
"heat_band": "hot",
"pages_viewed": 8,
"page_url": "https://example.com/pricing",
"dashboard_url": "https://pagefox.co/dashboard/leads?view=companies&companyId=company_example_1"
}
}lead.draft_ready
Lead: email draft ready. A checked first email or follow-up for a lead is ready to send, with its subject and body.
{
"id": "evt_lead_draft_ready_example",
"type": "lead.draft_ready",
"created_at": "2026-08-21T11:15:00.000Z",
"org_id": "9f1d2c3b-4a5e-4f60-8712-0c9a1b2d3e4f",
"data": {
"lead_id": "lead_example_1",
"email": "jane@northwind.example",
"company": "Northwind Example",
"company_domain": "northwind.example",
"draft_id": "draft_example_1",
"subject": "The Pune plant",
"body": "Hi Jane,\n\nCongrats on the second plant in Pune. Is dispatch on your plate right now?\n\nRavi",
"trigger": "top_picks"
}
}Retries, timeouts, and endpoint health.
3 attempts maximum per event, including the first. PageFox stops at the first 2xx.
Backoff of 500ms, then 1000ms between attempts (exponential from a 500ms base).
10-second timeout per attempt. A slow handler is a failed attempt, so acknowledge quickly and do your work asynchronously.
Redirects are not followed. A 3xx counts as a failure. Configure the final URL directly.
After 2 consecutive failed deliveries the endpoint is flagged in the dashboard so you can fix and reconnect it. Each attempt is recorded and the last ten are listed on the webhooks settings page.
There is no dead-letter queue and no manual redelivery. Once 3 attempts fail, that event is not retried again. Reconcile against the REST API if your handler was down.
Managing the secret and the endpoint.
The signing secret is generated by PageFox, prefixed whsec_, and shown exactly once: on first connect and again on each rotation. It is encrypted at rest and never returned to the dashboard afterwards, so store it in your secret manager immediately. If you lose it, rotate to get a new one; the old secret stops signing at that moment, so deploy the new one first if you can.
Who can configure this: the organization owner only. Members can view the endpoint's status and recent deliveries but cannot connect, rotate, disconnect, or send a test event. Webhooks are included on Growth and above; on Free the endpoint can be seen but not connected or changed.
The endpoint URL must be HTTPS and publicly resolvable; private and loopback addresses are rejected. One endpoint per organization. Configure it in Integrations → Outbound Webhooks, where a test delivery is also available.
What is not available yet.
PageFox does not currently publish inbound webhooks, per-event-type endpoint URLs, a delivery replay API, or a webhook SDK. Contact-reveal person fields are not shipping on any channel yet. Nothing on this page describes a planned capability: everything here is live behaviour.