Skip to content

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

  1. You configure a webhook URL and secret on a project.
  2. Whenever a reviewer submits a verdict in the review workspace — approve, reject, or correct — Tuor enqueues a background job.
  3. The background task dispatches an HTTP POST request asynchronously to the configured URL with a JSON body and signature headers.
  4. 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, read await 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 2xx response. 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 2xx only after you've durably stored the event (or are sure you can drop it).
  • Verify X-Tuor-Signature on every request — constant-time compare.
  • Deduplicate on event_id in 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.test events gracefully — they use trace: null and identify the project being tested.