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

An admin adds an endpoint under SettingsWebhooks and picks the signal types it receives. Noru shows the endpoint's signing secret once.
When a subscribed signal happens, Noru records one delivery per endpoint and sends it within seconds.
Your receiver checks the signature, stores the delivery id, answers 2xx quickly and does the work afterwards.
If the attempt fails, Noru tries again on a fixed schedule with the same delivery id. Every attempt, with what your endpoint answered, is kept in the delivery log.
FactValue
MethodPOST, body application/json (UTF-8)
AddressPublic https only. Private, loopback and link-local addresses are refused.
RedirectsNot followed. A 3xx answer fails the attempt.
SuccessAny 2xx status within 10 seconds. The response body is ignored, but its start is kept in the delivery log.
RetriesAfter 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours and 24 hours, then the delivery is marked failed
Endpoint turned offAfter 10 failed attempts in a row. Turning it back on clears the count.
Endpoints per organization10
OrderingNot guaranteed. Use the time field, not arrival order.

The request

Every attempt carries these headers:

HeaderExampleMeaning
webhook-idmsg_V1StGXR8_Z5jdHi6B-myTThe delivery id. The same on every attempt and on a manual redelivery, so use it to drop duplicates.
webhook-timestamp1773144000When this attempt was sent, in unix seconds. New on every attempt.
webhook-signaturev1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=One or more space-separated v1,<base64> signatures. See Verify the signature.
Content-Typeapplication/jsonAlways JSON.
User-AgentNoru-Webhooks/2.0Identifies 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" }
    }
  }
}
FieldTypeMeaning
idstringThe signal id. Every endpoint that receives this signal sees the same id.
typestringThe signal type, <subject>.<event>. Route on this.
timeISO 8601 stringWhen it happened, in UTC.
organizationIdstringThe organization it happened in.
subjectobjectThe record the signal is about: type and id.
dataobjectWhat 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:

  1. 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 each v1, entry in webhook-signature.
  2. The key. The secret looks like whsec_<base64>. The key is the part after whsec_, base64-decoded to 32 bytes. Using the text of the secret as the key is the most common mistake.
  3. The timestamp. Refuse a webhook-timestamp more 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 express
import 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 | base64

The 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 4xx or 5xx, 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 2xx to 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-timestamp per 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 saysWhat happenedWhat to check
Answered 401 or 403Your receiver refused it, usually at the signature checkThe secret (rotated since?), the base64-decoded key, and that you sign the raw body
Answered 400 or 422Your receiver could not use the bodyThat you ignore unknown fields and unknown types
Answered 404 or 405Nothing handles POST at that pathThe path, and that the route accepts POST
Answered 5xxYour receiver failedYour own logs, around the attempt's time
Answered with a redirectThe address moved (for example http to https, or a trailing slash)Register the final address; redirects are not followed
No answer within 10 secondsThe receiver was too slowAnswer first, then do the work
Could not connectDNS, TLS or the connection failedThe certificate (it must be valid for the host), firewalls, the receiver being up
Address is not a public https addressThe host resolves to a private or reserved addressUse 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" }
}
  • type is the step's Event name (workflow.webhook unless you change it).
  • data is the step's Data, with its {{ }} values filled in from the run.
  • webhook-id and id are 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.

TypeWhen it is sent
risk.createdA risk was added to the register.
risk.updatedA risk's title, status, category, rating, owner, due date or acceptance expiry changed.
risk.deletedA risk was removed from the register.
risk_treatment.createdA treatment was added to a risk.
risk_treatment.updatedA risk treatment's action, status, rating, owner or due date changed.
risk_treatment.deletedA treatment was removed from a risk.
vendor.createdA vendor was added, by a person, an import or discovery.
vendor.updatedA vendor's name, status, lifecycle stage, category, owner or risk rating changed.
vendor.deletedA vendor was removed.
control.updatedA control's implementation status, priority, risk level or owner changed.
evidence.createdEvidence was added, by upload or by an integration.
evidence.updatedEvidence's title, status, expiry date or owner changed.
evidence.deletedEvidence was removed.
policy.createdA policy was added, written, generated or imported.
policy.updatedA policy's name, status, version, owner, approver or review date changed.
policy.deletedA policy was removed.
security_finding.createdA security finding was raised, by a person, a scan or a supplier review.
security_finding.updatedA security finding's title, status, severity, owner, due date or linked risk changed.
security_finding.deletedA security finding was removed.
inbound_hook.receivedAnother system posted a message to one of your inbound hooks.
workflow.emittedA workflow step raised a named signal for other workflows.
workflow.run_failedA workflow run stopped on a step that failed.
vendor.tier_changedSupplier risk. A supplier's tier changed.
assessment.closedSupplier risk. A supplier review was closed with an outcome.
security_finding.supplier_repliedSupplier risk. A supplier replied to a request to fix a finding, or said it is fixed.
register_run.approvedSupplier risk. A register run was approved for filing.
vendor.breach_detectedSupplier risk. A public feed or partner reported a breach or security incident at a supplier.
vendor.document_expiringSupplier risk. A supplier document expires within 30 days or has expired.
vendor.subprocessors_changedSupplier risk. A supplier's published sub-processor list changed.
vendor.dpf_status_changedSupplier risk. A supplier's EU-U.S. Data Privacy Framework certification changed status.
vendor.contract_notice_dueSupplier 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:

CallScopeWhat it does
GET /v1/webhook-endpointsread:webhooksEndpoints, without secrets, and the signal types you can subscribe to
POST /v1/webhook-endpointswrite:webhooksAdd an endpoint. The response holds the secret, shown only then.
PATCH /v1/webhook-endpoints/{id}write:webhooksChange the address, description or signal types, or turn it off or on
POST /v1/webhook-endpoints/{id}/rotate-secretwrite:webhooksNew secret, shown only in the response
POST /v1/webhook-endpoints/{id}/testwrite:webhooksSend a test delivery now and return it with its first attempt
DELETE /v1/webhook-endpoints/{id}write:webhooksDelete the endpoint and its delivery log
GET /v1/webhook-deliveriesread:webhooksThe delivery log, newest first. Filter by endpointId and status.
GET /v1/webhook-deliveries/{id}read:webhooksOne delivery with its body, headers and every attempt
POST /v1/webhook-deliveries/{id}/redeliverwrite:webhooksSend a finished delivery again

What Noru does not do

  • It does not send to http addresses, 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.

Last updated on