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

Receive and verify webhooks

A working webhook receiver proves each delivery came from Ringside, refuses a payload that was captured and replayed and keeps verifying through a secret rotation without dropping an event. This guide walks the whole path. Register the endpoint, store the whsec_ secret, recompute the HMAC-SHA256 over the raw body, compare it in constant time, reject stale timestamps then fire a test event and inspect and replay the deliveries. Full copy-paste receivers in Python and Node are included.

What you are building

Ringside POSTs a signed JSON envelope to an https URL you register whenever a subscribed event fires (a customer crosses its budget, a wallet empties, a batch completes or a moderation pass flags content). Your receiver has to do more than parse the body. It has to verify the X-FC-Signature header before it trusts a single field. The URL is reachable by anyone on the internet, so an unverified handler is an open door. Anyone who learns the path can forge a wallet.empty or customer.budget_exceeded and drive your own automation against you.

The signature is an HMAC-SHA256 keyed by a per-endpoint secret that only you and Ringside hold. Recompute that HMAC over the exact bytes you received and compare it to the value in the header. A match proves two things at once. The payload came from Ringside (only the secret holder can produce the digest) and it was not altered in transit (one flipped byte changes the digest completely). Add a timestamp check and the same proof also defeats replay, because a captured-and-resent delivery carries an old t= that you reject.

Verify before you act

Until the signature checks out, treat the request body as attacker-controlled. Do not parse the JSON, do not log the contents, do not branch on the event type. Verify first, act second.

The mental model

Ringside delivery worker                 Your receiver
---------------------------              -------------------------
  t   = unix seconds (now)
  raw = JSON.stringify(envelope)
  sig = HMAC_SHA256(secret,
          t + "." + raw)  ── hex

  POST <your url>
    X-FC-Signature: t=<t>,v1=<sig> ───▶  read t and every v1= digest
    body: <raw bytes>                    read the RAW body (no re-encode)
                                         expect = HMAC_SHA256(secret,
                                                    t + "." + raw)
                                         constant-time compare to each v1
                                         reject if |now - t| > 300s
                                  ◀───   200 on match,  400/401 otherwise
The signed base is the literal string `<t>.<raw_body>`, joined by a period. Both sides MUST agree on the same byte sequence.

One detail breaks most first attempts. You sign the raw request body, not a re-serialized object. If your framework parses JSON into a dict and you re-encode it to feed the HMAC, key ordering, whitespace and unicode escaping will differ from what Ringside signed and every signature will fail to match. Capture the raw bytes off the wire before any JSON middleware touches them.

Step 1: Register the endpoint

Create the endpoint with POST /v1/webhooks. The url must be https (the one exception is http://localhost for local development) and must be unique within your developer account. events is a non-empty array of types from the event taxonomy. The response returns the signing secret exactly once, in the secret field. Store it before you do anything else, because no later call will ever show it again.

curl -sS https://api.fightclub.pro/v1/webhooks \
  -H "Authorization: Bearer ko_REPLACE_WITH_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "url": "https://hooks.example.com/ringside",
        "events": ["customer.budget_exceeded", "wallet.empty", "batch.completed"],
        "rotation_grace_minutes": 30
      }'

Body fields and their validation:

All validation errors carry type `invalid_request_error` except `webhook_url_in_use`, which is a 409.
FieldRequiredRuleError code on failure
urlyeshttps (or http://localhost), SSRF-safe (private IPs, link-local and cloud-metadata hosts are rejected), unique per developerinvalid_url, insecure_webhook_url, malformed_webhook_url, webhook_url_in_use (409)
eventsyesnon-empty array of known <resource>.<action> typesempty_events, unknown_event_type
rotation_grace_minutesnointeger in 0-1440 (default 30)invalid_rotation_grace
The secret is shown once

If you lose the whsec_ secret you cannot read it back. Call POST /v1/webhooks/{id}/rotate_secret to mint a new one. The redacted endpoint shapes from GET and list never include it.

Step 2: Store the secret

Put the whsec_ value where your receiver process can read it at startup and nowhere a build artifact, a log line or a client bundle can leak it. An environment variable injected by your secrets manager is the usual home. The secret is 32 random bytes rendered as 64 hex characters behind the whsec_ prefix, and the HMAC is keyed by the full string including the prefix, so store and use it verbatim.

  • ·One secret per endpoint. If you register three endpoints you store three secrets, keyed by endpoint id.
  • ·Never commit it. Treat it like a password: secrets manager or an injected env var, not source control.
  • ·During a rotation grace window you may briefly hold two valid secrets for one endpoint (covered below).

Step 3: Verify X-FC-Signature

The delivery header looks like this:

X-FC-Signature: t=1750759200,v1=2b3c4d5e6f70819a2b3c4d5e6f70819a2b3c4d5e6f70819a2b3c4d5e6f70819a

t is the unix timestamp (whole seconds) at which the worker signed the delivery. Each v1= is a lowercase hex HMAC-SHA256 digest. There can be more than one v1= pair: during a secret rotation the header carries two digests, one signed with the current secret and one with the previous secret, so a receiver mid-flip still verifies (Step 5). Unknown scheme keys other than t and v1 are ignored, which is how a future v2 digest could be added without breaking your parser.

The algorithm

  1. 01Read the RAW request body as bytes, before any JSON parsing.
  2. 02Parse the header: split on commas, take t (must be all digits) and collect every v1 value (each must be hex). If t is missing or no v1 is present, reject.
  3. 03Reject a stale timestamp: if the absolute difference between your clock and t exceeds the tolerance (300 seconds / 5 minutes), reject. This is the replay defence.
  4. 04Compute expected = HMAC_SHA256(secret, t + "." + raw_body) as a lowercase hex string. Note the literal period between t and the body.
  5. 05Compare expected against each v1 digest using a constant-time comparison. If any matches, the delivery is authentic. If none match, reject.
  6. 06Only now parse the JSON and dispatch on type. Use the envelope id (an evt_ value) to dedupe, since retries and the redelivery path reuse the same event id.
Constant-time compare, always

Use hmac.compare_digest (Python) or crypto.timingSafeEqual (Node), never ==. A plain string compare returns faster on an early mismatch and leaks the correct prefix one byte at a time to an attacker measuring response latency. Ringside verifies the same way: equal-length buffers fed through crypto.timingSafeEqual.

Other headers on the delivery

Dedupe on X-FC-Event-Id (stable across retries), not X-FC-Delivery-Id (unique per attempt).
HeaderMeaning
X-FC-Signaturet=<unix>,v1=<hex>[,v1=<hex>], the value you verify
X-FC-Event-IdThe evt_ event id, mirrors the envelope id; use it to dedupe
X-FC-Event-TypeThe event type, e.g. customer.budget_exceeded
X-FC-Delivery-IdThe wdl_ id of this specific delivery attempt row
User-AgentFightClub-Webhooks/1.0
Content-Typeapplication/json

The envelope

{
  "id": "evt_4a6b8c0d1e3f5a7b9c1d5e2f",
  "api_version": "1",
  "type": "customer.budget_exceeded",
  "created_at": "2026-06-24T10:05:00.000Z",
  "data": { "customer_id": "cus_42", "monthly_budget_usd": "100.00" },
  "dev_user_id": "..."
}

Full receiver: Python

Flask, reading the raw body off request.get_data() so the bytes match what was signed. The same logic works under any framework as long as you can reach the unparsed body.

import hmac, hashlib, time, os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["RINGSIDE_WEBHOOK_SECRET"]            # the whsec_ value
PREV_SECRET = os.environ.get("RINGSIDE_WEBHOOK_SECRET_PREV")  # set only during rotation
TOLERANCE = 300  # seconds; matches the Ringside default

def _expected(secret: str, t: str, raw: bytes) -> str:
    base = t.encode() + b"." + raw
    return hmac.new(secret.encode(), base, hashlib.sha256).hexdigest()

def verify(header: str, raw: bytes) -> bool:
    if not header:
        return False
    t, digests = None, []
    for part in header.split(","):
        part = part.strip()
        k, _, v = part.partition("=")
        if k == "t" and v.isdigit():
            t = v
        elif k == "v1":
            digests.append(v.lower())
    if t is None or not digests:
        return False
    if abs(int(time.time()) - int(t)) > TOLERANCE:
        return False  # stale: replayed or badly clock-skewed
    candidates = [_expected(SECRET, t, raw)]
    if PREV_SECRET:
        candidates.append(_expected(PREV_SECRET, t, raw))
    return any(
        hmac.compare_digest(c, d)   # constant-time
        for c in candidates for d in digests
    )

@app.post("/ringside")
def receive():
    raw = request.get_data()  # RAW bytes, pre-parse
    if not verify(request.headers.get("X-FC-Signature", ""), raw):
        abort(400)
    event = request.get_json()  # safe now
    # Dedupe on event["id"] (evt_...) before acting; retries reuse it.
    if event["type"] == "customer.budget_exceeded":
        handle_budget(event["data"]["customer_id"])
    return "", 200

Full receiver: Node

Express, with express.raw() so the handler sees a Buffer of the exact bytes. Do not mount express.json() ahead of this route, or the raw body is gone by the time you sign it.

const crypto = require('crypto');
const express = require('express');
const app = express();

const SECRET = process.env.RINGSIDE_WEBHOOK_SECRET;          // whsec_ value
const PREV_SECRET = process.env.RINGSIDE_WEBHOOK_SECRET_PREV; // set during rotation only
const TOLERANCE = 300; // seconds

function expected(secret, t, raw) {
  return crypto.createHmac('sha256', secret)
    .update(Buffer.concat([Buffer.from(t + '.'), raw]))
    .digest('hex');
}

function timingEqualHex(a, b) {
  if (a.length !== b.length) return false;
  const ba = Buffer.from(a, 'hex');
  const bb = Buffer.from(b, 'hex');
  if (ba.length !== bb.length || ba.length === 0) return false;
  return crypto.timingSafeEqual(ba, bb);
}

function verify(header, raw) {
  if (typeof header !== 'string') return false;
  let t = null;
  const digests = [];
  for (const part of header.split(',')) {
    const [k, v] = part.trim().split('=');
    if (k === 't' && /^[0-9]+$/.test(v)) t = v;
    else if (k === 'v1' && /^[0-9a-f]+$/i.test(v)) digests.push(v.toLowerCase());
  }
  if (t === null || digests.length === 0) return false;
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - Number(t)) > TOLERANCE) return false; // stale
  const candidates = [expected(SECRET, t, raw)];
  if (PREV_SECRET) candidates.push(expected(PREV_SECRET, t, raw));
  return candidates.some((c) => digests.some((d) => timingEqualHex(c, d)));
}

app.post('/ringside', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verify(req.get('X-FC-Signature'), req.body)) return res.status(400).end();
  const event = JSON.parse(req.body.toString());
  // Dedupe on event.id (evt_...) before acting.
  if (event.type === 'wallet.empty') handleWalletEmpty(event.data);
  res.status(200).end();
});
Or use the SDK

The official Ringside SDKs ship a webhooks.verify() helper that does the parse, the tolerance check and the constant-time compare for you (pip install ringside, npm install @ringside/sdk). The hand-rolled code above is here so you can verify under any stack and audit exactly what runs.

Step 4: Fire a test event

Once the receiver is deployed, drive a real signed delivery at it with POST /v1/webhooks/{id}/test. This enqueues a synthetic webhook.test event addressed to that one endpoint, regardless of whether the endpoint subscribes to it, and the delivery worker signs and POSTs it on the next tick. The response is a 202 with the ids you can then look up.

curl -sS -X POST \
  https://api.fightclub.pro/v1/webhooks/wh_9f1c0a4d2b6e8f3a1c5d7e90/test \
  -H "Authorization: Bearer ko_REPLACE_WITH_YOUR_KEY"
Test events cannot disable your endpoint

A failed webhook.test delivery is recorded for you to see, but it does NOT increment the endpoint failure streak. You can hammer /test against a half-built receiver without risking the auto-disable described below.

Step 5: Rotate the secret without dropping events

Rotating a leaked or aging secret is the part most webhook systems get wrong, because the naive swap drops every event in flight during the cutover. Ringside avoids that with a grace window. POST /v1/webhooks/{id}/rotate_secret mints a new secret, moves the old one into a previous_secret slot with an expiry and returns the new secret once. While that window is open the delivery worker signs each delivery with BOTH secrets and sends two v1= digests in one header. A receiver that still holds the old secret matches one digest, a receiver that has cut over to the new secret matches the other and neither drops an event.

curl -sS -X POST \
  https://api.fightclub.pro/v1/webhooks/wh_9f1c0a4d2b6e8f3a1c5d7e90/rotate_secret \
  -H "Authorization: Bearer ko_REPLACE_WITH_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"grace_minutes": 60}'

grace_minutes is optional and defaults to the endpoint rotation_grace_minutes (which itself defaults to 30). It is clamped to the integer range 0-1440 (24 hours); an out-of-range value returns 400 invalid_rotation_grace. The receiver code above already handles this: hold the new secret in RINGSIDE_WEBHOOK_SECRET and the old one in RINGSIDE_WEBHOOK_SECRET_PREV for the length of the window, then unset the previous one after it expires. The previous_secret_expires_at in the response tells you exactly when both-digest signing stops.

Rotation also heals

Rotating a secret resets the endpoint failure_streak to 0, the same as manually setting status back to active. A rotation is a clean way to clear a streak and refresh the key in one call.

Step 6: Inspect and replay deliveries

Every attempt is recorded. List the delivery rows for an endpoint with GET /v1/webhooks/{id}/deliveries, newest first. The rows carry the outcome fields you need to debug a flaky receiver (the response status your endpoint returned, a truncated response body preview, the error text and the attempt count) but never the payload body or the signature, so the log itself leaks nothing sensitive.

curl -sS \
  "https://api.fightclub.pro/v1/webhooks/wh_9f1c0a4d2b6e8f3a1c5d7e90/deliveries?status=dead&limit=20" \
  -H "Authorization: Bearer ko_REPLACE_WITH_YOUR_KEY"
Query paramDefaultNotes
limit50max 200; a non-positive value returns 400 invalid_limit
status(all)one of pending, delivering, delivered, retry_scheduled, dead; anything else is 400 invalid_status_filter
event_type(all)exact match, e.g. wallet.empty
after(none)pass the previous next_cursor to page; a cursor from another endpoint is silently ignored, not leaked

A delivery that exhausted its retries sits in dead. Replay it with POST /v1/webhooks/{id}/deliveries/{delivery_id}/retry. This inserts a fresh delivery row that reuses the original event_id (so your dedupe on evt_ still works) and the worker picks it up on the next tick. Only dead rows are eligible: retrying a delivered, pending or any non-dead row returns 409 delivery_not_retryable with type conflict_error.

curl -sS -X POST \
  "https://api.fightclub.pro/v1/webhooks/wh_9f1c0a4d2b6e8f3a1c5d7e90/deliveries/wdl_77a0b1c2d3e4f5a6b7c8d9e0/retry" \
  -H "Authorization: Bearer ko_REPLACE_WITH_YOUR_KEY"

Retry, backoff and auto-disable

Understanding the delivery worker tells you what your receiver has to tolerate. A delivery succeeds when your endpoint answers 2xx within a 15 second timeout. Any other status, a timeout or a connection error is a failure, and the row is rescheduled on a fixed backoff curve with a little jitter. After 6 total attempts the delivery flips to dead and stops on its own.

Each delay carries +/-10% jitter so many failing receivers do not retry in lockstep.
After failure #Next retry in
11 minute
25 minutes
330 minutes
42 hours
56 hours
6(none) -> status dead

Separately from per-delivery retries, each endpoint tracks a consecutive-failure streak across deliveries. When that streak reaches 25 the endpoint auto-disables: its status becomes disabled_after_failures and any rows already queued for it short-circuit to dead with error_text = endpoint_paused. One success resets the streak to 0. To bring a disabled endpoint back, PATCH it with status: "active" (which also clears the streak) or rotate its secret.

Design for at-least-once, out-of-order delivery

Retries and manual replays mean the same event id can arrive more than once, and concurrent fan-out means events can land out of order. Make your handler idempotent: dedupe on the evt_ envelope id and treat each event as a fact to reconcile against current state, not a step in a sequence.

Failure modes at a glance

Every signature fails to match
You are almost certainly re-serializing the body. Sign the raw bytes you received, not a re-encoded parse of them. Confirm the base is t + "." + raw with a literal period and the digest is lowercase hex.
Intermittent failures right after a rotation
Hold both secrets through the grace window. The header carries two v1= digests during rotation; accept a match against either. Drop the previous secret only after previous_secret_expires_at.
Old deliveries rejected as stale
Expected. The 300 second tolerance rejects a t more than 5 minutes off your clock. If legitimate deliveries are rejected, your server clock has drifted; sync NTP.
Endpoint went `disabled_after_failures`
Your receiver returned non-2xx (or timed out) 25 deliveries in a row. Fix the receiver, then PATCH status active or rotate the secret to re-enable and clear the streak.
Create returns 409 `webhook_url_in_use`
You already have an endpoint on that exact URL. Update or delete the existing one, or register a distinct path.
Create returns `insecure_webhook_url`
The URL is not https, or it is SSRF-unsafe (private/internal/metadata host). Use a public https URL.

On errors, branch on the machine-readable code field inside error, never the human message. The X-Request-Id response header is what you quote to support; it is never a body field.

Limits and defaults to remember

SettingValue
Signature schemeX-FC-Signature: t=<unix>,v1=<hex hmac_sha256>
Signed base<t>.<raw_body> (literal period join)
Replay tolerance300 seconds (5 minutes)
Delivery timeout15 seconds; non-2xx or timeout counts as a failure
Max attempts per delivery6, then dead
Auto-disable streak25 consecutive endpoint failures
Rotation grace0-1440 minutes, default 30
Deliveries listdefault 50, max 200
Endpoints listdefault 20, max 100
Secret formatwhsec_ + 64 hex chars (32 bytes), shown once on create + rotate
Required scope (write)api:webhooks; reads use api:read