Outbound Webhooks¶
Webhooks let your systems react to reviewer verdicts in real time — for example, updating a record the instant a reviewer corrects a model output, or paging an on-call engineer when a critical trace is rejected.
This page covers receiver setup, payload shape, signature verification, and delivery semantics.
How webhooks work¶
- You configure a webhook URL and secret on a project.
- Whenever a reviewer submits a verdict in the review workspace —
approve,reject, orcorrect— Tuor enqueues a background job. - The background task dispatches an HTTP
POSTrequest asynchronously to the configured URL with a JSON body and signature headers. - Delivery status is recorded in Tuor and can be inspected from the Web Console.
sequenceDiagram
participant Reviewer as Review Workspace
participant API as Tuor API
participant Q as Background Task
participant App as Your Server
Reviewer->>API: Submit review (approve/reject/correct)
API->>API: Persist trace + event
API->>Q: Enqueue webhook delivery
Q->>App: POST <webhook_url> (signed JSON)
App-->>Q: 2xx OK
Q->>API: Record delivery (success)
Outbound security¶
Tuor only delivers to publicly routable HTTPS hosts. Loopback addresses (localhost, 127.0.0.1, ::1) and RFC-1918 private ranges (10.0.0.0/8, 192.168.0.0/16, 172.16.0.0/12) are rejected to prevent SSRF. URLs must use the https:// scheme and may not include embedded credentials.
Configure a webhook¶
Configure the webhook URL and secret from the project's Webhook settings in the Web Console. For provisioning automation, the same fields are available on PUT /v1/projects/{project_id}:
curl -X PUT "https://api.tuor.dev/v1/projects/proj_abc123" \
-H "X-API-Key: $TUOR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"webhook_url": "https://hooks.example.com/tuor",
"webhook_secret": "whsec_8f3...",
"webhook_active": true
}'
| Field | Notes |
|---|---|
webhook_url |
The HTTPS endpoint that will receive POST requests. Up to 2048 characters. |
webhook_secret |
A shared secret used for HMAC-SHA256 signing. Up to 512 characters. |
webhook_active |
Boolean. Set false to stop deliveries without losing the URL or secret. |
You can also send a one-off test ping from the Web Console, or via POST /v1/projects/{project_id}/test-webhook when provisioning by API.
Tying webhook events back to your system (the traceback loop)¶
The cleanest way to map a webhook delivery to a row in your own database is to round-trip an identifier through trace_config. Every value you put in trace_config at ingest time is echoed back to you on the webhook payload under trace.trace_config.
1. Stamp your identifiers at ingest time¶
{
"project_id": "proj_xyz",
"model_input": { "content": "Compute net income" },
"model_output": { "amount": 250000 },
"trace_config": {
"model": "gpt-4o",
"internal_run_id": "run_01j2abcde",
"user_session_id": "sess_999xxx",
"prompt_version_id": "prm_abc123"
}
}
2. Look up the row when the webhook arrives¶
app.post("/tuor", async (req, res) => {
const { trace } = req.body;
const runId = trace.trace_config?.internal_run_id;
await db.runs.update(runId, {
verdict: req.body.event_type,
final_output: req.body.final_output_after,
});
res.sendStatus(200);
});
You never need to keep a separate mapping table from Tuor trace_id to your row — the trace itself carries the link.
Payload structure¶
Every webhook delivery is a JSON object with the following shape:
{
"event_id": "evt_abc123xyz",
"event_type": "review.corrected",
"actor_type": "user",
"actor_id": "user_2def3gh",
"occurred_at": "2026-05-20T14:19:55Z",
"status_before": "pending",
"status_after": "corrected",
"final_output_before": null,
"final_output_after": { "company": "Google Inc." },
"corrected_output_before": null,
"corrected_output_after": { "company": "Google Inc." },
"correction_diff": [
{
"path": "company",
"from": "Gogle Inc.",
"to": "Google Inc."
}
],
"trace": {
"id": "trace_555aaa",
"model_input": { "content": "Extract company name" },
"model_output": { "company": "Gogle Inc." },
"trace_config": {
"model": "gpt-4o",
"internal_run_id": "run_01j2abcde",
"user_session_id": "sess_999xxx",
"prompt_version_id": "prm_abc123"
}
}
}
webhook.test payloads are intentionally smaller: they include event_id, event_type, action, occurred_at, project, and trace: null.
Field reference¶
| Field | Description |
|---|---|
event_id |
ID of the underlying trace event. It is stable across manual retries, so use it for idempotent receiver-side processing. |
event_type |
One of review.approved, review.rejected, review.corrected, webhook.test. |
actor_type |
user, api_key, or system. |
actor_id |
The user ID or API key ID that performed the action. |
occurred_at |
ISO 8601 timestamp of the underlying event. |
status_before / status_after |
Trace status before and after the review. |
final_output_before / final_output_after |
The authoritative output, before and after. null is meaningful (e.g. after a reject). |
corrected_output_before / corrected_output_after |
The stored correction, before and after. |
correction_diff |
On review.corrected only: a list of field-level changes. null otherwise. |
trace |
A compact snapshot of the trace: its id, model_input, model_output, and trace_config. |
Event types¶
| Event | Fires when |
|---|---|
review.approved |
A reviewer (or API caller) submits an approve action. |
review.rejected |
A reviewer submits a reject action. |
review.corrected |
A reviewer submits a correction. |
webhook.test |
You hit POST /v1/projects/{project_id}/test-webhook to verify wiring. |
Only review outcomes produce webhook deliveries. Resets, deletes, restores, and tag changes are recorded in the trace event log but do not call your webhook.
Request headers¶
Every webhook request includes:
| Header | Purpose |
|---|---|
Content-Type: application/json |
Body is always JSON. |
X-Tuor-Event-Id |
Unique event identifier (matches the event_id in the body). |
X-Tuor-Trace-Id |
The trace's ID. Empty string for webhook.test events. |
X-Tuor-Signature |
HMAC-SHA256 signature of the raw request body, formatted sha256=<hex_digest>. |
Signature verification¶
Always verify the signature before trusting a webhook body. Tuor signs the raw request body bytes with HMAC-SHA256 using the webhook_secret you configured on the project.
Use a constant-time comparison; never compare hex strings with == in user code.
Python¶
import hmac
import hashlib
def verify_tuor_signature(raw_body: bytes, header_value: str, secret: str) -> bool:
if not header_value.startswith("sha256="):
return False
expected = hmac.new(
secret.encode("utf-8"),
raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(header_value.split("=", 1)[1], expected)
TypeScript¶
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyTuorSignature(
rawBody: Buffer,
headerValue: string,
secret: string,
): boolean {
if (!headerValue.startsWith("sha256=")) return false;
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
const provided = headerValue.slice("sha256=".length);
const a = Buffer.from(expected, "hex");
const b = Buffer.from(provided, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}
Important: signatures are computed over the raw bytes of the request body, before any JSON parsing. Frameworks that re-serialize the body will produce a different digest. In Express, use
express.raw({ type: "application/json" })on the webhook route; in FastAPI, readawait request.body()before parsing.
Complete raw-body handlers¶
Mount the raw-body webhook route before any global JSON parser middleware.
import express from "express";
const app = express();
const webhookSecret = process.env.TUOR_WEBHOOK_SECRET!;
app.post("/tuor", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.header("X-Tuor-Signature") ?? "";
if (!verifyTuorSignature(req.body, signature, webhookSecret)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
// Store event.event_id idempotently, then enqueue your own processing.
return res.sendStatus(204);
});
app.use(express.json());
import json
import os
from fastapi import FastAPI, Header, HTTPException, Request, Response
app = FastAPI()
webhook_secret = os.environ["TUOR_WEBHOOK_SECRET"]
@app.post("/tuor")
async def tuor_webhook(
request: Request,
x_tuor_signature: str = Header(default=""),
) -> Response:
raw_body = await request.body()
if not verify_tuor_signature(raw_body, x_tuor_signature, webhook_secret):
raise HTTPException(status_code=401, detail="invalid signature")
event = json.loads(raw_body)
# Store event["event_id"] idempotently, then enqueue your own processing.
return Response(status_code=204)
Delivery semantics¶
- Asynchronous: review API calls return as soon as the trace is persisted; webhooks fire shortly after in a background task.
- No automatic retries: a failed delivery is recorded with
status = "failed"and is not retried automatically. Use the Webhooks page in the Web Console to re-attempt it manually. - HTTP timeout: 10 seconds per attempt.
- Considered successful: any
2xxresponse. Anything else is recorded as a failure.
Delivery history and manual retry controls are available on the project's Webhooks page in the Web Console.
Receiver checklist¶
- Listen on a stable, publicly routable HTTPS URL.
- Respond with
2xxonly after you've durably stored the event (or are sure you can drop it). - Verify
X-Tuor-Signatureon every request — constant-time compare. - Deduplicate on
event_idin case you manually retry a delivery. - Process within 10 seconds; for heavy work, hand off to your own background queue and return immediately.
- Handle
webhook.testevents gracefully — they usetrace: nulland identify the project being tested.