NeccessoryNeccessory
REST API

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 in POST /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:

EventWhen
cloud.job.completedA job produced a measurement
cloud.job.failedProcessing failed
cloud.job.abortedThe job ended without a result but not through a failure
cloud.job.expiredThe uploaded object was swept before the job ran
webhook.testYou 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:

HeaderValue
x-webhook-idThe endpoint's id.
x-timestampUnix time in seconds at the moment of delivery.
x-nonceA fresh random string, unique per attempt.
x-signatureBase64( 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
AttemptsUp to 3, with exponential backoff
Timeout10 s per attempt
RedirectsNot followed — a 3xx is a failed delivery
SuccessAny 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.