Webhooks
Two delivery paths — a per-job webhookUrl and organization endpoints — both signed, both verifiable with a few lines of code.
A webhook pushes a job's final state to you instead of you polling for it. There are two kinds, and they coexist:
- Per-job
webhookUrl— passed inPOST /sdk/v1/cloud/jobs, fires once for that job. This is the original mechanism and it has not changed. - Organization endpoints — created in Console → Webhooks (up to 5 per organization), receive events for every cloud job in your organization.
Per-job webhookUrl
Supply webhookUrl when creating a batch job and we
POST the settled job to it, in the same envelope the polling endpoint returns:
{ "ok": true, "data": { /* the job, with its measurement when completed */ } }The request is signed with NCS1 using your own API key — headers x-key-id,
x-timestamp, x-nonce, x-signature — so you verify it with the same
function you use to sign your requests.
Organization endpoints
Managed entirely in the console: create an endpoint, receive its secret, watch its delivery journal. Each endpoint receives these events:
| Event | When |
|---|---|
cloud.job.completed | A job produced a measurement |
cloud.job.failed | Processing failed |
cloud.job.aborted | The job ended without a result but not through a failure |
cloud.job.expired | The uploaded object was swept before the job ran |
webhook.test | You pressed the test button in the console |
The envelope
{
"id": "whd_9c1f4b7ae2d05138b6a37f42",
"event": "cloud.job.completed",
"createdAt": 1756000000000,
"data": {
"jobId": "cj_…",
"mode": "batch_frames",
"status": "completed",
"measurementId": "meas_…",
"measurement": {}
}
}id is the delivery id — it also appears in the console journal, so a received
body can be matched to a journal row. data is the same job object
GET /sdk/v1/cloud/jobs/:jobId returns, with the
measurement inside when the job completed.
The signature
Four headers authenticate each delivery:
| Header | Value |
|---|---|
x-webhook-id | The endpoint's id. |
x-timestamp | Unix time in seconds at the moment of delivery. |
x-nonce | A fresh random string, unique per attempt. |
x-signature | Base64( HMAC-SHA256( SHA256(secret), canonicalString ) ). |
The secret is the whsec_… string shown once when the endpoint is
created or rotated — we store only its SHA256, exactly like an API-key secret.
The canonical string is the same NCS1 scheme
you already sign your own requests with:
NCS1
POST
<path of your webhook URL, without query string>
<x-timestamp>
<x-nonce>
<SHA256(request body) as lowercase hex>Verifying a delivery (Node.js)
import crypto from 'node:crypto';
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
function verifyWebhook(secret, path, headers, rawBody) {
const canonical = [
'NCS1',
'POST',
path,
headers['x-timestamp'],
headers['x-nonce'],
sha256(rawBody),
].join('\n');
const expected = crypto
.createHmac('sha256', sha256(secret))
.update(canonical)
.digest('base64');
const provided = String(headers['x-signature'] || '');
return (
provided.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided))
);
}Verify over the raw request body — parse the JSON only after the signature checks out. And remember what a verified webhook proves: the delivery came from us. What the measurement proves is a separate question — see Attestation.
Delivery semantics
| Rule | |
|---|---|
| Attempts | Up to 3, with exponential backoff |
| Timeout | 10 s per attempt |
| Redirects | Not followed — a 3xx is a failed delivery |
| Success | Any 2xx |
The target URL must be https on a publicly routable host, and it is re-checked at every delivery — a URL whose DNS has since moved to a private address is refused rather than called.
Every attempt lands in the delivery journal in the console: status, HTTP
code, attempt count, last error, response time. From there you can
redeliver — the body is regenerated from the job's current state, not
replayed from a stored copy — and send a webhook.test event to prove your
receiver end to end before real traffic arrives.
Next: Errors.