Guide
Receive webhooks
Nolmo tells your system when something happens to a case, with a signed request you can check came from Nolmo and was not changed on the way.
Last updated 20 October 2026
Make an endpoint
An admin can add one in Settings → Webhooks, or a key with the scope webhooks:manage can make it. The address must be https and public; redirects are not followed. The answer holds the signing secret, whsec_…, shown only once. An endpoint hears only the cases in the groups it was given.
curl https://api.nolmo.no/v1/webhook-endpoints \
-H "Authorization: Bearer $NOLMO_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Returns page",
"url": "https://returns.example.com/hooks/nolmo",
"events": ["case.created", "case.updated", "case.message.created"],
"all_groups": true
}'
# the answer holds "secret": "whsec_…", shown only hereThe events
case.created: a case was made.case.updated: status, assignee, priority or type changed;data.changessays what.case.message.created: a reply the customer sees, theirs, the team's or the AI's.case.note.created: an internal note. Only an endpoint that asks for it hears it.
{
"id": "0192f3a4-5555-7e2d-a3f4-5b6c7d8e9f01",
"type": "case.message.created",
"created_at": "2026-11-10T08:20:41Z",
"data": { "case": { "number": 1042, "subject": "…", "status": "open", … }, "message": { "kind": "public", … } }
}Each delivery is a POST with three headers: webhook-id, the event's id and the same on every retry, so dedupe by it; webhook-timestamp, in seconds; and webhook-signature. Ignore the fields and the events you do not know: more will come.
Check the signature
The signature is Standard Webhooks: HMAC-SHA256 over id.timestamp.body with the secret's bytes (the base64 after whsec_), as v1, and base64. While a secret is being rotated there are two, space separated, and either is good. Check the raw body, the bytes as they arrived. A request more than five minutes from your clock is refused, which is what makes a captured request useless later. If you already use a Standard Webhooks library, it works as it is.
import base64, hashlib, hmac, time
def verify(secret: str, headers: dict, body: bytes, tolerance: int = 300) -> bool:
"""True when `body` was signed with `secret` (the whsec_ value) and is fresh."""
msg_id = headers["webhook-id"]
timestamp = headers["webhook-timestamp"]
if abs(time.time() - int(timestamp)) > tolerance:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
return any(
hmac.compare_digest(expected, part.removeprefix("v1,"))
for part in headers["webhook-signature"].split()
if part.startswith("v1,")
)import json
from fastapi import FastAPI, HTTPException, Request
app = FastAPI()
@app.post("/hooks/nolmo")
async def nolmo(request: Request):
body = await request.body() # the raw bytes, before any JSON parsing
if not verify(SECRET, dict(request.headers), body):
raise HTTPException(status_code=400)
event = json.loads(body)
... # do the work, or queue it; then answer 2xx quicklyimport { createHmac, timingSafeEqual } from "node:crypto";
/**
* True when `body` was signed with `secret` (the whsec_ value) and is fresh.
* `body` is the raw request body, exactly as it arrived; `headers` has lower-case names.
*/
export function verify(
secret: string,
headers: Record<string, string>,
body: Buffer | string,
toleranceSeconds = 300,
): boolean {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
if (!id || !timestamp || !signatures) return false;
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!(age <= toleranceSeconds)) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.`).update(body).digest();
return signatures.split(" ").some((part) => {
if (!part.startsWith("v1,")) return false;
const given = Buffer.from(part.slice(3), "base64");
return given.length === expected.length && timingSafeEqual(given, expected);
});
}import express from "express";
import { verify } from "./verify";
const app = express();
app.post("/hooks/nolmo", express.raw({ type: "application/json" }), (req, res) => {
// req.body is the raw Buffer, before any JSON parsing
const headers = req.headers as Record<string, string>;
if (!verify(process.env.NOLMO_WEBHOOK_SECRET!, headers, req.body)) return res.sendStatus(400);
const event = JSON.parse(req.body.toString());
// do the work, or queue it; then answer 2xx quickly
res.sendStatus(200);
});Both are tested against a delivery signed by Nolmo's own code, in the same repository that sends it.
Answer, and what happens when you do not
Answer with any 2xx within ten seconds, and do the work after, from a queue. Anything else, or no answer, is tried again with a growing pause for 24 hours; the events of one case arrive in order. An endpoint that answers nothing for three days is turned off and the workspace's admins are told.
Rotate, resend and replay
POST /v1/webhook-endpoints/{id}/rotate-secretmakes a new secret. The old one keeps signing for 24 hours beside it, so you can change over without a gap.…/deliveriesis the log of the last 30 days, and…/deliveries/{id}/resendsends one event again with the samewebhook-id.…/replaysends everything since a time again, for a receiver that was down longer than 24 hours.
Next
Every route and the exact shape of each event is in the reference.