Prefer Swagger UI? Click hereThe same API in the classic OpenAPI explorer.
// concept · 9 min read

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.

Mental model

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.

Core event types accepted by the subscription validator.
Event typeFires when
customer.createdA new customer (cus_) is created, including lazy-create on first attributed call.
customer.archivedA customer is archived (soft delete).
customer.hard_deletedA customer is permanently removed.
customer.budget_exceededA call is blocked because the customer crossed monthly_budget_usd (the call returns 402 customer_budget_exceeded).
customer.rate_limit_exceededA call is blocked by the customer rpm/tpm limit.
wallet.lowA prepaid wallet drops below its low-balance threshold after a debit.
wallet.emptyA wallet reaches zero (further calls return 402 customer_wallet_empty or wallet_empty).
moderation.flaggedAn inline moderation pass (pre, post or both) flags content on a chat or conversation call.
request.failed_after_retryA request exhausts its retry budget and still fails.
chat.completion.finishedA 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.cancelledAsync batch lifecycle transitions.
vector_store.*Vector store and file-ingest lifecycle (see the dedicated table below).
brain.learning.proposedA 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.conflictA 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.frozenAutotune froze the brain policy after repeated churn, so it stops self-adjusting until you unfreeze it.
brain.policy.changedA policy PATCH re-ranked stored memory. Carries affected_entries and changed_types.
brain.learning.skippedA Brains learn pass was skipped (e.g. the account was unfunded under on_unfunded:recall_only). Carries a reason.
brain.reindex.completed / brain.reindex.failedA brain reindex (re-embed under a new model) finished or failed. Carries job_id.
webhook.testA 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_processed and its OpenAI-vocabulary alias vector_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
run.* is reserved

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.

FieldTypeNotes
urlstring (required)https, SSRF-safe, unique per developer.
eventsstring[] (required)Non-empty subset of the taxonomy. Each entry must match <resource>.<action>.
rotation_grace_minutesinteger (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 secret is shown once

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

Error bodies follow the standard `{ "error": { type, code, message } }` shape. Branch on `code`, never the human `message`. Every webhooks response also carries an `X-Request-Id` header (`req_<hex>`); quote it when you file a support ticket.
HTTPcodeCause
400invalid_urlurl missing or not a string.
400insecure_webhook_urlURL is not https (and not a localhost dev URL), or it failed the SSRF check.
400malformed_webhook_urlURL did not parse.
400empty_eventsevents was missing or an empty array.
400unknown_event_typeAn entry did not match the <resource>.<action> shape.
400invalid_rotation_gracerotation_grace_minutes was not an integer in [0, 1440].
409webhook_url_in_useYou 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>"
}
  • ·id is the event id (evt_<hex>). The same id is reused across retries and across the parallel fan-out to multiple endpoints, so use it as your idempotency key when processing.
  • ·api_version is locked to "1". Key your dispatch table off it so a future v2 envelope shape does not silently break your receiver.
  • ·type is one of the taxonomy strings.
  • ·created_at is 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.

  1. 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.
  2. 02Compare constant-time. Use hmac.compare_digest (Python), crypto.timingSafeEqual (Node) or equivalent. A plain == leaks timing information.
  3. 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):
    ...
Or use the SDK helper

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.

Six attempts total. Each scheduled delay carries plus or minus 10% jitter to avoid thundering herds.
After failure numberNext attempt in
11 minute
25 minutes
330 minutes
42 hours
56 hours
6no 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.

Test events never trip the streak

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.

  • ·status filter: one of pending, delivering, delivered, retry_scheduled, dead.
  • ·event_type filter: any taxonomy string.
  • ·Each row reports attempt_count, next_attempt_at, last_attempt_at, delivered_at, response_status, a truncated response_body_preview (up to ~2 KB of what your endpoint returned) and error_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.

The full webhooks sub-route surface.
Method + pathScopePurpose
POST /v1/webhooksapi:webhooksRegister an endpoint; returns the secret once.
GET /v1/webhooksapi:readList endpoints (secret redacted). Optional status, limit, after.
GET /v1/webhooks/{id}api:readFetch one endpoint.
PATCH /v1/webhooks/{id}api:webhooksUpdate url, events, rotation_grace_minutes or flip status between active and paused.
DELETE /v1/webhooks/{id}api:webhooksSoft-delete (returns 204). The row is retained so the delivery log stays queryable.
POST /v1/webhooks/{id}/rotate_secretapi:webhooksMint a new secret; returns it once. Optional grace_minutes (0 to 1440).
POST /v1/webhooks/{id}/testapi:webhooksFire a synthetic webhook.test delivery (202).
GET /v1/webhooks/{id}/deliveriesapi:readList delivery attempts (no payload, no signature).
POST /v1/webhooks/{id}/deliveries/{delivery_id}/retryapi:webhooksReplay a dead delivery (202).
Status transitions are constrained

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 v1 digests 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.