Choose only the events your integration needs
job.completed— terminal success with aggregate result counts and credits used.job.failed— terminal failure requiring attention.export.completed— generated output is ready to resolve through the authenticated API.export.failed— generation failed.credits.low— the organization’s spendable pool reached its computed low-credit threshold; emitted at most once per workspace per UTC day.
The low-credit threshold is computed as max(20, ceil(start-of-day organization pool × 20%)). After credit-using work completes, PeopleDat compares the current organization balance with that threshold and includes both balance and threshold in the event.
Payloads contain identifiers, status, aggregate counts, credit use, and fetch URLs where applicable. They never contain addresses or preserved source columns.
Register a hardened HTTPS receiver
Build a dedicated POST route
Return a 2xx quickly after authenticating and durably recording the event. Move slow work to your own queue.Use a public HTTPS URL on port 443
PeopleDat rejects credentials, redirects, private/local/metadata/link-local/reserved destinations, mapped IPv4, and unsafe DNS changes.Register it in Dashboard → Developer
Choose a clear name and one or more supported event types. A workspace may have up to five active endpoints.Store the signing secret once
The secret is shown only at creation. Put it in your server-side secret manager, not code or browser storage.Send a test event
Use the dashboard’s test action and inspect the delivery status, HTTP response, and any bounded diagnostic.
Read the versioned event envelope
{
"id": "evt_01…",
"type": "job.completed",
"api_version": "2026-08-27",
"created_at": "2026-08-30T14:32:10.000Z",
"data": {
"job_id": "job_01…",
"status": "completed",
"source": "API",
"total": 2500,
"credits_used": 2310,
"counts": {
"deliverable": 1984,
"risky": 241,
"undeliverable": 85,
"unknown": 150,
"excluded": 40
}
}
}source is an uppercase enum such as DASHBOARD, API, WIDGET, MCP, SCHEDULE, or SYSTEM. Treat fields you do not recognize as forward-compatible additions. Branch on type and the version your integration supports; do not infer sensitive row data from aggregate counts.
Verify the exact raw body before parsing
PeopleDat signs timestamp + period + raw UTF-8 body using HMAC-SHA256 and the endpoint secret.
import crypto from 'node:crypto'
export function verifyPeopleDatWebhook({ rawBody, headers, secret }) {
const timestamp = headers.get('peopledat-timestamp')
const supplied = headers.get('peopledat-signature')
const signature = supplied && /^v1=([a-f0-9]{64})$/.exec(supplied)
if (!timestamp || !/^\d+$/.test(timestamp) || !signature) return false
const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp))
if (!Number.isFinite(ageSeconds) || ageSeconds > 300) return false
const expected = crypto
.createHmac('sha256', secret)
.update(timestamp + '.', 'utf8')
.update(rawBody)
.digest()
const received = Buffer.from(signature[1], 'hex')
return expected.length === received.length
&& crypto.timingSafeEqual(expected, received)
}
export async function POST(request) {
const secret = process.env.PEOPLEDAT_WEBHOOK_SECRET
if (!secret) return new Response('Webhook is not configured', { status: 500 })
// request.headers is a Web Headers object. Capture the body before parsing.
const rawBody = Buffer.from(await request.arrayBuffer())
const valid = verifyPeopleDatWebhook({ rawBody, headers: request.headers, secret })
if (!valid) return new Response('Invalid signature', { status: 401 })
const event = JSON.parse(rawBody.toString('utf8'))
// Durably claim event.id before starting side effects.
await processOnce(event)
return new Response(null, { status: 204 })
}- Read
PeopleDat-Timestamp,PeopleDat-Signature, andPeopleDat-Event-Id. - Reject missing, malformed, or stale timestamps; five minutes is a practical default tolerance.
- Compute over the exact raw request bytes before JSON parsing or reserialization.
- Compare the lowercase hex digest after
v1=in constant time. - Only parse/process the JSON after the signature passes.
Process events exactly once from your perspective
Authenticate first
Reject invalid signatures without logging the secret or full body.Claim the event ID
InsertPeopleDat-Event-Idinto a table with a unique constraint. If it already exists, return 2xx without repeating side effects.Record minimal metadata
Store event ID, type, creation time, processing state, and the resource ID needed for your workflow.Acknowledge quickly
Return a 2xx once the event is durably accepted, then perform slow work asynchronously.Fetch current state when needed
Use the authenticated PeopleDat API for job results or export resolution rather than expecting row data in the event.
Understand retries, diagnostics, and replay
Failed delivery is attempted immediately, then after approximately 1 minute, 5 minutes, 30 minutes, 2 hours, and 12 hours. Any 2xx response is success. Timeouts, connection failures, and non-2xx responses enter the retry schedule.
Delivery diagnostics are retained for 30 days. Owners and admins can send test deliveries and replay an eligible delivery from the dashboard. A replay can deliver the same immutable event again, so event-ID deduplication is required.
Troubleshoot deliveries
Signature mismatch
Confirm you used the endpoint’s current secret, the raw body bytes, the decimal timestamp string, a literal period, and lowercase hex output.
Destination rejected
Use a public DNS name, HTTPS on 443, no URL credentials or redirect, and no private/reserved address resolution.
Repeated events
Retries and manual replays are expected. Enforce a unique event ID before side effects and return 2xx for duplicates.
Timeouts
Verify and persist first; defer API fetches and other slow work to your own queue.
Missing row data
This is intentional. Webhooks are aggregate-only; fetch authorized results from /api/v1 with a workspace API key.