# Webhooks

[RightNow AI](https://www.rightnowai.co/) / [RightNow System Zero](https://www.rightnowai.co/docs/system-zero) / [Webhooks](https://www.rightnowai.co/docs/system-zero/webhooks)

Updated 2026-11-07

Webhooks send signed RightNow System Zero event metadata to an HTTPS endpoint you control. Manage endpoints over HTTP with an API key from your server.

## Routes

Every route takes an API key in the `Authorization` header and acts on that key's workspace. Playground credentials cannot manage endpoints.

**Webhook routes**

| Route | Body | Success |
| --- | --- | --- |
| `POST /v1/system-zero/webhooks` | `{url, events}` | 201. The endpoint and its secret. |
| `GET /v1/system-zero/webhooks` | None | 200. Every endpoint, without secrets. |
| `POST /v1/system-zero/webhooks/{id}/rotate-secret` | None or `{}` | 200. The endpoint and a new secret. |
| `GET /v1/system-zero/webhooks/{id}/deliveries` | None | 200. Recent delivery attempts. |
| `DELETE /v1/system-zero/webhooks/{id}` | None | 200. The deleted endpoint ID. |

Management calls never count as decisions. Ordinary rate limits apply, and every answer carries `Cache-Control: no-store`.

Send no `Idempotency-Key`: these routes refuse it. A body is at most 4,096 bytes, and only the delivery log takes a query parameter.

## Create an endpoint

Send `url` and `events`. No other field is accepted.

- **`url`**: Your HTTPS receiver, at most 2,048 bytes, on any port. No user name, password, fragment, whitespace or backslash. Every address its host resolves to must be public, when you create the endpoint and before each attempt.
- **`events`**: One or more of `decision.completed`, `decision.refused` or `stream.closed`, each at most once.

**create-webhook.sh**

```shell
curl https://api.rightnowai.co/v1/system-zero/webhooks \
  -H "Authorization: Bearer $SYSTEM_ZERO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://receiver.example/webhooks",
    "events": ["decision.completed", "decision.refused", "stream.closed"]
  }'
```

A URL that fails these checks, or does not resolve within 5 seconds, answers 400 `webhook_invalid`.

The answer is the endpoint with its signing secret: `id`, `object`, `url`, `events`, `created` and `secret`. The ID starts with `wh_`, `object` is `webhook_endpoint`, `url` is normalized and `created` is in Unix seconds.

The secret starts with `whsec_`. Only create and rotation return it. Store it in your secret manager and never log it.

A workspace holds at most 5 endpoints across all its API keys. Never repeat a create automatically: if its answer is lost, list the endpoints, then rotate or delete the new one.

## List, rotate and delete

List returns `{object: "list", data}` with every endpoint of the workspace and no secrets. Set `WEBHOOK_ID` to an endpoint's `id`.

**list-webhooks.sh**

```shell
curl https://api.rightnowai.co/v1/system-zero/webhooks \
  -H "Authorization: Bearer $SYSTEM_ZERO_API_KEY"
```

Rotation returns the endpoint with a new secret. Attempts prepared after it are signed with the new secret; an attempt prepared before it can still arrive signed with the old one. If a rotation answer is lost, rotate again.

**rotate-secret.sh**

```shell
curl https://api.rightnowai.co/v1/system-zero/webhooks/$WEBHOOK_ID/rotate-secret \
  -H "Authorization: Bearer $SYSTEM_ZERO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'
```

Delete answers `{id, deleted: true}`. An unknown ID, an endpoint already deleted and another workspace's endpoint all answer 404 `webhook_not_found`.

**delete-webhook.sh**

```shell
curl -X DELETE https://api.rightnowai.co/v1/system-zero/webhooks/$WEBHOOK_ID \
  -H "Authorization: Bearer $SYSTEM_ZERO_API_KEY"
```

## Verify a delivery

Each delivery carries `Webhook-Signature: t=<timestamp>,v1=<signature>`. The timestamp is the signing time in Unix seconds; the signature is lowercase hexadecimal.

The signature is HMAC-SHA256 over the timestamp, a period and the exact body bytes. Its key is the bytes that the hexadecimal digits after `whsec_` encode.

**TypeScript (verify-webhook.ts)**

```typescript
import { createHmac, timingSafeEqual } from 'node:crypto';

const MAX_BODY_BYTES = 16_384;
const TOLERANCE_SECONDS = 300;
const HEADER = /^t=(0|[1-9][0-9]{0,15}),v1=([0-9a-f]{64})$/;
const SECRET = /^whsec_[0-9a-f]{64}$/;

// rawBody: the exact request bytes. header: the Webhook-Signature value.
export function verifyWebhook(rawBody: Buffer, header: string, secret: string): boolean {
  const match = HEADER.exec(header);
  if (!match || !SECRET.test(secret) || rawBody.length > MAX_BODY_BYTES) return false;
  const [, stamp, signature] = match;
  const age = Math.floor(Date.now() / 1000) - Number(stamp);
  if (!Number.isSafeInteger(Number(stamp)) || Math.abs(age) > TOLERANCE_SECONDS) return false;
  const expected = createHmac('sha256', Buffer.from(secret.slice(6), 'hex'))
    .update(`${stamp}.`)
    .update(rawBody)
    .digest();
  return timingSafeEqual(expected, Buffer.from(signature, 'hex'));
}
```

**Python (verify\_webhook.py)**

```python
import hashlib
import hmac
import re
import time

MAX_BODY_BYTES = 16_384
TOLERANCE_SECONDS = 300
HEADER = re.compile(r"t=(0|[1-9][0-9]{0,15}),v1=([0-9a-f]{64})")
SECRET = re.compile(r"whsec_[0-9a-f]{64}")


def verify_webhook(raw_body: bytes, header: str, secret: str) -> bool:
    """raw_body: the exact request bytes. header: the Webhook-Signature value."""
    match = HEADER.fullmatch(header)
    if match is None or SECRET.fullmatch(secret) is None or len(raw_body) > MAX_BODY_BYTES:
        return False
    stamp, signature = match.groups()
    if abs(int(time.time()) - int(stamp)) > TOLERANCE_SECONDS:
        return False
    signed = b".".join([stamp.encode("ascii"), raw_body])
    expected = hmac.new(bytes.fromhex(secret[6:]), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)
```

- Refuse a body over 16,384 bytes before you buffer it.
- Verify the raw bytes before you parse JSON. Parsing and serializing again can change the bytes.
- Refuse a timestamp more than 300 seconds from your clock, and keep that clock accurate.
- Store each event `id` durably and skip IDs you already handled, including repeats inside the timestamp window.

## Event data

Each body is one JSON event: `id`, `object`, `type`, `created` and `data`. `object` is always `webhook_event`.

- **`id`**: `evt_` and a UUID. Every attempt of one event carries the same ID.
- **`type`**: `decision.completed`, `decision.refused` or `stream.closed`
- **`created`**: The event time in Unix seconds. It differs from the signature timestamp.

**Webhook event types**

| Type | Sent when |
| --- | --- |
| `decision.completed` | A decision answered 200, once its usage is recorded. Questions refused inside that answer still make it completed. |
| `decision.refused` | A decision request was refused with an error status. It is sent after the response. |
| `stream.closed` | The server saw a stream close. It is sent after the close. |

Decision events carry this `data`:

- **`decision_id`**: The decision's ID.
- **`request_id`**: The request's ID.
- **`created`**: When the decision was created, or when the refused request arrived, in Unix seconds.
- **`model`**: The served version on success, the requested alias or version before execution, or `unknown` before the model was checked.
- **`route`**: `direct` or `long`, or null when routing did not occur.
- **`classes`**: One entry per input class in the request: `name` (`text`, `document`, `image`, `audio` or `video`) and `status` (`served` or `held`).
- **`status`**: 200 when completed, the HTTP status from 400 to 599 when refused.
- **`refusal_code`**: Null when completed, the error code when refused.
- **`usage_units`**: Seven nonnegative integers: `input_tokens`, `cached_input_tokens`, `media_tokens`, `cached_media_tokens`, `long_input_tokens`, `media_items` and `thought_tokens`.

`stream.closed` carries `stream_id`, `close_code` (1000 to 4999) and `reason`: `client_close`, `shutdown`, `max_lifetime`, `idle`, `maintenance`, `lease_lost`, `slow_reader`, `overload`, `auth`, `policy`, `protocol` or `store_unavailable`.

Events carry metadata only. They never include state, questions or their names, answers, criteria, attachments, media URLs, headers, workspace identifiers or API keys.

Responses never wait for webhook work. Replayed answers and Playground runs send no events. A refusal or close event can be lost when its store is unavailable or full, or the server stops before saving it; the response is never affected.

## Delivery and retries

Each attempt is an HTTPS POST of the event with `Content-Type: application/json`, `User-Agent: SystemZero-Webhooks/1` and `Webhook-Signature`. Your receiver needs a valid certificate and TLS 1.2 or newer. The address is checked again before every attempt, and redirects are never followed.

Answer with a 2xx status within 5 seconds, DNS and TLS included. Any other status fails the attempt, and so do more than 8 informational responses, headers over 8 KiB or more than 32 KiB of response.

An event gets at most 8 attempts within 7 days while its endpoint exists. A failed attempt schedules the next one after a random delay in its range; the delay is the earliest the next attempt can start.

- **After attempt 1**: 0.5 to 1 second
- **After attempt 2**: 1 to 2 seconds
- **After attempt 3**: 2 to 4 seconds
- **After attempt 4**: 4 to 8 seconds
- **After attempt 5**: 8 to 16 seconds
- **After attempt 6**: 16 to 32 seconds
- **After attempt 7**: 32 to 64 seconds
- **After attempt 8**: No retry. The event is not sent again.

Retries keep the event ID and body bytes, with a new timestamp and the endpoint's current secret. Delivery is at least once: the same event can arrive again, even after your 2xx. There is no route to resend an event.

Return a 2xx only after you store the event ID and accept the work. Receiver failures never change decision responses or usage.

## Delivery log

Read an endpoint's recent attempts, newest first. Set `limit` from 1 to 100; the default is 100.

**webhook-deliveries.sh**

```shell
curl "https://api.rightnowai.co/v1/system-zero/webhooks/$WEBHOOK_ID/deliveries?limit=20" \
  -H "Authorization: Bearer $SYSTEM_ZERO_API_KEY"
```

Each row holds `event_id`, `type`, `attempt`, `status_code`, `latency_ms`, `outcome` and `created`. `status_code` is null when no HTTP status arrived. The log keeps at most 1,000 attempts per endpoint and leaves out attempts older than 7 days.

- **`delivered`**: Your receiver answered with a 2xx status.
- **`http_error`**: Your receiver answered with another status, a redirect included.
- **`blocked`**: The URL failed its checks before sending, such as an address that is not public.
- **`timeout`**: The attempt did not finish within 5 seconds.
- **`network_error`**: DNS, the connection or TLS failed, or the response passed a size limit.
- **`dead_letter`**: Delivery ended after the last attempt.

The log never holds the event body, your URL, headers, response bodies or failure text.

## Management errors

**Webhook errors**

| Code | Status | Retry | What to do |
| --- | --- | --- | --- |
| `webhook_invalid` | 400 | No | Fix the URL, events, limit or body, and send no Idempotency-Key. |
| `webhook_not_found` | 404 | No | Check the endpoint ID and the key's workspace. |
| `webhook_limit_exceeded` | 429 | No | Delete an endpoint, then create the new one. Waiting does not clear the limit. |
| `webhook_unavailable` | 503 | Yes | Wait for Retry-After. List endpoints before you repeat a create or rotation. |

`webhook_limit_exceeded` carries `details.cause` `webhook_endpoint_limit` and `details.limit` 5, with no `Retry-After`. `webhook_unavailable` carries `Retry-After` and `Retry-After-Ms`.

Other refusals keep their meanings: 401 `invalid_api_key`, 403 `auth_error`, 403 `account_suspended`, 405 `method_not_allowed` and 413 `payload_too_large`. See [Errors](https://www.rightnowai.co/docs/system-zero/errors#body) for the error body.

## Retention and deletion

Event metadata expires after 7 days. A workspace keeps at most 10,000 refusal and stream close events; past that, new ones are not sent.

Signing secrets are stored encrypted. Through the API, only the workspace's API keys can read its endpoint URLs; treat them as sensitive.

An endpoint stays until you delete it. Deleting removes the endpoint, its pending deliveries and its delivery log. It cannot recall an attempt already prepared or sent, and your receiver keeps what it received.

## Next

[Danger zone](https://www.rightnowai.co/docs/system-zero/danger-zone)
