Webhooks
Get an HTTPS call from Nairi when an agent answers, a message fails, or a conversation ends, instead of polling the messages endpoint. Setup, event types, and signature verification in bash, TypeScript, Ruby, Python, and Go.
Instead of polling GET /messages/{message_id} every few seconds, give Nairi a URL once and we call it whenever something in one of your API conversations changes state. Polling keeps working; webhooks are an addition, not a replacement.
Webhooks cover conversations started through the API. Conversations started from Slack, Discord, or the dashboard never produce events.
Setting up
Webhooks are configured per organization by the Nairi team. Send your endpoint URL to support@nairi.ai from the account that owns the organization.
The URL must:
- use
httpsand a hostname (not an IP address); - carry no username, password, or
#fragment; - answer
POSTrequests with a2xxstatus within 10 seconds.
We reply with a signing secret that starts with whsec_. You see it once; we keep only an encrypted copy. Store it as NAIRI_WEBHOOK_SECRET next to your API key. To rotate the secret or turn webhooks off, write to the same address.
What you receive
Every event is one POST with three headers and a small JSON body. The body points at the message or conversation; fetch the content with the endpoints you already use.
POST /hooks/nairi HTTP/1.1
Content-Type: application/json
User-Agent: nairi-webhooks/1
webhook-id: whev_01JCBQ6V000000000000000000
webhook-timestamp: 1762424102
webhook-signature: v1,eWt2tMMm95VMPyhoS6Gqpkk/xJhL5xHvjRF2s07/nuE=
{"id":"whev_01JCBQ6V000000000000000000","type":"message.completed","created_at":"2026-11-06T10:15:02.123Z","data":{"job_id":"job_1","message_id":"cmsg_1"}}| Type | Sent when | data |
|---|---|---|
message.completed | the agent answered your message, or the message was stopped | job_id, message_id |
message.failed | the agent hit an error, or the message was still open when the conversation ended | job_id, message_id |
conversation.completed | the agent finished the conversation, or it was closed after 24 hours | job_id |
conversation.abandoned | the agent went silent and Nairi closed the conversation | job_id |
Type names follow the status the API returns: when you receive message.completed, GET /messages/{message_id} already says status: "completed". Nothing is sent for queued, pending, progress, or system messages.
Verifying the signature
Deliveries are signed with Standard Webhooks, so any Standard Webhooks library verifies them. The signed string is webhook-id, a dot, webhook-timestamp, a dot, and the raw request body. The HMAC-SHA256 key is the base64-decoded part of the secret after whsec_. The header holds one or more v1,<base64> signatures separated by spaces; accept the delivery if any of them matches.
Verify against the raw body bytes, before any JSON parsing or re-serialization, and reject timestamps more than five minutes from your clock.
# ID, TS and SIG hold the three webhook-* headers; BODY holds the raw request body.
KEY_HEX=$(printf '%s' "${NAIRI_WEBHOOK_SECRET#whsec_}" | base64 -d | xxd -p -c 256)
EXPECTED="v1,$(printf '%s.%s.%s' "$ID" "$TS" "$BODY" \
| openssl dgst -sha256 -mac HMAC -macopt hexkey:"$KEY_HEX" -binary | base64)"
NOW=$(date +%s)
if [ $((NOW > TS ? NOW - TS : TS - NOW)) -gt 300 ]; then echo "stale timestamp"; exit 1; fi
case " $SIG " in
*" $EXPECTED "*) echo "verified" ;;
*) echo "bad signature"; exit 1 ;;
esacimport { createHmac, timingSafeEqual } from "node:crypto";
const key = Buffer.from(
(process.env.NAIRI_WEBHOOK_SECRET ?? "").replace(/^whsec_/, ""),
"base64",
);
// headers: the request headers, lower-cased. rawBody: the exact bytes received.
export function verify(headers: Record<string, string | undefined>, rawBody: Buffer): boolean {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
if (!id || !timestamp || !signatures) return false;
const sentAt = Number(timestamp);
if (!Number.isFinite(sentAt) || Math.abs(Date.now() / 1000 - sentAt) > 300) return false;
const expected = createHmac("sha256", key)
.update(`${id}.${timestamp}.`)
.update(rawBody)
.digest();
return signatures.split(" ").some((signature) => {
const [version, value] = signature.split(",");
if (version !== "v1" || !value) return false;
const given = Buffer.from(value, "base64");
return given.length === expected.length && timingSafeEqual(given, expected);
});
}With Express, read the body with express.raw({ type: "application/json" }) so req.body is the untouched Buffer.
require "openssl"
require "base64"
KEY = Base64.decode64(ENV.fetch("NAIRI_WEBHOOK_SECRET").delete_prefix("whsec_"))
# headers: the request headers, lower-cased. raw_body: the exact bytes received.
def verify(headers, raw_body)
id, timestamp, signatures = headers.values_at("webhook-id", "webhook-timestamp", "webhook-signature")
return false if id.nil? || timestamp.nil? || signatures.nil?
return false if (Time.now.to_i - timestamp.to_i).abs > 300
expected = OpenSSL::HMAC.digest("SHA256", KEY, "#{id}.#{timestamp}.#{raw_body}")
signatures.split(" ").any? do |signature|
version, value = signature.split(",", 2)
version == "v1" && !value.nil? && secure_equal(Base64.decode64(value), expected)
end
end
def secure_equal(a, b)
return false unless a.bytesize == b.bytesize
a.bytes.zip(b.bytes).reduce(0) { |acc, (x, y)| acc | (x ^ y) }.zero?
endimport base64
import binascii
import hashlib
import hmac
import os
import time
KEY = base64.b64decode(os.environ["NAIRI_WEBHOOK_SECRET"].removeprefix("whsec_"))
# headers: the request headers, lower-cased. raw_body: the exact bytes received.
def verify(headers: dict[str, str], raw_body: bytes) -> bool:
event_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signatures = headers.get("webhook-signature")
if not event_id or not timestamp or not signatures:
return False
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
return False
expected = hmac.new(KEY, f"{event_id}.{timestamp}.".encode() + raw_body, hashlib.sha256).digest()
for signature in signatures.split(" "):
version, _, value = signature.partition(",")
if version != "v1" or not value:
continue
try:
given = base64.b64decode(value, validate=True)
except (ValueError, binascii.Error):
continue # a malformed signature is skipped, the next one may still match
if hmac.compare_digest(given, expected):
return True
return Falsepackage main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"net/http"
"os"
"strconv"
"strings"
"time"
)
var key, _ = base64.StdEncoding.DecodeString(
strings.TrimPrefix(os.Getenv("NAIRI_WEBHOOK_SECRET"), "whsec_"),
)
// verify takes the request headers and the exact body bytes received.
func verify(headers http.Header, rawBody []byte) bool {
id := headers.Get("webhook-id")
timestamp := headers.Get("webhook-timestamp")
signatures := headers.Get("webhook-signature")
if id == "" || timestamp == "" || signatures == "" {
return false
}
sent, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || time.Since(time.Unix(sent, 0)).Abs() > 5*time.Minute {
return false
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte(id + "." + timestamp + "."))
mac.Write(rawBody)
expected := mac.Sum(nil)
for _, signature := range strings.Split(signatures, " ") {
value, ok := strings.CutPrefix(signature, "v1,")
if !ok {
continue
}
given, err := base64.StdEncoding.DecodeString(value)
if err == nil && hmac.Equal(given, expected) {
return true
}
}
return false
}Delivery rules
- Answer fast, work later. Return any
2xxwithin 10 seconds. Queue the event and do the real work after responding; a slow handler is retried as a failure. - At least once, same
id. A retry carries the samewebhook-idand the same body. Store the ids you have handled and skip repeats. Keep them for days, not minutes: a retry can arrive many hours after the first attempt. - Order is not guaranteed. Route on
data.job_idand read the current state from the API rather than assuming events arrive in sequence. - Retries. A non-
2xxresponse, a timeout, or a redirect is retried after about 30 seconds, then 2 minutes, 10 minutes, 1 hour, 3 hours, and then every 6 hours, with some jitter. After 11 attempts (about 34 hours) the event is dropped. 410 Gonestops retries for that event immediately. Use it when you no longer want a particular delivery.429or503withRetry-Aftermakes us wait at least that long before the next try, up to 6 hours. It never makes us retry sooner than the schedule.- Redirects are not followed. Point the URL at the final endpoint.
- Secret rotation. For 24 hours after a rotation every delivery carries two signatures in
webhook-signature, one per secret, separated by a space. The samples above already accept either.
Message reference
Reference for the roles, statuses, and content fields returned on every conversation message in Nairi's REST API. Useful when parsing agent replies.
Overview
Upload files to Nairi via the REST API and read back any files the agent returns. Two-step flow: upload to get an attachment_id, then reference it in messages.