# 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 [#setting-up]

Webhooks are configured per organization by the Nairi team. Send your endpoint URL to [support@nairi.ai](mailto: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 [#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.

```http
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.

<Callout type="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`.
</Callout>

## Verifying the signature [#verifying-the-signature]

Deliveries are signed with [Standard Webhooks](https://www.standardwebhooks.com/), 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.

<Tabs items="[&#x22;bash&#x22;, &#x22;TypeScript&#x22;, &#x22;Ruby&#x22;, &#x22;Python&#x22;, &#x22;Go&#x22;]">
  <Tab value="bash">
    ```bash
    # 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
    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts
    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`.
  </Tab>

  <Tab value="Ruby">
    ```ruby
    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
    ```
  </Tab>

  <Tab value="Python">
    ```python
    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
    ```
  </Tab>

  <Tab value="Go">
    ```go
    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
    }
    ```
  </Tab>
</Tabs>

## Delivery rules [#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.
