Inbound hooks
Let another system start a Noru workflow by posting JSON to a hook address: authentication, signing, responses, duplicates and limits.
An inbound hook is an address on api.noru.tech that another system (a
scanner, a ticketing tool, a CI job, your own service) posts JSON to. Each
accepted message becomes an inbound_hook.received signal and starts the
workflow whose trigger is When another system calls Noru with that hook.
It is the opposite direction of webhooks, which
Noru sends to you.
Create a hook
Admins create hooks in either of two places:
- SettingsWorkflowsInbound hooks, with New hook.
- In a workflow, from its trigger: set Starts to When another system calls Noru, then click New hook next to Hook. The new hook is chosen for that workflow straight away.
The address looks like https://api.noru.tech/v1/hooks/<hook id>. It carries
no secret, so it is safe to put in configuration that is not secret. The
caller proves itself with the secret on every request.
Choose how callers prove themselves
| Mode | Secret looks like | The caller sends | Use it when |
|---|---|---|---|
| Signed (Standard Webhooks) | whsec_<base64> | webhook-id, webhook-timestamp and webhook-signature headers over the exact body | The caller can sign, or already sends Standard Webhooks. The body cannot be altered and an old request cannot be replayed. |
| Bearer secret | nhk_<random> | Authorization: Bearer <secret> | The caller can only set a header, for example a SaaS tool's "custom webhook" with a static auth header |
Signed is the default and the safer choice. A bearer secret proves who sent the request but not that the body was not changed in transit by something that can already read it.
Send a message
Every request is a POST with Content-Type: application/json and a JSON body
of at most 64 KB. The body can be any JSON value; the workflow reads it as
trigger.data.payload.
import crypto from "node:crypto"
const HOOK_URL = process.env.NORU_HOOK_URL // https://api.noru.tech/v1/hooks/...
const SECRET = process.env.NORU_HOOK_SECRET // "whsec_..."
export async function sendToNoru(event, messageId = crypto.randomUUID()) {
const body = JSON.stringify(event)
const timestamp = Math.floor(Date.now() / 1000).toString()
const key = Buffer.from(SECRET.replace(/^whsec_/, ""), "base64")
const signature = crypto
.createHmac("sha256", key)
.update(`${messageId}.${timestamp}.${body}`)
.digest("base64")
const response = await fetch(HOOK_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"webhook-id": messageId, // reuse it when you retry the same message
"webhook-timestamp": timestamp,
"webhook-signature": `v1,${signature}`,
},
body, // send exactly the string you signed
})
if (response.status !== 202) {
throw new Error(`Noru refused the message: ${response.status}`)
}
return await response.json() // { signal_id, duplicate }
}Sign exactly what you send
The signature covers the body byte for byte. Serialize the JSON once, sign
that string, and send the same string. Letting an HTTP client re-encode an
object after you signed it is the usual cause of a 401.
A Standard Webhooks library can produce the headers for you, for example
new Webhook(secret).sign(messageId, new Date(), body) with the
standardwebhooks package. The webhook-timestamp must be within five
minutes of Noru's clock.
Responses
Answers are JSON. Refusals carry a short code and nothing else, so a caller without the secret learns nothing about the hook.
| Status | Body | Meaning | What to do |
|---|---|---|---|
202 | {"signal_id": "...", "duplicate": false} | Accepted. A signal was recorded and any published workflow on this hook starts. | Nothing |
202 | {"signal_id": null, "duplicate": true} | Accepted before: same webhook-id (signed) or Idempotency-Key (bearer). Nothing new was recorded. | Nothing; this is how retries are made safe |
400 | {"error": "invalid_json"} | The body is not valid JSON | Fix the body |
401 | {"error": "unauthorized"} | Missing or wrong signature or bearer secret, or a timestamp more than five minutes off | Check the secret (replaced since?), the clock, and that you sign the exact body |
404 | {"error": "not_found"} | No hook at this address | Check the address |
413 | {"error": "payload_too_large"} | The body is over 64 KB | Send ids and short fields, not whole documents |
415 | {"error": "unsupported_media_type"} | Content-Type is not application/json | Set the header |
429 | {"error": "rate_limited"} | More than 60 messages in a minute to this hook | Back off and retry with the same id |
Retries and duplicates
Retry on a network error, a 429 or a 5xx, with the same webhook-id (or
Idempotency-Key for a bearer hook). Noru records a message with a repeated
id only once, so a retry never starts a second run. Do not retry 400, 401,
404, 413 or 415 unchanged: they will fail the same way.
Without an Idempotency-Key, every bearer message is recorded, even an
identical one.
Use the payload in a workflow
In the workflow, the message is available as:
| Value | Holds |
|---|---|
trigger.data.payload | The JSON body you posted, as is |
trigger.data.hookName | The hook's name |
trigger.id, trigger.time | The signal id and when it arrived |
For the curl example above, {{ trigger.data.payload.severity }} is high.
Use it in an Only when condition (start only for high alerts) or in a
step, for example Turn another system's alerts into security findings, a
template in the workflow gallery. See
Use values from the run.
The run page shows what each step did with these values. The full message is in
the run's export and in GET /v1/workflow-runs/{id}.
Manage hooks
SettingsWorkflowsInbound hooks lists every hook with its address (with a copy button), how callers prove themselves, whether it is on, when it last accepted a message, and the workflows that start on it. Only admins can open it. From a hook's … menu:
| Action | What happens |
|---|---|
| Rename | Workflows keep using it; the address and secret stay the same. New messages carry the new name as trigger.data.hookName. |
| Replace the secret | A new secret, shown once. The old one stops working at once, so update the caller straight away. The address stays the same. You can also do this from the workflow's trigger. |
| Turn off | Callers get 404 not_found and nothing is recorded, so the workflows on it stop starting. Turn on reverses it. |
| Delete | The address stops working for good. Workflows that used it will not start, and cannot be published, until they are given another hook. Cannot be undone. |
See what callers sent
Recent messages on a hook shows the last 50 messages it accepted, newest
first: when each arrived, its signal id (the signal_id in the 202 answer),
the exact JSON the caller posted, and the runs it started with their status
and a link to each. Messages are kept for 180 days.
A message that started no run was accepted, but no published workflow on the
hook took it, or its Only when conditions did not hold. Refused requests
(401, 413, 415 …) are not recorded; the caller sees the refusal code.
What Noru does not do
- It does not accept anything but JSON, and nothing larger than 64 KB.
- It does not show refused requests: only the caller sees why a request was refused.
- A hook belongs to the workflows that choose it. Messages to a hook no published workflow uses are accepted and start nothing.
Related
Last updated on
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.
MCP server
Connect AI clients to Noru over MCP with OAuth or API keys, and understand which tools each scope and role unlocks.