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.
Give the hook a Name (for example "Scanner alerts") and choose How callers prove themselves: Signed (Standard Webhooks) or Bearer secret.
Click Create. Copy the Address and the Secret from the dialog: the secret is shown only this once.
Choose the hook as the trigger of the workflow it should start, and publish the workflow. Messages that arrive before then are accepted but start nothing.

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

ModeSecret looks likeThe caller sendsUse it when
Signed (Standard Webhooks)whsec_<base64>webhook-id, webhook-timestamp and webhook-signature headers over the exact bodyThe caller can sign, or already sends Standard Webhooks. The body cannot be altered and an old request cannot be replayed.
Bearer secretnhk_<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.

StatusBodyMeaningWhat 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 JSONFix the body
401{"error": "unauthorized"}Missing or wrong signature or bearer secret, or a timestamp more than five minutes offCheck the secret (replaced since?), the clock, and that you sign the exact body
404{"error": "not_found"}No hook at this addressCheck the address
413{"error": "payload_too_large"}The body is over 64 KBSend ids and short fields, not whole documents
415{"error": "unsupported_media_type"}Content-Type is not application/jsonSet the header
429{"error": "rate_limited"}More than 60 messages in a minute to this hookBack 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:

ValueHolds
trigger.data.payloadThe JSON body you posted, as is
trigger.data.hookNameThe hook's name
trigger.id, trigger.timeThe 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:

ActionWhat happens
RenameWorkflows keep using it; the address and secret stay the same. New messages carry the new name as trigger.data.hookName.
Replace the secretA 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 offCallers get 404 not_found and nothing is recorded, so the workflows on it stop starting. Turn on reverses it.
DeleteThe 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.

Last updated on