Skip to main content

Webhooks

Pass webhookUrl and a unique webhookSecret when you start a debate. The server will POST the full session to that URL when the debate finishes, whether it succeeds or fails. The server signs every delivery with HMAC-SHA256.

The contract

When you start a debate with webhookUrl, the server will POST a JSON body to that URL once the debate reaches a terminal state, either COMPLETED or FAILED. You must supply webhookSecret with 32 to 256 characters. The API never returns the destination URL or secret.

  • Method: POST
  • Content-Type: application/json
  • Body: the full session, identical to the response from GET /v1/debates/:id
  • Triggered on: success or failure
  • Timeout: your endpoint must return a 2xx within 10 seconds
curl -X POST https://rigordesk.com/api/v1/debates \
 -H "Authorization: Bearer $DBT_KEY" \
 -H "Content-Type: application/json" \
 -H "Idempotency-Key: $(uuidgen)" \
 -d '{
 "prompt": "Refactor this React component",
 "assemblyId": "9fa4f385-…",
 "webhookUrl": "https://my-app.com/webhooks/debate",
 "webhookSecret": "replace-with-at-least-32-random-characters"
 }'
POST /webhooks/debate HTTP/1.1
Host: my-app.com
Content-Type: application/json
User-Agent: rigordesk-webhook/1
X-RigorDesk-Event: debate.complete
X-RigorDesk-Delivery: 4aa863cc-5cf1-4ea8-a0d2-a13bc4db1513
X-RigorDesk-Timestamp: 1784822100
X-RigorDesk-Signature: v1=9fd4…

{
 "id": "efb4097e-e58c-49b7-885d-860753a0d7ed",
 "status": "COMPLETED",
 "title": "Verify Claims About Database Obsolescence",
 "userPrompt": "…the complete original prompt…",
 "finalAnswer": "…the chairman's verdict…",
 "totalCost": 0.42,
 "totalTokens": 12100,
 "rounds": [ /* full transcript */ ]
}

Inspecting the result

The body is the same shape as GET /v1/debates/:id, including both the concise title and complete original userPrompt. Check status:

statusMeaning
COMPLETEDDebate ran to completion. finalAnswer has the verdict.
FAILEDDebate errored out (budget exhausted, model failure, etc.). finalAnswer will be null.

Retries and reliability

If your endpoint returns a non-2xx status, or times out, the server does not currently retry. Webhooks are fire-and-forget. For production use, design for at-most-once delivery:

  • Persist the result locally on receipt by writing to a DB row keyed by the debate id.
  • Use the Idempotency-Key mechanism as a backup. If you get a duplicate webhook, your DB write is idempotent on the id.
  • Fall back to polling if the webhook never arrives within your SLA. Poll GET /v1/debates/:id/status every 60 seconds until isComplete: true.

Verifying a delivery

Compute HMAC-SHA256 over <X-RigorDesk-Timestamp>.<raw_request_body> with the exact webhookSecret you supplied when you created the debate. Compare the expected value with X-RigorDesk-Signature using a constant-time comparison. Reject timestamps older than five minutes to limit replay attacks. Parse JSON only after verification.

A reference handler in Node

import express from "express";
import crypto from "node:crypto";

const app = express();
const webhookSecret = process.env.RIGORDESK_WEBHOOK_SECRET;

app.post(
 "/webhooks/debate",
 express.raw({ type: "application/json", limit: "5mb" }),
 async (req, res) => {
 const timestamp = req.get("X-RigorDesk-Timestamp") || "";
 const supplied = req.get("X-RigorDesk-Signature") || "";
 const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
 if (!webhookSecret || !timestamp || ageSeconds > 300) {
 return res.status(400).send("invalid timestamp");
 }

 const expected = `v1=${crypto
 .createHmac("sha256", webhookSecret)
 .update(`${timestamp}.${req.body.toString("utf8")}`)
 .digest("hex")}`;
 const valid =
 supplied.length === expected.length &&
 crypto.timingSafeEqual(Buffer.from(supplied), Buffer.from(expected));
 if (!valid) return res.status(401).send("invalid signature");

 const debate = JSON.parse(req.body.toString("utf8"));
 // Idempotent upsert keyed by debate.id
 await db.debates.upsert({
 where: { id: debate.id },
 create: {
 id: debate.id,
 status: debate.status,
 verdict: debate.finalAnswer,
 cost: debate.totalCost,
 transcript: debate,
 },
 update: {
 status: debate.status,
 verdict: debate.finalAnswer,
 cost: debate.totalCost,
 transcript: debate,
 },
 });

 // Acknowledge fast, anything heavy should be queued, not blocking
 res.status(200).send("ok");
 }
);

app.listen(3000);

A reference handler in Python with FastAPI

from fastapi import FastAPI, Request, HTTPException
import hashlib
import hmac
import json
import os
import time

app = FastAPI()
webhook_secret = os.environ["RIGORDESK_WEBHOOK_SECRET"].encode()

@app.post("/webhooks/debate")
async def receive(request: Request):
 timestamp = request.headers.get("x-rigordesk-timestamp", "")
 supplied = request.headers.get("x-rigordesk-signature", "")
 raw_body = await request.body()
 try:
 if abs(time.time() - int(timestamp)) > 300:
 raise ValueError()
 except ValueError:
 raise HTTPException(status_code=400, detail="invalid timestamp")
 digest = hmac.new(
 webhook_secret,
 timestamp.encode() + b"." + raw_body,
 hashlib.sha256,
 ).hexdigest()
 if not hmac.compare_digest(supplied, f"v1={digest}"):
 raise HTTPException(status_code=401, detail="invalid signature")

 debate = json.loads(raw_body)

 # Idempotent upsert
 await db.execute(
 """
 INSERT INTO debates (id, status, verdict, cost, transcript, received_at)
 VALUES ($1, $2, $3, $4, $5, NOW())
 ON CONFLICT (id) DO UPDATE
 SET status = EXCLUDED.status,
 verdict = EXCLUDED.verdict,
 cost = EXCLUDED.cost,
 transcript = EXCLUDED.transcript,
 received_at = NOW()
 """,
 debate["id"], debate["status"], debate.get("finalAnswer"),
 debate.get("totalCost", 0), debate,
 )
 return {"ok": True}

Polling as a fallback

If you don't pass webhookUrl, you must poll. The recommended pattern is:

  1. Poll GET /v1/debates/:id/status every 3 to 5 seconds.
  2. Stop polling when isComplete: true or after a reasonable timeout (e.g. 5 minutes for a 2-round Refinement, 15 minutes for a 4-round Oxford Union).
  3. On timeout, log the debate id and check GET /v1/debates (list) to see if it completed in the background.