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.
Register an endpoint
Section titled “Register an endpoint”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.
The event envelope
Section titled “The event envelope”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 the signature
Section titled “Verify the signature”Verify every delivery before you read it, in this order, and answer 400 if any check fails.
- Each of the four headers is present exactly once. The same name in another letter case counts as a repeat.
- The timestamp is within 300 seconds of your clock.
- The key id names a secret you hold.
- The signature matches, compared in constant time, over the raw bytes you received.
- The body’s
idequalsNR-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.
Answer endpoint.verification
Section titled “Answer endpoint.verification”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.
Handle duplicates, versions and expiry
Section titled “Handle duplicates, versions and expiry”Delivery is at least once and unordered. Three rules keep your state right.
- Deduplicate on
id. A redelivery carries the sameid, and a receiver that has already processed it still answers 2xx. - Keep the highest
assessment_version. Apply anassessment.updatedevent only ifdata.assessment_versionis higher than the version you hold for thatassessment_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.
A full receiver
Section titled “A full receiver”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:
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:
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 });}FastAPI:
import os
from fastapi import FastAPI, Request, Responsefrom fastapi.responses import JSONResponsefrom noresponse import Assessment, AssessmentExpired, Webhook, WebhookVerificationError
SECRETS = {os.environ["NORESPONSE_WEBHOOK_KEY_ID"]: os.environ["NORESPONSE_WEBHOOK_SECRET"]}app = FastAPI()
@app.post("/webhooks/noresponse")async def noresponse_webhook(request: Request) -> Response: raw_body = await request.body() try: event = Webhook.construct_event(raw_body, request.headers, SECRETS) except WebhookVerificationError: return Response(status_code=400) if event.type == "endpoint.verification": return JSONResponse(Webhook.verification_response(event)) if await events.seen(event.id): return Response(status_code=200) data = event.data if isinstance(data, AssessmentExpired): await assessments.forget(data.assessment_id) elif isinstance(data, Assessment) and data.assessment_version > await assessments.version(data.assessment_id): await queue.push(data) # run decide() in a worker await events.mark_seen(event.id) return Response(status_code=200)net/http:
package main
import ( "encoding/json" "io" "net/http" "os"
noresponse "github.com/noresponsehq/go/v2" "github.com/noresponsehq/go/v2/models/components")
var secrets = map[string]string{os.Getenv("NORESPONSE_WEBHOOK_KEY_ID"): os.Getenv("NORESPONSE_WEBHOOK_SECRET")}
func noresponseWebhook(w http.ResponseWriter, r *http.Request) { rawBody, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) if err != nil { w.WriteHeader(http.StatusBadRequest) return } event, err := noresponse.ConstructEvent(rawBody, r.Header, secrets) if err != nil { w.WriteHeader(http.StatusBadRequest) return } switch data := event.Data.(type) { case *noresponse.EndpointVerification: response, _ := noresponse.VerificationResponse(event) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(response) return case *noresponse.AssessmentExpired: assessments.Forget(data.AssessmentID) case *components.Assessment: switch { case events.Seen(event.ID): case data.AssessmentVersion > assessments.Version(data.AssessmentID): queue.Push(data) // run decide() in a worker } } events.MarkSeen(event.ID) w.WriteHeader(http.StatusOK)}There is no HTTP call to make from your side. To exercise a receiver before we register it, sign a delivery yourself with the test vector and send it:
curl https://app.example.com/webhooks/noresponse \ -H "Content-Type: application/json" \ -H "NR-Webhook-Id: evt_01JA2ZB4N5P6Q7R8S9T0VWXYZ1" \ -H "NR-Webhook-Timestamp: 1791968402" \ -H "NR-Webhook-Key-Id: whk_testvector01" \ -H "NR-Webhook-Signature: v1=b7f16475acf02e6fd8055046be174b434b3fdc508c2d0a64fae0f7d59503827d" \ --data-binary @webhook-assessment-updated.jsonYour receiver’s clock must be within 300 seconds of the vector’s timestamp for it to pass, so set the receiver’s tolerance high while you test with it, or sign a fresh timestamp with the same secret.
Delivery and retries
Section titled “Delivery and retries”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.
Rotate the signing secret
Section titled “Rotate the signing secret”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.
Test vector
Section titled “Test vector”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 |
{"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.