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 Webhook section on the project's Config tab 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)¶
A webhook payload is the same public TraceEventResponse event object returned in trace detail timelines, so it carries the Tuor trace_id (also sent in the X-Tuor-Trace-Id header) but not the trace's spans / trace_config / model_output. Map deliveries back to your own rows by storing the trace_id you receive at ingest time.
1. Record the trace id at ingest time¶
Creating a trace returns its Tuor id. Persist it against your own row:
const trace = await tuor.createTrace({ project_id, spans, model_output, trace_config });
await db.runs.update(internalRunId, { tuor_trace_id: trace.id });
2. Look up the row when the webhook arrives¶
app.post("/tuor", async (req, res) => {
const { trace_id, event_type, final_output_after } = req.body;
await db.runs.updateByTuorTraceId(trace_id, {
verdict: event_type,
final_output: final_output_after,
});
res.sendStatus(200);
});
The verdict (event_type) and authoritative output (final_output_after) are in the event itself, so the common case needs no callback.
Payload structure¶
Every webhook delivery uses the same public TraceEventResponse shape returned in trace detail timelines. Datetimes are ISO 8601 strings, and enum values are plain strings:
{
"id": "evt_abc123xyz",
"org_id": "org_2xyz789",
"trace_id": "trace_555aaa",
"event_type": "review.corrected",
"actor_type": "user",
"actor_id": "user_2def3gh",
"occurred_at": "2026-05-20T14:19:55Z",
"trace_version_before": 1,
"trace_version_after": 2,
"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": [
{
"op": "modify",
"path": "company",
"from": "Gogle Inc.",
"to": "Google Inc."
}
]
}
The payload carries the event and its before/after deltas, plus trace_id to link back to the trace — but not the trace's static fields (spans, model_output, trace_config, output_schema). Fetch GET /v1/traces/{trace_id} if a receiver needs the full canonical trace object.
webhook.test payloads are a separate, smaller shape: they include event_type (webhook.test), action, occurred_at, and project. A test ping has no real event or trace, so it carries no id and its X-Tuor-Event-Id / X-Tuor-Trace-Id headers are blank.
Deliveries enqueued before the canonical event payload change retain their previous shape on manual retry — payloads are point-in-time snapshots.
Field reference¶
| Field | Description |
|---|---|
id |
ID of the trace event. Stable across manual retries, so use it for idempotent receiver-side processing. Also sent in the X-Tuor-Event-Id header. |
org_id |
Organization that owns the trace event. |
trace_id |
The trace this event belongs to. Use it to correlate to your own records. Also sent in the X-Tuor-Trace-Id header. |
trace_version_before / trace_version_after |
The trace's optimistic-locking version before and after the event. trace_version_after increases by one per mutation, so use it to order a trace's events. |
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 change operations (see Correction diff format). null otherwise. |
Correction diff format¶
correction_diff compares the pre-correction baseline (the trace's previous corrected_output, or its model_output if it had never been corrected) against the new correction. It is a list of change operations; each entry carries an op field and comes in one of three shapes:
op |
Fields | Meaning |
|---|---|---|
modify |
path, from, to |
The value at path changed from from to to. |
add |
path, value |
A key or array element was added at path with value. |
remove |
path, value |
The key or array element at path holding value was removed. |
path is a dot path into the output object; array elements are indexed, e.g. line_items[1].
[
{ "op": "modify", "path": "company", "from": "Gogle Inc.", "to": "Google Inc." },
{ "op": "add", "path": "invoice.tax_id", "value": "12-345678" },
{ "op": "remove", "path": "line_items[1]", "value": { "description": "Duplicate" } }
]
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 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.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["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.
- One automatic retry, transport failures only: if the connection is refused, DNS resolution fails, or the attempt times out before your server returns a status line, Tuor retries once, immediately. Once your server responds with any HTTP status — including a non-2xx — that response is final and is never retried automatically.
- No automatic retries past that: a delivery that settles as
status = "failed"(or stalls — see below) stays there until it's retried by a human or an API call. Use the Webhook section on the project's Config tab in the Web Console, orPOST /v1/webhook-deliveries/{delivery_id}/retry. - HTTP timeout: 10 seconds per attempt.
- Considered successful: any
2xxresponse. Anything else is recorded as a failure. - A
pendingrow can stall: delivery runs as an in-process background task, so a crash or restart mid-send can abandon a row inpendingwith no further attempt coming. Tuor never sweeps these tofailed— the stored status is left exactly as it was written. The Web Console infers a "stalled" label for anypendingrow whose last attempt (or creation time, if it never got that far) is more than 5 minutes old, and offers the same Retry action afailedrow gets; this label is inferred for display only and is never written back to the row.
Delivery history and manual retry controls are available in the Webhook section on the project's Config tab in the Web Console. The same data is available for automation:
| Method | Path | Use when |
|---|---|---|
GET |
/v1/projects/{project_id}/webhook-deliveries |
List recent deliveries for a project, newest first. Narrow with ?status=pending, failed, or delivered. |
GET |
/v1/projects/{project_id}/webhook-deliveries/stats |
Counts of deliveries by status, for alerting without polling the full log. |
POST |
/v1/webhook-deliveries/{delivery_id}/retry |
Reset a delivery to pending and schedule another attempt. Works on failed rows and stalled pending rows alike. |
GET .../webhook-deliveries returns WebhookDeliveryResponse rows: id, project_id, trace_id, event_id, status, attempt_count, last_attempt_at, delivered_at, response_status, response_body, error, created_at. That's every stored column except the delivery's payload. For a delivery that never reached your server, call GET /v1/traces/{trace_id} (using the row's trace_id) and match event_id against an entry in the response's events timeline — this reconstructs the event in its current shape, which is exact for recent deliveries but won't reproduce the previous, point-in-time shape of a delivery enqueued before the canonical event payload change (see above).
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 the event
id(or theX-Tuor-Event-Idheader). Manual retries reuse the sameid, and so can the single automatic retry Tuor makes after a connection-level failure — that retry fires precisely because Tuor can't tell whether your server already received the first attempt. - Process within 10 seconds; for heavy work, hand off to your own background queue and return immediately.
- Handle
webhook.testevents gracefully — they carry no trace data and identify the project being tested.