Skip to content

Webhooks

Some sources answer after the synchronous deadline. When an assessment’s score, band, recommendation, coverage or status changes, NoResponse sends an assessment.updated event to your endpoint. The event carries the whole assessment, so your receiver has nothing to fetch.

Endpoint registration is not self-serve yet. Send the URL of your receiver to [email protected]. We register your endpoint URL and give you the key id and secret that sign its deliveries. The secret is shown once. Store both in your secret store, for example as NORESPONSE_WEBHOOK_KEY_ID and NORESPONSE_WEBHOOK_SECRET.

The key id starts with whk_ and is followed by 26 letters and digits, such as whk_01JA2ZB4N5P6Q7R8S9T0VWXYZ1. The SDK verifiers take a map from key id to secret, so you can hold two secrets during a rotation.

The URL must use https on port 443 or 8443 with a publicly trusted certificate, name a host by DNS name rather than IP address, and carry no user name, password or fragment. It is at most 2,048 characters. The host must resolve only to public addresses. Your platform can have up to five endpoints. NoResponse follows no redirects and reads at most 4 KiB of your response.

Before any assessment event reaches a new endpoint, NoResponse sends an endpoint.verification event and waits for the right answer.

Every delivery has the same envelope, and four signed headers.

{
"id": "evt_01JA2ZB4N5P6Q7R8S9T0VWXYZ1",
"type": "assessment.updated",
"created_at": "2026-10-14T09:00:02.000Z",
"data": { "...": "the assessment, or the verification challenge" }
}
Header Value
NR-Webhook-Id The body’s id. The same on every redelivery.
NR-Webhook-Timestamp Unix time in whole seconds for this delivery attempt.
NR-Webhook-Key-Id The whk_ key id of the secret that signed this delivery.
NR-Webhook-Signature v1= followed by the lowercase hex HMAC-SHA256 of timestamp + "." + raw body, keyed with the secret.

data depends on type. For assessment.updated it is the assessment exactly as GET /v1/assessments/{assessment_id} returns it at that assessment_version, in the response mode set for the endpoint. Revisions that change none of score, band, recommendation, coverage or status are merged, and the final event has status: "complete". For endpoint.verification it holds an endpoint_id and a challenge.

Verify every delivery before you read it, in this order, and answer 400 if any check fails.

  1. Each of the four headers is present exactly once. The same name in another letter case counts as a repeat.
  2. The timestamp is within 300 seconds of your clock.
  3. The key id names a secret you hold.
  4. The signature matches, compared in constant time, over the raw bytes you received.
  5. The body’s id equals NR-Webhook-Id.

The SDK helper does all five and returns a typed event. Give it the raw body bytes, the request headers and your secrets. It refuses a repeated signature header and a stale timestamp, and it accepts a tolerance of 300 seconds by default.

When NoResponse registers your endpoint, or you change its URL, it sends a signed endpoint.verification event.

{
"id": "evt_01JA2ZD4N5P6Q7R8S9T0VWXYZ4",
"type": "endpoint.verification",
"created_at": "2026-10-14T08:00:00.000Z",
"data": {
"endpoint_id": "whe_01JA2Z0A1B2C3D4E5F6G7H8J9K",
"challenge": "q3Jt8mV0x2Yb7Lk9Pz4Rw6Hn1Sd5Fg8Ja0Tc3Ue7Wi4"
}
}

Verify the signature, then answer 200 with Content-Type: application/json and the challenge echoed, within 5 seconds.

{ "challenge": "q3Jt8mV0x2Yb7Lk9Pz4Rw6Hn1Sd5Fg8Ja0Tc3Ue7Wi4" }

Anything else, including a 2xx without the exact challenge, leaves the endpoint unverified and no assessment event is sent to it. The SDK helper builds the answer, so the receivers below return it as the JSON body.

Delivery is at least once and unordered. Three rules keep your state right.

  • Deduplicate on id. A redelivery carries the same id, and a receiver that has already processed it still answers 2xx.
  • Keep the highest assessment_version. Apply an assessment.updated event only if data.assessment_version is higher than the version you hold for that assessment_id.
  • Always apply an expiry, then ignore later events for that assessment.

When an assessment reaches expires_at, NoResponse sends one last assessment.updated event whose data is minimal.

{
"id": "evt_01JA2ZC4N5P6Q7R8S9T0VWXYZ3",
"type": "assessment.updated",
"created_at": "2026-11-13T09:00:01.000Z",
"data": {
"assessment_id": "asm_01JA2Z8K6Q4V7W9XBCDEFGHJKM",
"account_id": "acct_7f3a9c",
"assessment_version": 2,
"status": "expired",
"change_reason": "expired"
}
}

Delete what you hold for that assessment. There is nothing to fetch, because GET /v1/assessments/{assessment_id} now answers 404. assessment_version is the last version served, not a new one, so a version comparison alone would drop this event. Check status first.

Each receiver reads the raw body, verifies it, answers the challenge, deduplicates on id, applies the version rule, handles expired, and answers 200 within 5 seconds. Put the slow work on a queue. Any 2xx within 5 seconds acknowledges the delivery.

Express:

webhooks.ts
import express from "express";
import { NoResponse, isExpiredEvent } from "@noresponse/node";
const secrets = { [process.env.NORESPONSE_WEBHOOK_KEY_ID!]: process.env.NORESPONSE_WEBHOOK_SECRET! };
const app = express();
app.post("/webhooks/noresponse", express.raw({ type: "application/json" }), async (req, res) => {
let event;
try {
event = NoResponse.webhooks.constructEvent(req.body as Buffer, req.headers, secrets);
} catch {
return res.status(400).end();
}
if (event.type === "endpoint.verification") {
return res.status(200).json(NoResponse.webhooks.verificationResponse(event));
}
if (await events.seen(event.id)) return res.status(200).end();
if (isExpiredEvent(event)) {
await assessments.forget(event.data.assessmentId);
} else if (event.data.assessmentVersion > (await assessments.version(event.data.assessmentId))) {
await queue.push({ assessment: event.data }); // run decide() in a worker
}
await events.markSeen(event.id);
res.status(200).end();
});

Next.js route handler:

app/webhooks/noresponse/route.ts
import { NextResponse } from "next/server";
import { NoResponse, isExpiredEvent } from "@noresponse/node";
const secrets = { [process.env.NORESPONSE_WEBHOOK_KEY_ID!]: process.env.NORESPONSE_WEBHOOK_SECRET! };
export async function POST(request: Request) {
const rawBody = Buffer.from(await request.arrayBuffer());
let event;
try {
event = NoResponse.webhooks.constructEvent(rawBody, request.headers, secrets);
} catch {
return new NextResponse(null, { status: 400 });
}
if (event.type === "endpoint.verification") {
return NextResponse.json(NoResponse.webhooks.verificationResponse(event));
}
if (await events.seen(event.id)) return new NextResponse(null, { status: 200 });
if (isExpiredEvent(event)) {
await assessments.forget(event.data.assessmentId);
} else if (event.data.assessmentVersion > (await assessments.version(event.data.assessmentId))) {
await queue.push({ assessment: event.data }); // run decide() in a worker
}
await events.markSeen(event.id);
return new NextResponse(null, { status: 200 });
}

A failed delivery, a non-2xx answer or no answer within 5 seconds is retried after 30, 60, 120, 240, 480, 960, 1,920 and 3,600 seconds, each plus or minus 20 percent, for nine attempts in all. After that the event is dropped and polling GET /v1/assessments/{assessment_id} stays authoritative. An endpoint whose deliveries have all failed for 72 hours in a row is disabled and you are told. Re-enabling it repeats the verification.

When we rotate an endpoint’s secret you receive a new key id and secret. The old key id keeps verifying for 7 days. Hold both in the map you pass to the verifier, keyed by key id, and NR-Webhook-Key-Id tells the verifier which one signed each delivery.

Check a hand-written verifier against this vector. The secret is a published test value, never a real key. The body is the compact JSON below, exactly as written, with no trailing newline.

Input Value
Secret, as UTF-8 nr-test-vector-secret-not-a-real-key
NR-Webhook-Key-Id whk_testvector01
NR-Webhook-Timestamp 1791968402
NR-Webhook-Id evt_01JA2ZB4N5P6Q7R8S9T0VWXYZ1
Expected NR-Webhook-Signature v1=b7f16475acf02e6fd8055046be174b434b3fdc508c2d0a64fae0f7d59503827d
webhook-assessment-updated.json
{"id":"evt_01JA2ZB4N5P6Q7R8S9T0VWXYZ1","type":"assessment.updated","created_at":"2026-10-14T09:00:02.000Z","data":{"schema_version":"1","assessment_id":"asm_01JA2Z8K6Q4V7W9XBCDEFGHJKM","account_id":"acct_7f3a9c","request_id":"req-2026-10-14-0001","status":"complete","recommendation":"step_up","confidence":"medium","risk":{"score":50,"band":"medium","recommendation":"step_up","confidence":"medium","policy_version":"risk-2026-10.1","policy_basis":"heuristic_unvalidated","reasons":[{"code":"no_digital_footprint","weight":35},{"code":"new_email","weight":15,"identifier":"email"}],"summary":{"phone":{"line_type":"mobile","country":"US"},"email":{"disposable":false,"accounts_found":0,"breach":{"found":false}}},"coverage":{"answered":4,"failed":0,"pending":0}},"incomplete_sources":[],"policy_version":"heuristic-2026-10.1","policy_basis":"heuristic_unvalidated","assessment_version":2,"created_at":"2026-10-14T09:00:00.000Z","updated_at":"2026-10-14T09:00:02.000Z","expires_at":"2027-10-14T09:00:00.000Z","timing":{"deadline_ms":300,"elapsed_ms":2000}}}

The signing input is the timestamp, a full stop, then the body: 1791968402.{"id":"evt_01JA2ZB4N5P6Q7R8S9T0VWXYZ1",...}. The vector’s key id is shorter than the ids we issue, so pass it to a verifier that accepts any whk_ id, or compute the HMAC directly.