Skip to main content
Peopledat Logo

Signed webhooks

Know when work finishes—without polling forever.

Receive privacy-safe terminal job, export, and low-credit events with exact-body HMAC signatures, durable retries, diagnostics, and controlled replay.

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

  1. Build a dedicated POST route

    Return a 2xx quickly after authenticating and durably recording the event. Move slow work to your own queue.
  2. 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.
  3. 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.
  4. 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.
  5. 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

Representative job.completed body
{
  "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.

Next.js Node runtime with Fetch Request and Headers
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, and PeopleDat-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

  1. Authenticate first

    Reject invalid signatures without logging the secret or full body.
  2. Claim the event ID

    Insert PeopleDat-Event-Id into a table with a unique constraint. If it already exists, return 2xx without repeating side effects.
  3. Record minimal metadata

    Store event ID, type, creation time, processing state, and the resource ID needed for your workflow.
  4. Acknowledge quickly

    Return a 2xx once the event is durably accepted, then perform slow work asynchronously.
  5. 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.