Nairi APITask ManagementConversationsWebhooks

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 https and a hostname (not an IP address);
  • carry no username, password, or #fragment;
  • answer POST requests with a 2xx status 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"}}
TypeSent whendata
message.completedthe agent answered your message, or the message was stoppedjob_id, message_id
message.failedthe agent hit an error, or the message was still open when the conversation endedjob_id, message_id
conversation.completedthe agent finished the conversation, or it was closed after 24 hoursjob_id
conversation.abandonedthe agent went silent and Nairi closed the conversationjob_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.

Info

A conversation event is a promise: by the time you receive conversation.completed or conversation.abandoned, every message in that conversation has a final status, completed or failed.

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 ;;
esac
import { 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?
end
import 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 False
package 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 2xx within 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 same webhook-id and 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_id and read the current state from the API rather than assuming events arrive in sequence.
  • Retries. A non-2xx response, 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 Gone stops retries for that event immediately. Use it when you no longer want a particular delivery.
  • 429 or 503 with Retry-After makes 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.

On this page