Skip to content

Developer platform

Receive and verify PageFox webhooks

Signed with HMAC-SHA256 · one endpoint per organization · owner-managed

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.

HeaderContains
X-PageFox-EventThe event type, identical to the body's type.
X-PageFox-DeliveryA UUID unique to this delivery, identical to the body's id. Use it to make your handler idempotent.
X-PageFox-Signaturet=<unix seconds>,v1=<hex hmac>

Every body has the same five-key envelope: id, type, created_at, org_id, and a per-event data object.

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 t but does not enforce a window on your behalf. The reference implementation below rejects anything more than 300 seconds away from your clock in either direction; that value is the recommended default, not a PageFox-side rule.
Node.js
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)
}

A verification failure is a rejection, never a retry-worthy error on your side: respond 400 and drop the delivery.

What the payloads deliberately omit

Lead and company payloads pass through the same privacy rules the dashboard and the REST API enforce, so the examples below are what you will actually receive, not a maximal shape:

  • 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 receive company_locked: true with company and enriched_data nulled. A company.* event for a locked company is not sent at all — its entire content would be the identity.
  • Demo websites emit nothing. Sample data never reaches your endpoint.
lead.hot — metering-locked company
{
  "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",
    "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

Nine event types. An endpoint subscribes to any subset, or to all of them. Each example below is generated from the code that emits it and verified against it on every build.

lead.hot

Lead — HOT. A lead was classified (or upgraded) to HOT.

lead.hot
{
  "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",
    "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 — WARM. A lead was classified (or upgraded) to WARM.

lead.warm
{
  "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",
    "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.captured

Lead captured. A visitor became an identified lead for the first time.

lead.captured
{
  "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",
    "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.

agent.visited
{
  "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%.

quota.warning
{
  "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.

crawl.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.

subscription.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.

company.identified
{
  "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.

company.returned
{
  "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"
  }
}

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. There is no plan requirement — webhooks are available on every plan, including free.

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.