cdpfleet Docs Sign up free

Webhooks

Function jobs and monitors can POST what happened to your URL. Every request is signed with your account's secret, carries an id that stays the same when it is retried, and is retried until your endpoint answers 2xx or the schedule runs out. Jobs and monitors come with paid plans.

What arrives

A JSON POST to the public https URL you gave in "deliver": {"webhook": {"url": "https://…"}}. Job webhooks are {type, job_id, created_at, data}, monitor webhooks {type, created_at, data}:

typeWhendata
job.doneA job ends (with "events": ["done"], the default)status (done, stopped, failed or interrupted), progress
job.pageEach page of a batch or crawl ("page")That page's result, as in results.jsonl; over 1 MB, a result_url
job.eventA watch's live events, at most one request per 2 s ("event")A batch of events
job.archiveThe zip of a job with "format": "archive" is ready ("archive")url, bytes
monitor.changeA monitor's page changedmonitor_id, change_id, url, at, added, removed, diff, snapshot_url
monitor.errorA monitor failed three checks in a row (sent once)monitor_id, url, error, failures

Two of the deliveries a two-page batch sent on 2026-11-14, byte for byte:

{"type":"job.page","job_id":"f1e49e3c-820e-4fcf-b0a0-7c9b3aedc20f","created_at":"2026-11-14T02:05:08.979Z","data":{"url":"https://books.toscrape.com/","status":200,"extract":{"title":"All products"}}}
{"type":"job.done","job_id":"f1e49e3c-820e-4fcf-b0a0-7c9b3aedc20f","created_at":"2026-11-14T02:05:09.087Z","data":{"status":"done","progress":{"done":2,"failed":0,"queued":0,"total":2}}}

Headers

Verify the signature

Your secret is a whsec_… string, one per account for all your jobs and monitors: GET /v1/me/webhook-secret returns it (and creates it the first time). Treat it like an API key. For each request:

  1. Take t and v1 from x-cdpfleet-signature.
  2. Compute HMAC-SHA256 of "<t>.<raw body>", keyed with the whole whsec_… string, as lowercase hex.
  3. Accept only if it equals v1 (compare in constant time) and t is within 5 minutes of your clock.
import crypto from 'node:crypto';

// rawBody: the request body exactly as received (a Buffer or string), before any JSON parsing.
export function verifyWebhook(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(String(header ?? '').split(',').map((kv) => kv.trim().split('=', 2)));
  const t = Number(parts.t);
  if (!Number.isInteger(t) || !/^[0-9a-f]{64}$/.test(parts.v1 ?? '')) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
  return crypto.timingSafeEqual(expected, Buffer.from(parts.v1, 'hex'));
}

Sign the raw body, not parsed JSON. The signature covers the exact bytes we sent; JSON parsed and serialised again almost never matches. Where to get the raw bytes:

Tested on 2026-11-14 with real deliveries (two job.page, a job.done and a monitor.change): each of the five functions accepted all four, and rejected the same body with one value changed, the body parsed and serialised again with spaces, a signature with one hex digit changed, a correct signature dated 10 minutes earlier, and a missing header.

A receiver

A complete one in Node.js, no dependencies: verify, answer at once, then skip deliveries you've already handled.

import http from 'node:http';
import { verifyWebhook } from './verify.mjs';

const secret = process.env.CDPFLEET_WEBHOOK_SECRET; // whsec_…, from GET /v1/me/webhook-secret
const seen = new Set(); // delivery ids already handled; keep them in your database in production

http.createServer((req, res) => {
  const chunks = [];
  req.on('data', (c) => chunks.push(c));
  req.on('end', () => {
    const raw = Buffer.concat(chunks);
    if (!verifyWebhook(raw, req.headers['x-cdpfleet-signature'], secret)) return res.writeHead(401).end();
    res.writeHead(204).end(); // answer first, work after
    const id = req.headers['x-cdpfleet-delivery'];
    if (seen.has(id)) return; // a retry of one already handled
    seen.add(id);
    const event = JSON.parse(raw);
    console.log(event.type, event.job_id ?? event.data.monitor_id, JSON.stringify(event.data));
  });
}).listen(3000);

To try it before any job runs, sign a request yourself with the secret in CDPFLEET_WEBHOOK_SECRET:

body='{"type":"job.done","job_id":"test","created_at":"2026-11-14T00:00:00Z","data":{"status":"done"}}'
t=$(date +%s)
sig=$(printf '%s' "$t.$body" | openssl dgst -sha256 -hmac "$CDPFLEET_WEBHOOK_SECRET" | sed 's/^.* //')
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/hooks/cdpfleet -H 'content-type: application/json' \
  -H 'x-cdpfleet-event: job.done' -H "x-cdpfleet-delivery: test-$t" -H "x-cdpfleet-signature: t=$t,v1=$sig" -d "$body"

The receiver answers 204 and logs the event once, however often you send the same delivery id, and 401 to a request without a valid signature.

Retries

De-duplicate

An attempt can reach you and still count as failed — your endpoint was slow, or the answer was lost — and then you get it again. Store each x-cdpfleet-delivery you've handled for a few hours (the whole schedule fits in 3) and skip repeats. Answer first and do the work after, so a slow handler doesn't turn into a retry.

Rotate the secret

POST /v1/me/webhook-secret/rotate returns a new secret and replaces the old one at once: every attempt from then on, retries included, is signed with the new one. Answer a request whose signature doesn't verify with an error such as 401, not 2xx: then the deliveries sent while you switch are retried, and pass once your receiver has the new secret.

The URL

It must be https and resolve to a public address; a private or local one is refused when you create the job or monitor (400 invalid_option) and checked again on every attempt. The URL can carry its own path and query string if you want to tell endpoints apart.