Webhooks and events
Ringside pushes signed event payloads to an https endpoint you register, so you react to spend, moderation and lifecycle changes without polling. This page covers the event taxonomy, the X-FC-Signature scheme in full, delivery and retry behaviour, secret rotation and a verified receiver you can paste into production.
What webhooks are for
Most of what Ringside does is synchronous: you call POST /v1/chat/completions, you get a response, the wallet is debited and the headers tell you what happened. Some things, though, happen outside any single request you make. A customer crosses its monthly budget on a call your own backend never saw. A prepaid wallet runs dry. A batch you submitted finishes forty minutes later. A moderation pass flags content. Webhooks are how Ringside tells you about those out-of-band events: you register an https URL, subscribe to the event types you care about and Ringside POSTs a signed JSON envelope to that URL when each one fires.
The alternative is polling, and polling is wasteful and laggy. Event-driven delivery means your integration reacts in seconds, and the signature on every delivery means you can trust the payload came from Ringside and was not replayed or tampered with in transit.
A webhook endpoint is a row scoped to your developer account (wh_<hex>). It has a URL, a subscription list, a signing secret and a health state. Every event you are subscribed to fans out to one delivery row per matching endpoint, and a background worker drains those rows, signs each body and POSTs it with retry and backoff.
The event taxonomy
When you register an endpoint you pass events[], a non-empty subset of the taxonomy below. Subscribe narrowly. An endpoint that subscribes to everything will receive a lot of traffic it then has to filter, and each delivery still counts against that endpoint's failure streak if your receiver rejects it.
| Event type | Fires when |
|---|---|
customer.created | A new customer (cus_) is created, including lazy-create on first attributed call. |
customer.archived | A customer is archived (soft delete). |
customer.hard_deleted | A customer is permanently removed. |
customer.budget_exceeded | A call is blocked because the customer crossed monthly_budget_usd (the call returns 402 customer_budget_exceeded). |
customer.rate_limit_exceeded | A call is blocked by the customer rpm/tpm limit. |
wallet.low | A prepaid wallet drops below its low-balance threshold after a debit. |
wallet.empty | A wallet reaches zero (further calls return 402 customer_wallet_empty or wallet_empty). |
moderation.flagged | An inline moderation pass (pre, post or both) flags content on a chat or conversation call. |
request.failed_after_retry | A request exhausts its retry budget and still fails. |
chat.completion.finished | A chat completion finishes. Subscribable now; emit wiring on the chat path is a tracked follow-up, so treat delivery as best-effort. |
batch.created / batch.completed / batch.failed / batch.cancelled | Async batch lifecycle transitions. |
vector_store.* | Vector store and file-ingest lifecycle (see the dedicated table below). |
brain.learning.proposed | A Brains learn pass proposed a memory change that is waiting in the review queue (only fires when learn_mode routes the change to review). The payload carries the proposal id. |
brain.conflict | A Brains learn pass detected that a new fact contradicts an existing memory. The payload identifies the conflicting entries so you can reconcile them. |
brain.autotune.frozen | Autotune froze the brain policy after repeated churn, so it stops self-adjusting until you unfreeze it. |
brain.policy.changed | A policy PATCH re-ranked stored memory. Carries affected_entries and changed_types. |
brain.learning.skipped | A Brains learn pass was skipped (e.g. the account was unfunded under on_unfunded:recall_only). Carries a reason. |
brain.reindex.completed / brain.reindex.failed | A brain reindex (re-embed under a new model) finished or failed. Carries job_id. |
webhook.test | A synthetic event you fire yourself via POST /v1/webhooks/{id}/test to prove your receiver works. |
vector_store.* events
- ·
vector_store.created,vector_store.deleted - ·
vector_store.file_processedand its OpenAI-vocabulary aliasvector_store.file.completed(both fire together so docs that tell you to subscribe to either one work) - ·
vector_store.file_failed - ·
vector_store.file_batch.in_progress,vector_store.file_batch.completed,vector_store.file_batch.failed - ·
vector_store.migration.completed,vector_store.migration.failed - ·
vector_store.payment_required,vector_store.suspended,vector_store.deletion_warning
The taxonomy registers run.completed, run.failed and run.cancelled so subscriptions to them are accepted, but no emit call sites exist yet for the Assistants run lifecycle. Do not build logic that depends on receiving them. Some vector_store.* and chat.completion.finished types are likewise registered ahead of their emitters; subscribe if you want them, but design for best-effort delivery until you confirm they fire for your account.
Registering an endpoint
Register with POST /v1/webhooks. Your developer key must hold the api:webhooks scope. The url must be https (the one exception is http://localhost or http://127.0.0.1 for local development) and it must be unique within your account. Ringside also runs an SSRF check at registration, so URLs that resolve to private, internal or cloud-metadata addresses are rejected.
| Field | Type | Notes |
|---|---|---|
url | string (required) | https, SSRF-safe, unique per developer. |
events | string[] (required) | Non-empty subset of the taxonomy. Each entry must match <resource>.<action>. |
rotation_grace_minutes | integer (optional) | Default 30. Range 0 to 1440 (24 h). The dual-signature window applied when you rotate the secret. |
curl https://api.fightclub.pro/v1/webhooks \
-H "Authorization: Bearer ko_<your_key>" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.example.com/ringside",
"events": [
"customer.budget_exceeded",
"wallet.low",
"wallet.empty",
"moderation.flagged",
"request.failed_after_retry"
],
"rotation_grace_minutes": 30
}'The whsec_ signing secret is returned in plaintext only in the create response and in the rotate_secret response. Every list and get response redacts it. Store it at registration time. If you lose it, your only recovery is to rotate (POST /v1/webhooks/{id}/rotate_secret), which mints a new one.
Registration errors
| HTTP | code | Cause |
|---|---|---|
| 400 | invalid_url | url missing or not a string. |
| 400 | insecure_webhook_url | URL is not https (and not a localhost dev URL), or it failed the SSRF check. |
| 400 | malformed_webhook_url | URL did not parse. |
| 400 | empty_events | events was missing or an empty array. |
| 400 | unknown_event_type | An entry did not match the <resource>.<action> shape. |
| 400 | invalid_rotation_grace | rotation_grace_minutes was not an integer in [0, 1440]. |
| 409 | webhook_url_in_use | You already have an endpoint registered for that URL. |
The delivery envelope
Every delivery is a single JSON object POSTed to your URL with Content-Type: application/json. The envelope is stable across event types: the type-specific detail lives under data.
{
"id": "evt_9b3c7e1d4a82f06c5b1e9d40",
"api_version": "1",
"type": "customer.budget_exceeded",
"created_at": "2026-06-24T11:02:48.331Z",
"data": { "customer_id": "cus_42", "...": "..." },
"dev_user_id": "<your developer id>"
}- ·
idis the event id (evt_<hex>). The sameidis reused across retries and across the parallel fan-out to multiple endpoints, so use it as your idempotency key when processing. - ·
api_versionis locked to"1". Key your dispatch table off it so a future v2 envelope shape does not silently break your receiver. - ·
typeis one of the taxonomy strings. - ·
created_atis when the event was emitted (ISO 8601), not when this attempt was made.
Alongside the signature, each delivery carries X-FC-Event-Id, X-FC-Event-Type and X-FC-Delivery-Id headers, plus User-Agent: FightClub-Webhooks/1.0. The delivery id is unique per attempt-row; the event id is not.
Signature verification
Every delivery carries an X-FC-Signature header. Verify it before you trust the body. The header looks like this:
X-FC-Signature: t=1750761768,v1=4f8c2b1a...e07d
- t
- The unix timestamp (seconds) at which Ringside signed and sent this attempt. Stamped at send time, not at emit time, so it reflects wall-clock transit.
- v1
- A hex HMAC-SHA256 digest. The signed message is the literal string
<t>.<raw_body>(the timestamp, a period, then the exact raw request body), keyed by your endpoint secret.
To verify: read the raw request body before any JSON parsing or re-serialization, split the header into t and v1 parts, recompute HMAC-SHA256(secret, t + "." + rawBody) and compare it constant-time against the v1 digest. Two checks beyond the digest matter as much as the digest itself.
- 01Use the raw bytes. If your framework parses the JSON and you re-stringify it, key ordering or whitespace will differ and the HMAC will not match. Capture the body as a string or buffer first.
- 02Compare constant-time. Use
hmac.compare_digest(Python),crypto.timingSafeEqual(Node) or equivalent. A plain==leaks timing information. - 03Reject stale timestamps. If
abs(now - t)exceeds your tolerance, reject the delivery even if the digest matches. This is your replay defence. Ringside's own verifier defaults to a 300 second (5 minute) window; mirror that.
Rotation grace: two v1 digests
When you rotate a secret, Ringside does not cut over instantly. For the grace window (your endpoint's rotation_grace_minutes, default 30), the delivery worker signs each body with both the new secret and the previous one, and the header carries two digests:
X-FC-Signature: t=1750761768,v1=<digest-new-secret>,v1=<digest-previous-secret>
Your verifier should iterate every v1= digest in the header and accept the delivery if any one of them matches the secret you currently hold. That is what lets you flip your stored secret to the new value at any point during the window without dropping a single event: before you flip, the previous-secret digest matches; after you flip, the new-secret digest matches. Unknown key=value pairs in the header are ignored by Ringside's parser, so write yours the same way (skip anything that is not t or v1) and future header additions will not break you.
A verified receiver (Python)
A complete Flask receiver that captures the raw body, supports multiple v1 digests for rotation grace, enforces the timestamp window and deduplicates by event id. Paste it into a real service and it holds up.
import hashlib
import hmac
import time
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = "whsec_3a1c...e9f0" # the secret returned at registration
TOLERANCE_SECONDS = 300 # mirror Ringside's 5-minute window
# In production this is Redis or a table with a TTL, not a set.
_seen_event_ids: set[str] = set()
def verify_signature(header: str, raw_body: bytes, secret: str) -> bool:
if not header:
return False
ts = None
digests = []
for part in header.split(","):
part = part.strip()
key, _, val = part.partition("=")
if key == "t" and val.isdigit():
ts = int(val)
elif key == "v1":
digests.append(val.lower())
# ignore any other scheme so future header fields don't break us
if ts is None or not digests:
return False
# Replay defence: reject stale or future-dated timestamps.
if abs(int(time.time()) - ts) > TOLERANCE_SECONDS:
return False
signed = f"{ts}.".encode("utf-8") + raw_body
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
# Accept if ANY presented digest matches (rotation grace sends two).
return any(hmac.compare_digest(expected, d) for d in digests)
@app.post("/ringside")
def ringside_webhook():
raw = request.get_data() # raw bytes, BEFORE any JSON parsing
sig = request.headers.get("X-FC-Signature", "")
if not verify_signature(sig, raw, WEBHOOK_SECRET):
abort(400, "invalid signature")
event = request.get_json()
event_id = event["id"]
# Same event id arrives on retries and parallel fan-out. Dedupe.
if event_id in _seen_event_ids:
return ("", 200)
_seen_event_ids.add(event_id)
if event["type"] == "customer.budget_exceeded":
handle_budget_exceeded(event["data"])
elif event["type"] in ("wallet.low", "wallet.empty"):
handle_wallet(event["type"], event["data"])
# 2xx tells Ringside the delivery succeeded. Return fast; do real
# work on a queue so you don't burn the 15s delivery timeout.
return ("", 200)
def handle_budget_exceeded(data):
...
def handle_wallet(event_type, data):
...The official Ringside SDK ships webhooks.verify(), which does the header parse, raw-body HMAC, constant-time compare and timestamp-window check for you, including multi-digest rotation grace. Use it if you are already on the SDK (pip install ringside, npm install @ringside/sdk).
Delivery, retries and backoff
A background worker drains delivery rows, signs each body, POSTs it with a 15 second timeout and redirect: manual (so a 3xx from your endpoint cannot bounce the request elsewhere), then records the outcome. Any 2xx response marks the delivery delivered and resets the endpoint's failure streak. Anything else, a non-2xx status, a timeout or a network error, is a failure and schedules a retry on a fixed backoff curve.
| After failure number | Next attempt in |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 6 hours |
| 6 | no further retry; the delivery is marked dead |
A delivery moves through pending, delivering, then either delivered or retry_scheduled, and finally dead once the six attempts are exhausted. Because the same event id rides every attempt, your receiver will see the same evt_ id on each redelivery. Deduplicate on it, as the example above does.
Failure-streak auto-disable
Separate from per-delivery retries, each endpoint tracks a consecutive-failure streak across deliveries. A single success resets it to zero. When the streak reaches 25 the endpoint auto-disables: its status flips to disabled_after_failures and any rows already queued for it short-circuit to dead with error_text = endpoint_paused. This stops a dead URL from generating delivery load forever.
Deliveries for webhook.test record their failure in the delivery log (so you can see a bad URL) but are deliberately excluded from the failure-streak counter. You cannot disable your own live endpoint by hammering POST /webhooks/{id}/test against a stale receiver.
To bring a disabled endpoint back, PATCH /v1/webhooks/{id} with status: "active". That clears the failure streak so a single transient blip cannot immediately re-trip the auto-disable. Rotating the secret also resets the streak, since rotation is itself a deliberate intervention.
The deliveries log and replay
Every attempt is recorded. GET /v1/webhooks/{id}/deliveries lists delivery rows newest-first with cursor pagination (limit default 50, max 200; pass the prior next_cursor as after). The payload body and the signature are never returned in this log; you get the observability fields you need to debug without re-exposing event content.
- ·
statusfilter: one ofpending,delivering,delivered,retry_scheduled,dead. - ·
event_typefilter: any taxonomy string. - ·Each row reports
attempt_count,next_attempt_at,last_attempt_at,delivered_at,response_status, a truncatedresponse_body_preview(up to ~2 KB of what your endpoint returned) anderror_text.
When a delivery has gone dead, you can manually replay it with POST /v1/webhooks/{id}/deliveries/{delivery_id}/retry. Only dead rows are eligible; retrying a delivered, pending or in-flight row returns 409 delivery_not_retryable. Replay does not mutate the original row (it stays as the audit record of the exhausted attempts). Instead it inserts a fresh delivery row that reuses the same event id and starts at attempt zero, then returns 202 with the new delivery_id, the event_id and the original_delivery_id.
curl -X POST \ https://api.fightclub.pro/v1/webhooks/wh_5f2a9c1b4e7d0a83c6b1f244/deliveries/wdl_71b0.../retry \ -H "Authorization: Bearer ko_<your_key>"
Testing and managing endpoints
Fire a synthetic delivery at one endpoint, regardless of its subscription list, with POST /v1/webhooks/{id}/test. It enqueues a single webhook.test delivery scoped to that endpoint and returns 202 with { delivery_id, event_id, scheduled_at }. Use it to confirm your receiver is reachable, parses the envelope and verifies the signature before you wait on a real event.
| Method + path | Scope | Purpose |
|---|---|---|
POST /v1/webhooks | api:webhooks | Register an endpoint; returns the secret once. |
GET /v1/webhooks | api:read | List endpoints (secret redacted). Optional status, limit, after. |
GET /v1/webhooks/{id} | api:read | Fetch one endpoint. |
PATCH /v1/webhooks/{id} | api:webhooks | Update url, events, rotation_grace_minutes or flip status between active and paused. |
DELETE /v1/webhooks/{id} | api:webhooks | Soft-delete (returns 204). The row is retained so the delivery log stays queryable. |
POST /v1/webhooks/{id}/rotate_secret | api:webhooks | Mint a new secret; returns it once. Optional grace_minutes (0 to 1440). |
POST /v1/webhooks/{id}/test | api:webhooks | Fire a synthetic webhook.test delivery (202). |
GET /v1/webhooks/{id}/deliveries | api:read | List delivery attempts (no payload, no signature). |
POST /v1/webhooks/{id}/deliveries/{delivery_id}/retry | api:webhooks | Replay a dead delivery (202). |
From PATCH you may only set status to active or paused. The disabled_after_failures state is worker-only; trying to set it returns 400 invalid_status_transition. To recover a disabled endpoint, PATCH it to active, which also clears the failure streak.
Like every Ringside resource, webhook endpoints are tenant-isolated. A request for an endpoint or delivery that belongs to another developer returns 404 (not_found_error), never 403, so ids stay non-enumerable. A delivery id that belongs to a different endpoint in your own account also 404s rather than leaking that it exists elsewhere.
Limits and defaults at a glance
- Delivery attempts
- 6 total per delivery row, then
dead. - Backoff curve
- 1 min, 5 min, 30 min, 2 h, 6 h, then dead, each with plus or minus 10% jitter.
- Per-delivery timeout
- 15 seconds. Return your 2xx fast and do heavy work asynchronously.
- Auto-disable streak
- 25 consecutive failures across deliveries disables the endpoint.
- Rotation grace
- Default 30 minutes, settable 0 to 1440 (24 h). Dual
v1digests for that window. - Timestamp tolerance
- Ringside's verifier defaults to 300 seconds; use the same window in your receiver.
- Deliveries list
- limit default 50, max 200.
- Response body preview
- Up to ~2 KB of your endpoint's response is stored per attempt.