Webhooks
Receive signed JSON from Noru when risks, vendors, policies and other records change: the request format, how to verify the signature, retries, and how to debug a delivery.
A webhook endpoint is an https address of yours that Noru calls when
something happens in your organization: a risk is created, a policy is
approved, a supplier reports a breach. Each call is a POST with a JSON body
describing one event, called a signal, and a signature you check with the
endpoint's secret. Deliveries follow the open
Standard Webhooks specification, so any
Standard Webhooks library can verify them.
This page is the technical contract. For the settings screens, see Webhook settings.
How delivery works
2xx quickly and does the work afterwards.| Fact | Value |
|---|---|
| Method | POST, body application/json (UTF-8) |
| Address | Public https only. Private, loopback and link-local addresses are refused. |
| Redirects | Not followed. A 3xx answer fails the attempt. |
| Success | Any 2xx status within 10 seconds. The response body is ignored, but its start is kept in the delivery log. |
| Retries | After 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours and 24 hours, then the delivery is marked failed |
| Endpoint turned off | After 10 failed attempts in a row. Turning it back on clears the count. |
| Endpoints per organization | 10 |
| Ordering | Not guaranteed. Use the time field, not arrival order. |
The request
Every attempt carries these headers:
| Header | Example | Meaning |
|---|---|---|
webhook-id | msg_V1StGXR8_Z5jdHi6B-myT | The delivery id. The same on every attempt and on a manual redelivery, so use it to drop duplicates. |
webhook-timestamp | 1773144000 | When this attempt was sent, in unix seconds. New on every attempt. |
webhook-signature | v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4= | One or more space-separated v1,<base64> signatures. See Verify the signature. |
Content-Type | application/json | Always JSON. |
User-Agent | Noru-Webhooks/2.0 | Identifies Noru. Do not use it to authenticate: anyone can send it. |
Payload format
The body is one signal:
{
"id": "NORU-SIG-1042",
"type": "risk.updated",
"time": "2026-03-10T12:00:00.000Z",
"organizationId": "org_2abc123def456",
"subject": { "type": "risk", "id": "NORU-RISK-12" },
"data": {
"record": {
"id": "NORU-RISK-12",
"title": "Phishing leads to account takeover",
"status": "mitigating",
"riskLevel": "high",
"ownerId": "user_2abc",
"dueDate": "2026-06-30T00:00:00.000Z"
},
"changes": {
"status": { "from": "assessing", "to": "mitigating" }
}
}
}| Field | Type | Meaning |
|---|---|---|
id | string | The signal id. Every endpoint that receives this signal sees the same id. |
type | string | The signal type, <subject>.<event>. Route on this. |
time | ISO 8601 string | When it happened, in UTC. |
organizationId | string | The organization it happened in. |
subject | object | The record the signal is about: type and id. |
data | object | What happened. Its shape depends on type. |
For the record signals (*.created, *.updated, *.deleted), data.record
is the record as it is after the change (or just before it was deleted), and
*.updated adds data.changes: one entry per field that changed, with from
and to. Other signals carry their own fields. The
API reference lists the exact schema and an example
for every type under Webhooks, and the
OpenAPI document carries them in its
webhooks section if you want to generate types.
New fields can appear
Noru may add fields to data, and new signal types, at any time. Ignore
fields and types you do not know rather than rejecting the delivery.
Verify the signature
Verify every delivery before you trust its body. Check three things:
- The signature. Compute HMAC-SHA256 over the exact string
<webhook-id>.<webhook-timestamp>.<raw body>, keyed with the endpoint's secret, and compare it with eachv1,entry inwebhook-signature. - The key. The secret looks like
whsec_<base64>. The key is the part afterwhsec_, base64-decoded to 32 bytes. Using the text of the secret as the key is the most common mistake. - The timestamp. Refuse a
webhook-timestampmore than five minutes from your clock, so a captured request cannot be replayed later.
Sign the raw body
Compute the signature over the body bytes exactly as they arrived. If your
framework parses the JSON first and you re-serialize it, key order and
whitespace change and the signature no longer matches. In Express use
express.raw({ type: "application/json" }) on the webhook route; in
Flask use request.get_data().
With a Standard Webhooks library
The quickest correct option. Libraries exist for most languages; they accept
the whsec_ secret as Noru shows it, check the timestamp, and compare in
constant time.
npm install standardwebhooks expressimport express from "express"
import { Webhook } from "standardwebhooks"
const webhook = new Webhook(process.env.NORU_WEBHOOK_SECRET) // "whsec_..."
const app = express()
app.post(
"/noru/webhooks",
express.raw({ type: "application/json" }),
async (req, res) => {
let event
try {
event = webhook.verify(req.body.toString("utf8"), req.headers)
} catch {
return res.status(401).send("invalid signature")
}
if (await alreadyProcessed(req.headers["webhook-id"])) {
return res.sendStatus(200) // a retry of something you already handled
}
await enqueue(event) // do the work after answering
res.sendStatus(204)
}
)
app.listen(3000)Without a library
The same check in a few lines, if you would rather not add a dependency.
import crypto from "node:crypto"
const TOLERANCE_SECONDS = 5 * 60
/** Returns the parsed event, or throws when the delivery is not from Noru. */
export function verifyNoruWebhook(rawBody, headers, secret) {
const id = headers["webhook-id"]
const timestamp = headers["webhook-timestamp"]
const signatures = headers["webhook-signature"]
if (!id || !timestamp || !signatures) {
throw new Error("Missing webhook headers")
}
const now = Math.floor(Date.now() / 1000)
if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) {
throw new Error("Timestamp too old or too new")
}
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64")
const expected = crypto
.createHmac("sha256", key)
.update(`${id}.${timestamp}.${rawBody}`)
.digest("base64")
const matches = signatures.split(" ").some((entry) => {
const [version, signature] = entry.split(",")
return (
version === "v1" &&
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
)
})
if (!matches) {
throw new Error("Invalid signature")
}
return JSON.parse(rawBody)
}Check one delivery by hand
To see whether your secret is right, recompute the signature of a delivery
you received (or one from the delivery log, see
Debug a failing endpoint) in a shell. Save the
raw body to body.json exactly as received, then:
SECRET='whsec_...' # the endpoint's secret
ID='msg_...' # the webhook-id header
TS='1773144000' # the webhook-timestamp header
KEY_HEX=$(printf '%s' "${SECRET#whsec_}" | base64 -d | od -An -v -tx1 | tr -d ' \n')
printf '%s.%s.' "$ID" "$TS" | cat - body.json \
| openssl dgst -sha256 -mac HMAC -macopt "hexkey:$KEY_HEX" -binary | base64The output must equal the part after v1, in the webhook-signature header.
Respond quickly and handle retries
- Answer within 10 seconds with any
2xx. Put slow work on a queue and answer first; a timeout counts as a failure even if your work later succeeds. - Any other answer fails the attempt: a
4xxor5xx, a redirect, a timeout, a refused connection or a TLS error. Noru tries again after 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours and 24 hours: seven attempts over about a day and a half. Then the delivery is marked Failed, and you can still send it again by hand. - Ten failed attempts in a row turn the endpoint off, whichever deliveries they belong to. Pending retries for it are not sent. Fix the receiver, then turn the endpoint back on in settings; that clears the count. Signals that happen while an endpoint is off are not delivered to it later. Deliveries that were waiting for a retry show as Not sent, and you can redeliver them one by one.
- Answer
2xxto signal types you do not handle. Ignoring is a success; failing would only cause retries and could turn the endpoint off.
Drop duplicates
A delivery can arrive more than once: when your 2xx was lost on the way
back, when Noru retries after a timeout, or when someone redelivers it.
webhook-id is the same each time, so record it (for a day or two is
enough) and skip a delivery whose id you have already processed. The body's
id is the signal id: two endpoints receiving the same signal see the same
id but different webhook-ids.
Test your receiver
Use Send test delivery in the endpoint's menu under
SettingsWebhooks, or POST /v1/webhook-endpoints/{id}/test.
Noru sends a signed delivery of type webhook.test right away, whatever signal
types the endpoint subscribes to, and shows what your endpoint answered:
{
"id": "test_x8Kq2mV0uZsd1Yh3",
"type": "webhook.test",
"time": "2026-10-18T09:30:00.000Z",
"organizationId": "org_2abc123def456",
"subject": { "type": "webhook_endpoint", "id": "NORU-WHK-3" },
"data": {
"message": "A test delivery from Noru. If your receiver verified its signature, it is ready."
}
}A test delivery is signed, logged and retried exactly like a real one, and a
failed one counts towards turning the endpoint off. Answer 2xx to
webhook.test once your signature check passes.
Developing locally
Noru only calls public https addresses. While you build a receiver on your laptop, expose it through a tunnelling service and register the tunnel's https address as a second endpoint, then delete it when you are done.
Debug a failing endpoint
Every delivery and every attempt is kept in the delivery log under SettingsWebhooksDeliveries. Click a row to open it. For a delivery you see:
- The request: the endpoint address, the headers (
webhook-id,webhook-timestampper attempt,Content-Type,User-Agent) and the exact JSON body that was signed. - Every attempt, newest first: when it was sent, the HTTP status your endpoint answered, how long it took, and the first 4 KB of its response body. Return a short error message in your response body and you will see it here.
The same data is available from GET /v1/webhook-deliveries/{id} and the
getWebhookDelivery MCP tool. When an attempt failed, the log names why:
| What the log says | What happened | What to check |
|---|---|---|
| Answered 401 or 403 | Your receiver refused it, usually at the signature check | The secret (rotated since?), the base64-decoded key, and that you sign the raw body |
| Answered 400 or 422 | Your receiver could not use the body | That you ignore unknown fields and unknown types |
| Answered 404 or 405 | Nothing handles POST at that path | The path, and that the route accepts POST |
| Answered 5xx | Your receiver failed | Your own logs, around the attempt's time |
| Answered with a redirect | The address moved (for example http to https, or a trailing slash) | Register the final address; redirects are not followed |
| No answer within 10 seconds | The receiver was too slow | Answer first, then do the work |
| Could not connect | DNS, TLS or the connection failed | The certificate (it must be valid for the host), firewalls, the receiver being up |
| Address is not a public https address | The host resolves to a private or reserved address | Use a public address |
Fix the cause, then click Redeliver on the delivery. It is sent again now,
with the same webhook-id.
Rotate the secret
Rotate secret in the endpoint's menu creates a new secret and shows it once. The old secret stops working at once, so deploy the new one to your receiver straight away; deliveries in between fail the signature check and are retried. During a planned switch you can have the receiver accept either secret for a few minutes.
If a secret leaks, rotate it. Nothing else is needed: the endpoint address is not a secret.
Messages from workflows
A workflow's Send a webhook step posts to one of these endpoints, signed with its secret, with the same headers. Its body is the step's message rather than a signal:
{
"id": "NORU-RUN-88:notify_siem",
"type": "workflow.webhook",
"time": "2026-10-18T09:30:00.000Z",
"organizationId": "org_2abc123def456",
"workflow": { "id": "NORU-WF-4", "name": "Escalate critical risks" },
"run": { "id": "NORU-RUN-88" },
"data": { "riskId": "NORU-RISK-12", "title": "Phishing leads to account takeover" }
}typeis the step's Event name (workflow.webhookunless you change it).datais the step's Data, with its{{ }}values filled in from the run.webhook-idandidare both<run id>:<step id>, the same on each attempt.- The workflow retries the step itself, a few times within minutes, and then the run fails. These messages appear in the delivery log with a link to the run.
See Build a workflow.
Signal types
An endpoint receives only the types it subscribes to. Types marked Supplier risk exist only in organizations with supplier risk management.
| Type | When it is sent |
|---|---|
risk.created | A risk was added to the register. |
risk.updated | A risk's title, status, category, rating, owner, due date or acceptance expiry changed. |
risk.deleted | A risk was removed from the register. |
risk_treatment.created | A treatment was added to a risk. |
risk_treatment.updated | A risk treatment's action, status, rating, owner or due date changed. |
risk_treatment.deleted | A treatment was removed from a risk. |
vendor.created | A vendor was added, by a person, an import or discovery. |
vendor.updated | A vendor's name, status, lifecycle stage, category, owner or risk rating changed. |
vendor.deleted | A vendor was removed. |
control.updated | A control's implementation status, priority, risk level or owner changed. |
evidence.created | Evidence was added, by upload or by an integration. |
evidence.updated | Evidence's title, status, expiry date or owner changed. |
evidence.deleted | Evidence was removed. |
policy.created | A policy was added, written, generated or imported. |
policy.updated | A policy's name, status, version, owner, approver or review date changed. |
policy.deleted | A policy was removed. |
security_finding.created | A security finding was raised, by a person, a scan or a supplier review. |
security_finding.updated | A security finding's title, status, severity, owner, due date or linked risk changed. |
security_finding.deleted | A security finding was removed. |
inbound_hook.received | Another system posted a message to one of your inbound hooks. |
workflow.emitted | A workflow step raised a named signal for other workflows. |
workflow.run_failed | A workflow run stopped on a step that failed. |
vendor.tier_changed | Supplier risk. A supplier's tier changed. |
assessment.closed | Supplier risk. A supplier review was closed with an outcome. |
security_finding.supplier_replied | Supplier risk. A supplier replied to a request to fix a finding, or said it is fixed. |
register_run.approved | Supplier risk. A register run was approved for filing. |
vendor.breach_detected | Supplier risk. A public feed or partner reported a breach or security incident at a supplier. |
vendor.document_expiring | Supplier risk. A supplier document expires within 30 days or has expired. |
vendor.subprocessors_changed | Supplier risk. A supplier's published sub-processor list changed. |
vendor.dpf_status_changed | Supplier risk. A supplier's EU-U.S. Data Privacy Framework certification changed status. |
vendor.contract_notice_due | Supplier risk. A supplier contract's notice date falls within the next 60 days. |
GET /v1/webhook-endpoints also returns the list of types your organization
can subscribe to, with a label and description for each.
Manage endpoints with the API
Everything in the settings tab is also available over the
REST API with an API key holding read:webhooks
or write:webhooks, and as MCP tools with the same names:
| Call | Scope | What it does |
|---|---|---|
GET /v1/webhook-endpoints | read:webhooks | Endpoints, without secrets, and the signal types you can subscribe to |
POST /v1/webhook-endpoints | write:webhooks | Add an endpoint. The response holds the secret, shown only then. |
PATCH /v1/webhook-endpoints/{id} | write:webhooks | Change the address, description or signal types, or turn it off or on |
POST /v1/webhook-endpoints/{id}/rotate-secret | write:webhooks | New secret, shown only in the response |
POST /v1/webhook-endpoints/{id}/test | write:webhooks | Send a test delivery now and return it with its first attempt |
DELETE /v1/webhook-endpoints/{id} | write:webhooks | Delete the endpoint and its delivery log |
GET /v1/webhook-deliveries | read:webhooks | The delivery log, newest first. Filter by endpointId and status. |
GET /v1/webhook-deliveries/{id} | read:webhooks | One delivery with its body, headers and every attempt |
POST /v1/webhook-deliveries/{id}/redeliver | write:webhooks | Send a finished delivery again |
What Noru does not do
- It does not send to
httpaddresses, private networks, or addresses that redirect. - It does not sign with anything but the endpoint's secret. There are no fixed source IP addresses to allow-list; verify the signature instead.
- It does not batch: one delivery is one signal.
- It does not guarantee order, and it does not keep sending to an endpoint it turned off.
- It does not store your full response: only its status and its first 4 KB.
Related
- Webhook settings: add endpoints and read the delivery log in the app
- Inbound hooks: the other direction, other systems calling Noru
- Workflows
- API keys and REST API
Last updated on