Skip to content
Docs navigation: System Zero / Webhooks

Webhooks

RightNow AIRightNow System ZeroWebhooks Updated

On this page

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.

RouteBodySuccess
POST /v1/system-zero/webhooks{url, events}201. The endpoint and its secret.
GET /v1/system-zero/webhooksNone200. Every endpoint, without secrets.
POST /v1/system-zero/webhooks/{id}/rotate-secretNone or {}200. The endpoint and a new secret.
GET /v1/system-zero/webhooks/{id}/deliveriesNone200. Recent delivery attempts.
DELETE /v1/system-zero/webhooks/{id}None200. 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.

verify-webhook.ts
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'));
}
  • 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.
TypeSent when
decision.completedA decision answered 200, once its usage is recorded. Questions refused inside that answer still make it completed.
decision.refusedA decision request was refused with an error status. It is sent after the response.
stream.closedThe 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

CodeStatusRetryWhat to do
webhook_invalid400NoFix the URL, events, limit or body, and send no Idempotency-Key.
webhook_not_found404NoCheck the endpoint ID and the key's workspace.
webhook_limit_exceeded429NoDelete an endpoint, then create the new one. Waiting does not clear the limit.
webhook_unavailable503YesWait 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 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.