Webhooks
Get notified when work finishes, without holding a connection open.
Register an endpoint
curl -sX POST $RTFC/webhooks \
-H "X-Auth-Token: $TOKEN" -H 'Content-Type: application/json' \
-d '{"url": "https://yourapp.example/hooks/rtfc",
"events": ["claim.completed"],
"description": "production"}'{"id": "…", "url": "…", "events": ["claim.completed"], "active": true,
"secret": "whsec_…", "secret_prefix": "whsec_Sr3ozl-Y"}The secret is returned exactly once - store it now. Later reads show only the prefix, so a leaked listing cannot be used to forge signatures.
Subscribing to no events means all of them.
Events
| Event | When |
|---|---|
claim.completed | A claim finished research and has a best answer |
debate.ingest_completed | Ingest finished and every claim is settled |
debate.paused | Research was paused (credit or quota) |
quota.threshold_reached | The account crossed 80% or 100% of its allowance |
New events may be added. Ignore an event you do not recognise rather than erroring - that is the contract.
{
"id": "evt_0a86dae901e64fae99aebaa64b376cab",
"event": "claim.completed",
"created_at": "2026-08-24T12:31:08.512Z",
"data": {"debate_id": "…", "claim_id": "…", "verdict": "false",
"confidence": 0.97, "explanation": "…"}
}Headers: X-RTFC-Event, X-RTFC-Delivery, X-RTFC-Signature.
Verify the signature - do not skip this
Anyone can POST to your URL. The signature is what proves a delivery came from us.
X-RTFC-Signature: t=1735689600,v1=5257a869e7ecebeda32affa62cdca3fa…v1 is HMAC-SHA256 over "{t}.{raw body}" - the timestamp is inside the signed payload on purpose. Signing the body alone would let anyone who once captured a valid delivery replay it forever, since the signature would stay valid.
import hashlib, hmac, time
def verify(secret: str, raw_body: bytes, header: str, tolerance: int = 300) -> bool:
try:
parts = dict(p.split("=", 1) for p in header.split(","))
ts, received = int(parts["t"]), parts["v1"]
except Exception:
return False
if abs(time.time() - ts) > tolerance: # reject replays
return False
expected = hmac.new(secret.encode(),
f"{ts}.".encode() + raw_body,
hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received) # constant timeconst crypto = require("crypto");
function verify(secret, rawBody, header, tolerance = 300) {
const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
const ts = parseInt(parts.t, 10);
if (Math.abs(Date.now() / 1000 - ts) > tolerance) return false;
const expected = crypto.createHmac("sha256", secret)
.update(`${ts}.`).update(rawBody).digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}Three things people get wrong:
- Compute over the RAW body. Not a parsed-and-re-serialised object - key order
and whitespace differ and the HMAC will not match.
- Check the timestamp. Without it the replay protection does nothing.
- Compare in constant time.
==leaks timing.
Delivery, retries, duplicates
Respond 2xx promptly. Anything else is retried with backoff - 1m, 5m, 30m, 2h, 6h
- then given up. A
4xxother than 408/429 is not retried: you rejected the
request itself and an identical resend cannot help.
Do the real work asynchronously and acknowledge fast. A slow receiver looks like a failing one.
Delivery is at-least-once. A receiver that succeeds after the connection drops will be retried, so deduplicate on the payload's id - it is stable across retries of the same delivery.
Debugging
curl -sX POST $RTFC/webhooks/$ID/test -H "X-Auth-Token: $TOKEN" # send one now
curl -s $RTFC/webhooks/$ID/deliveries -H "X-Auth-Token: $TOKEN" # what happenedThe delivery log shows status, attempt count, next retry and error for each attempt - enough to tell "we never sent it" from "your endpoint 500'd".
POST /v1/webhooks/{id}/rotate-secret issues a new secret. The old one stops working immediately, so deploy the new one to your receiver first.