Webhooks

View as Markdown

Telltale sends signed events to HTTPS endpoints you register on the Webhooks tab. Deliveries follow Standard Webhooks, so any Standard Webhooks library can verify them, and /adapters/v1/server.js includes verifyWebhook() with no dependencies.

Receive and verify

Download the server adapter as the quickstart shows (server.js and identifiers.js in lib/telltale, with the one-line package.json that marks the folder as ES modules, so it also loads in a CommonJS project). Verify the exact raw body before parsing JSON, then deduplicate on webhook-id:

JavaScript
import { verifyWebhook } from './lib/telltale/server.js'; // from /adapters/v1/server.js

export async function POST(request) {
  const rawBody = await request.text();
  if (!verifyWebhook({ rawBody, headers: request.headers, secret: process.env.TELLTALE_WEBHOOK_SECRET })) {
    return new Response('invalid signature', { status: 400 });
  }
  const event = JSON.parse(rawBody);
  if (await alreadyProcessed(request.headers.get('webhook-id'))) return new Response(null, { status: 204 });
  await handle(event); // keep this quick; acknowledge with any 2xx
  return new Response(null, { status: 204 });
}

Webhook payloads describe what happened (for example a verdict change or completed usage); they never authorize an action and never contain session tokens or server keys.

Delivery format

Signed events follow Standard Webhooks. Each delivery is a JSON POST with webhook-id, webhook-timestamp and webhook-signature. The signature is v1, followed by a comma and the base64 HMAC-SHA256 of webhook-id.webhook-timestamp.body, keyed by the base64-decoded bytes after the whsec_ prefix of your signing secret. webhook-id equals the payload id and stays the same on every retry, so deduplicate on it. webhook-timestamp is the attempt time; reject deliveries more than five minutes from your clock. Any 2xx acknowledges; other responses and timeouts retry with backoff, up to eight attempts in all (see Delivery, cancellation and retry). Event types: session.verdict_changed, session.identified, usage.recorded, agent_usage.recorded, agent_usage.summary (period since/until, half-open, with totals matching GET /api/v1/agent-usage.json, plus agentsUsed and per-agent rows; a controller is named only from signed or weak-marker attribution, otherwise controller is null and strength unattributed), account.seat_changed, account.agent_usage_started and account.agent_usage_threshold (account alerts from the agent-usage ledger, when they are on), account.agent_calls_failing (one account's MCP and API calls failed at or above your line in a UTC day, when an "Agent calls keep failing" alert rule is on), agent.first_seen (a named agent seen for the first time, when a "New named agent" alert rule is on), agent_call.recorded (one tool call through your MCP server; a usage event for charging your customers, off by default), agent_call.errors (an MCP tool or API route started failing: at least 5 failed calls and a quarter of its finished calls in 30 minutes, at most once per tool every 6 hours), agent_call.slow (an MCP tool or API route call ran longer than your slow-call limit, 30 s by default; at most once per tool every 6 hours; not an error), agent_call.new_account (an account your server named made its first MCP or API agent call; at most 10 a Site a day), agent_client.new (an MCP client name or API agent name first seen on the Site; at most 10 a Site a day), issue.regressed (an issue you resolved in Issues happened again), agent_feedback.received (an agent said what it needed and could not find, when agent feedback is on; the shape only, never the note), tool.completed, tool.suspect (three consecutive agent-side 4xx failures; the tool stays enabled until you decide), action.schema_changed, protection.decision (a protected verify decision that matches the endpoint's decision filter), review.resolved (a reviewer approved or rejected a held action), report.weekly (the weekly agent report's numbers, when it is on), alert.threshold_crossed (a goal you set was crossed, or an action is held for review) and webhook.test. GET /openapi.json describes the event under webhooks.

Session events carry account: {userId, accountId, verified, source}, or null before any identity. verified is true with source server-associated only after your backend called identify; a browser SDK identify() claim has verified false and source browser-supplied. When identity is first associated after a session.verdict_changed was already sent, a session.identified event follows with sessionId, the last-observed verdict and score, and account. It is sent at most once for a browser-supplied identity and at most once for a server-associated identity, so a browser claim followed by server association sends two events, the second with verified true. Controller changes alone never count as a sent verdict change. A session.verdict_changed to agent carries handoverLatencyMs (null when not measured) and, for browser evidence, handoverKind: same-context (the tab that turned agent had human evidence), quiet-context (another tab had gone quiet for 10 seconds or more) or parallel-context (a person was still active in another tab, so it is not a handover). A signature presented at SDK init or forwarded at verify sends this event once, when it first makes the session agent. Webhook deliveries are stored with the session change in one transaction, so nothing is sent for a change that was not saved. Identity never grants collection permission or authorization.

verifyWebhook({rawBody, headers, secret}) in /adapters/v1/server.js checks a delivery with node:crypto only: it accepts Node request headers or a fetch Headers object, compares in constant time, accepts any matching v1 entry in a multi-signature header, enforces the five-minute tolerance (toleranceSeconds changes it) and returns true or false. Pass the exact raw body, before JSON parsing. secret may be an array while you roll secrets on your side. Any Standard Webhooks library also works.

Sample payloads

Every delivery has the same envelope. id equals the webhook-id header, type is the event type, createdAt is when the event was emitted, and data depends on the type. IDs and values below are examples; the field names are exact.

JSON
{
  "id": "delivery_6f1c2a9e0b7d4c31",
  "type": "session.verdict_changed",
  "createdAt": "2026-10-25T14:03:11.482Z",
  "data": { "...": "one of the shapes below" }
}

session.verdict_changed

JSON
{
  "sessionId": "sess_8b2e41",
  "identity": { "userId": "usr_123", "accountId": "acct_456", "verified": true },
  "account": { "userId": "usr_123", "accountId": "acct_456", "verified": true, "source": "server-associated" },
  "previous": "unknown",
  "actionVerdict": "agent",
  "analyticsOnly": false,
  "verdict": "agent",
  "score": 99,
  "reasons": ["Browser reports automation control"],
  "driver": "Unattributed automation",
  "basis": "heuristic-v4.33",
  "weightedEvidence": { "version": "weighted-v1", "automation": 90, "human": 0, "threshold": 80, "automationEligible": true, "humanBlocked": false, "conflict": false, "families": [] },
  "agentEvidence": "automation-trace",
  "confidence": "high",
  "attribution": { "strength": "unattributed-automation", "controller": null, "operator": null, "kind": null },
  "handoverLatencyMs": 1840,
  "handoverKind": "same-context"
}

identity is null until your backend calls identify. actionVerdict is the verdict protection and chargeable use follow; agentEvidence and confidence say what backs an agent actionVerdict and its band (high or medium, never a probability), as on verify. weightedEvidence is the categorical evidence summary behind the verdict (point totals per evidence family against a fixed threshold), not a probability. handoverKind is present for browser evidence only; a change caused by a signature at SDK init or verify has no handoverKind.

session.identified

JSON
{
  "sessionId": "sess_8b2e41",
  "verdict": "agent",
  "score": 99,
  "account": { "userId": "usr_123", "accountId": "acct_456", "verified": false, "source": "browser-supplied" }
}

usage.recorded

JSON
{
  "usageId": "meter_3f9a…",
  "sessionId": "sess_8b2e41",
  "userId": "usr_123",
  "accountId": "acct_456",
  "actionId": "export_report",
  "units": 1,
  "source": "server-reported",
  "verdict": "agent",
  "basis": "heuristic-v4.33",
  "accountResponse": "default",
  "recordedAt": 1761401000000
}

agent_usage.recorded

JSON
{
  "usageId": "agentminute_51c0",
  "unit": "session_minute",
  "sessionId": "sess_8b2e41",
  "accountId": "acct_456",
  "accountSource": "server-associated",
  "category": "unattributed",
  "billingMode": "metered",
  "accountResponse": "default",
  "units": 1,
  "meteredUnits": 1,
  "toolId": null,
  "at": 1761401040000
}

unit is session_minute or tool_execution. meteredUnits is 0 when the category's billing mode is included. A session-minute is one unit however many accounts touched it. When a stronger account source (your server's identify beats the page's claim, which beats signed-out traffic) arrives later in the same minute, the same usageId is sent again with revised: true, previousAccountId and previousAccountSource, and the new accountId and accountSource. It is not a second unit: replace the row you hold for that usageId.

agent_usage.summary

JSON
{
  "since": 1760796000000,
  "until": 1761400800000,
  "periodBounds": "[since, until)",
  "asOf": 1761400800000,
  "window": "Oct 18 – Oct 24 UTC (last 7 days)",
  "windowDays": 7,
  "coverageStart": 1760796000000,
  "oldestRetainedAt": 1758900000000,
  "totals": { "toolExecutions": 4, "sessionMinutes": 12, "metered": { "toolExecutions": 4, "sessionMinutes": 12 }, "included": { "toolExecutions": 0, "sessionMinutes": 0 }, "byResponse": {}, "noAgentEvidence": 0, "notRecorded": 0 },
  "accounts": [{ "accountId": "acct_456", "source": "server-associated", "category": "unattributed", "currentResponse": "default", "meteredUnits": { "toolExecutions": 4, "sessionMinutes": 12 }, "includedUnits": { "toolExecutions": 0, "sessionMinutes": 0 }, "unitsByResponse": {} }],
  "categories": [{ "category": "unattributed", "currentMode": "metered", "meteredUnits": { "toolExecutions": 4, "sessionMinutes": 12 }, "includedUnits": { "toolExecutions": 0, "sessionMinutes": 0 } }],
  "agentsUsed": 0,
  "agents": [{ "controller": null, "strength": "unattributed", "category": "unattributed", "units": { "toolExecutions": 4, "sessionMinutes": 12 }, "meteredUnits": { "toolExecutions": 4, "sessionMinutes": 12 }, "includedUnits": { "toolExecutions": 0, "sessionMinutes": 0 }, "lastAt": 1761400700000 }]
}

account.seat_changed

JSON
{
  "accountId": "acct_456",
  "previous": "default",
  "response": "agent_seat",
  "changedAt": 1761401100000,
  "note": "Applies to usage ingested from now on; earlier usage keeps its snapshot. Not a charge, verdict or authorization."
}

account.agent_usage_started

JSON
{
  "siteId": "site_3b1d",
  "accountId": "acct_456",
  "accountName": "Acme Ltd",
  "counts": "billable",
  "units": 2,
  "actionId": "export_report",
  "startedAt": "2026-11-03T14:02:11.000Z",
  "ledgerEntryId": "ledger_5c0e91aa",
  "usageId": "meter_9f2c",
  "evidenceClass": "automation-trace",
  "howWeKnow": { "strength": "weak-marker", "label": "Unconfirmed" },
  "controller": "Claude in Chrome"
}

Sent once per account, when its first counted agent unit reaches the agent-usage ledger, if account alerts are on (Charge › Ledger › Account alerts). counts is billable (shown as chargeable in the dashboard; chargeable units only: AI agent verdicts at High confidence; the default) or agent (chargeable plus behaviour-only Medium-confidence units). A unit posted more than an hour after it happened updates the counts without an event.

account.agent_usage_threshold

JSON
{
  "siteId": "site_3b1d",
  "accountId": "acct_456",
  "accountName": "Acme Ltd",
  "counts": "billable",
  "threshold": 100,
  "units": 104,
  "month": "2026-11",
  "ledgerEntryId": "ledger_77b2e0c1",
  "usageId": "meter_a41d",
  "evidenceClass": "verified-identity",
  "howWeKnow": { "strength": "signed", "label": "Verified" },
  "controller": "ChatGPT agent"
}

Sent when an account's counted agent units in a calendar month (UTC) reach one of the thresholds you set, once per threshold and month.

account.agent_calls_failing

JSON
{
  "alertId": "alert_6d1f",
  "goalId": "goal_a2c9",
  "accountId": "acct_456",
  "accountName": "Acme Ltd",
  "day": "2026-11-05",
  "calls": 40,
  "failed": 12,
  "errorRate": 30,
  "threshold": 25,
  "minCalls": 20,
  "channels": { "mcp": { "calls": 30, "failed": 10 }, "api": { "calls": 10, "failed": 2 } },
  "crossedAt": 1793923200000,
  "note": "Counts of your MCP server and API calls in one UTC day. No call content or error text."
}

Sent when an "Agent calls keep failing" alert rule is on (Settings › Alert rules) and one account's MCP server and API calls fail at or above your line in the current UTC day, with at least your minimum number of calls. Once per account per day. A refused call (your rule or a missing approval) is not a failure. The same alert also arrives as alert.threshold_crossed, rings the bell and, when the rule asks, posts to the Slack channel set for account alerts. Counts only: never the call content, error text or a person.

agent.first_seen

JSON
{
  "alertId": "alert_9c2e",
  "goalId": "goal_41aa",
  "controller": "claudeInChrome",
  "agentName": "Claude in Chrome",
  "scope": { "site": true },
  "seenAt": 1793793600000,
  "note": "Named from a signature, a page marker or a verified address; a tool name is not a person."
}

Sent with alert.threshold_crossed when a "New named agent" alert rule sees a named agent for the first time on the Site (or on one account, when the rule names one; then scope is {"accountId": …} and accountId is set). The agent is named only from signed, weak-marker or IP-verified attribution. The rule can also post one line to the Slack channel set for account alerts.

agent_call.recorded

JSON
{
  "callId": "routecall_5d1e",
  "channel": "mcp",
  "tool": "export_report",
  "outcome": "error",
  "errorClass": "tool_error",
  "refusedBy": null,
  "accountId": "acct_42",
  "accountSource": "server-associated",
  "userId": "user_7",
  "client": { "name": "Claude Code", "version": "2.0.1", "howWeKnow": "Declared" },
  "mcpSessionId": "mcpsession_8a2f",
  "billable": true,
  "durationMs": 412,
  "at": 1793793600000
}

Sent for each tool call your MCP server reports through Telltale for MCP servers. Shapes only: never the call's arguments, result, error text or headers. The client is what the MCP client declared about itself ("Declared"), never a person. outcome is ok, error (with errorClass tool_error, invalid_params, unknown_tool, internal_error, timeout or other), refused (refusedBy deny, hold or unreachable) or cancelled. It is a usage event for charging your customers: endpoints created after October 25, 2026 receive it only with billing turned on or when it is listed in events.

It is also sent for each agent call to your API reported through Telltale for APIs (channel api; calls with no agent evidence are not sent):

JSON
{
  "callId": "routecall_9c40",
  "channel": "api",
  "tool": "POST /v1/invoices",
  "actionId": "api_post_v1_invoices_3f2a9c1b",
  "method": "POST",
  "route": "/v1/invoices",
  "statusCode": 201,
  "outcome": "ok",
  "accountId": "acct_42",
  "accountSource": "server-associated",
  "userId": "user_7",
  "agent": { "name": "ChatGPT-User", "howWeKnow": "Verified", "confidence": "high" },
  "apiSessionId": "apisession_4b1d",
  "billable": true,
  "durationMs": 88,
  "at": 1793793600000
}

Shapes only: never bodies, query values, headers or tokens. agent says how we know (Verified by token, Verified, Published IP range or Declared) and the confidence band (high or medium; never a probability); name is null when the evidence names no agent. outcome is ok, error (errorClass server_error, not_allowed, not_found, rate_limited or client_error, from the status code) or refused (refusedBy deny, hold or unreachable).

agent_call.errors

JSON
{
  "channel": "mcp",
  "channelLabel": "MCP server",
  "tool": "create_invoice",
  "calls": 18,
  "errors": 9,
  "errorRate": 50,
  "windowMinutes": 30,
  "errorClass": "tool_error"
}

Sent when an MCP tool or an API route (tool is then the route, such as POST /v1/invoices) starts failing: in the last two 15-minute windows together at least 5 failed calls, and at least a quarter of its finished calls (refused and cancelled calls are left out). One event per tool or route every 6 hours, however many calls fail; the dashboard bell shows the same notice. Counts and the error class of the call that crossed the line only: never error text, arguments or results. Calls more than an hour old (a backfill) never raise it.

agent_call.new_account

JSON
{
  "channel": "api",
  "channelLabel": "API",
  "accountId": "acct_42",
  "accountSource": "server-associated",
  "tool": "GET /v1/reports/:id",
  "agent": { "name": "ChatGPT agent", "howWeKnow": "Verified" },
  "firstAt": 1793793600000
}

Sent the first time an account your server named (identify, or an agent token bound to an account) makes an MCP or API agent call. On the MCP channel it carries client (what the MCP client declared about itself, "Declared", or "Verified by token" for a call made with a valid agent token) instead of agent. A call more than an hour old never announces, and at most 10 are sent a Site a day, so an import cannot flood your endpoint. The account ID is the one your server sent; a client or agent name is never a person.

agent_call.slow

JSON
{
  "channel": "mcp",
  "channelLabel": "MCP server",
  "tool": "export_report",
  "durationMs": 41250,
  "limitMs": 30000,
  "slowCalls": 2,
  "accountId": "acct_42",
  "at": 1793793600000
}

Sent when an MCP tool call or an API route call (tool is then the route) runs longer than the Site's slow-call limit: 30,000 ms unless you set another limit in Issues › Limits, for the whole Site or for one tool. One event per tool or route every 6 hours; slowCalls is how many slow calls arrived together with this one. A slow call is not an error: error counts and error rates never change. The bell shows the same notice, and Issues lists the tool under Slow tool calls.

agent_client.new

JSON
{
  "channel": "mcp",
  "channelLabel": "MCP server",
  "client": { "name": "Cursor", "howWeKnow": "Declared" },
  "tool": "search_docs",
  "accountId": "acct_42",
  "firstAt": 1793793600000
}

Sent the first time a Site sees an MCP client name (what the client declared about itself; "Verified by token" when the call carried a valid agent token) or an API agent name (with how we know it). Calls more than an hour old never announce, and at most 10 are sent a Site a day. A client name names software, never a person.

agent_feedback.received

JSON
{
  "feedbackId": "agentfeedback_4f0c1b9e2d7a6c3b8e5f1a0d9c2b7e4f6a8d1c3e",
  "channel": "mcp",
  "channelLabel": "MCP server",
  "accountId": "acct_123",
  "client": { "name": "Claude Code", "howWeKnow": "Declared" },
  "length": 64,
  "at": 1793793600000
}

The shape only: the note the agent wrote stays in the dashboard (Issues and the bell). See Agent feedback.

issue.regressed

JSON
{
  "issue": "AI-7K2M",
  "title": "Create invoice: your server answered with an error",
  "category": "errors",
  "kind": "tool_failed",
  "channel": "mcp",
  "channelLabel": "MCP server",
  "tool": "create_invoice",
  "resolvedAt": 1793790000000,
  "at": 1793793600000
}

Sent when an MCP or API call belongs to an issue you marked Resolved in Issues, after you resolved it. Once per resolution (resolve it again to be told again) and at most once per issue every 6 hours. category is errors, slow or oversized. The Issues list already shows it as Unresolved, "Came back".

tool.completed

JSON
{
  "executionId": "exec_77d1",
  "tool": "export_report",
  "status": "succeeded",
  "source": "browser-reported",
  "httpStatus": 200
}

A reviewed tool in your pages (WebMCP or the in-page runner) finished. Tool calls through your own MCP server are agent_call.* events instead.

status is succeeded, failed or cancelled. httpStatus is present only when the page reported one. source is browser-reported: the page says the tool finished, which is not proof.

tool.suspect

JSON
{
  "toolId": "action_9c2e",
  "tool": "export_report",
  "kind": "action",
  "failures": 3,
  "lastStatus": 422
}

action.schema_changed

JSON
{
  "actionId": "action_9c2e",
  "version": 3
}

The action and any workflows using it are disabled until someone reviews the new shape.

report.weekly

JSON
{
  "period": { "since": 1760918400000, "until": 1761523200000, "periodBounds": "[since, until)", "dayBoundary": "UTC", "label": "Oct 20 – Oct 26", "title": "Last week (Oct 20 – Oct 26)" },
  "previous": { "since": 1760313600000, "until": 1760918400000 },
  "sessions": { "human": 120, "agent": 44, "unknown": 21, "total": 185, "percent": { "human": 64.9, "agent": 23.8, "unknown": 11.3 } },
  "minutes": { "human": 900, "agent": 310, "unknown": 75, "mixed": 15, "total": 1300, "percent": { "human": 69.2, "agent": 23.8, "unknown": 5.8, "mixed": 1.2 } },
  "agentMinutes": { "total": 310, "named": 40, "namedShare": 12.9, "byCategory": { "computer_use": 0, "browser_use": 40, "coding_agent": 0, "crawler": 0, "unattributed": 270 } },
  "completedWork": { "units": 52, "human": 30, "agent": 18, "unknown": 4, "agentVia": { "tool": 11, "ui": 6, "unknown": 1 } },
  "toolRuns": { "starts": 14, "succeeded": 12, "failed": 2 },
  "agentCalls": { "mcp": { "calls": 230, "failed": 9, "refused": 2 }, "api": { "calls": 118, "failed": 4, "refused": 0 } },
  "change": { "sessionsPoints": { "human": -3.1, "agent": 2.5, "unknown": 0.6 }, "minutesPoints": { "human": -2, "agent": 1.8, "unknown": 0.1, "mixed": 0.1 }, "agentMinutes": 41, "completedWorkUnits": 6, "toolRuns": 3 },
  "note": "Shares are of sessions or minutes, not probabilities. Changes are percentage points. Unknown is never counted as human."
}

Sent once per complete ISO week (Monday 00:00 UTC) while the weekly agent report is on (Settings › Weekly agent report, or POST /api/admin/report-schedule {siteId, weekly: true}). It carries summary numbers only: no accounts, pages, tools, sessions or users. Shares are of sessions or minutes; changes are percentage points.

alert.threshold_crossed

JSON
{
  "alertId": "alert_5b1f",
  "goalId": "goal_91aa",
  "type": "agent_share",
  "scope": { "accountId": "acct_456" },
  "threshold": 30,
  "windowDays": 7,
  "value": 33.3,
  "unit": "percent of activity minutes",
  "observed": { "human": 40, "agent": 30, "unknown": 18, "mixed": 2, "total": 90 },
  "message": "Agent share passed 30% for account acct_456: 33.3% over the last 7 days (mixed minutes included)",
  "crossedAt": 1761401100000,
  "note": "Observed counts, not probabilities. Unknown is never counted as human."
}

type is agent_share (the denominator includes unknown and mixed minutes), new_agent (observed carries the controller, named only from signed, weak-marker or IP-verified attribution; value and threshold are null), tool_failure_rate (failed share of finished runs, observed carries finished, failed, succeeded and cancelled), agent_stuck (Agent sessions that got stuck over the window: failed or refused tool calls, denied or never-approved holds, failed outcomes, failing page requests or a page opened again and again; value is the number of sessions, threshold the sessions you set, observed carries sessions, accounts, times and groups) account_calls_failing (one account's MCP server and API calls in the current UTC day: value is that account's failed share, threshold your line, observed carries accountId, calls, failed, channels and day; scope is {accountId}; it fires once per account per UTC day and also sends account.agent_calls_failing) or approval_waiting (below). A goal fires when it crosses its threshold, not on every check, and re-arms once the value falls back below it. message is a plain sentence for people; the value in it is the value when the goal crossed.

An approval_waiting goal fires once for each held check that is held for review (held by a rule or sent to review), optionally for one action only:

JSON
{
  "alertId": "alert_7c20",
  "goalId": "goal_3e41",
  "type": "approval_waiting",
  "scope": { "actionId": "invite_teammate" },
  "threshold": null,
  "windowDays": null,
  "value": null,
  "unit": null,
  "observed": { "decisionId": "decision_Qm9yZ2V0LW1lLW5vdA", "actionId": "invite_teammate", "verdict": "agent", "heldAt": 1761400000000, "decideBy": 1761400900000 },
  "message": "Held for review: Invite teammate · decide in 15 min",
  "crossedAt": 1761400002000,
  "note": "An action is held for review. Observed counts, not probabilities. Unknown is never counted as human."
}

scope is {actionId}; threshold, windowDays, value and unit are null. decideBy is the end of the 15-minute approval window.

protection.decision

JSON
{
  "decisionId": "decision_Qm9yZ2V0LW1lLW5vdA",
  "sessionId": "session_8f3c",
  "actionId": "delete_report",
  "decision": "deny",
  "verdict": "agent",
  "stale": false,
  "attribution": {"strength": "unattributed-automation", "controller": null, "operator": null},
  "account": {"userId": "usr_ada", "accountId": "org_acme", "verified": true},
  "identityMatched": true,
  "targetId": "report_1042",
  "policy": {"id": "policy_5d1e", "saved": true, "human": "allow", "agent": "deny", "unknown": "review", "stale": "review", "maxAgeMs": 60000},
  "unknownCause": null,
  "agentEvidence": "automation-trace",
  "confidence": "high",
  "stepUp": null,
  "review": null
}

agentEvidence says what backs an agent verdict (verified, automation-trace, injected-input, sustained-behaviour, behaviour-change, behaviour-corroborated, cursor-driven or behaviour; null otherwise), and confidence is its band: high for every value but behaviour, medium for behaviour, null when the verdict is not agent (a band, never a probability; see Verify a protected action). A behaviour-only agent never gets deny: the AI agent rule's Deny becomes review, with heldInsteadOfDeny: true. stepUp is {reason, expiresAt, report} while a review decision waits: your backend may settle it with its own check (step-up).

Sent for every protected verify decision that matches the endpoint's decision filter (deny and review by default; allow is optional) and, when set, its action filter. Checks sent with purpose: 'display' never send it. attribution names a controller only together with its strength; unknownCause explains an unknown verdict (no_events, consent_held, evidence_expired, inconclusive_evidence, legacy too_few_interactions or fresh_session) without relabelling it. The event never authorizes anything.

review.resolved

JSON
{
  "decisionId": "decision_Qm9yZ2V0LW1lLW5vdA",
  "sessionId": "session_8f3c",
  "actionId": "invite_teammate",
  "targetId": "inv_7Kq2",
  "status": "approved",
  "resolvedAt": 1761400000000,
  "expiresAt": 1761400900000
}

A reviewer approved or rejected a held action in Control › Approvals › Waiting, or your backend reported a step-up result: then the payload also carries "via": "step-up" and the method (passkey, mfa, turnstile, own_approval or other), and a passed step-up is already used, so there is no approval to redeem. The payload carries no approval token: read it with GET /api/v1/decisions/{decisionId} (the adapter's getDecision), then redeem it with redeemApproval. An approval is evidence, not authorization.

webhook.test

JSON
{
  "message": "Telltale test event"
}

Several endpoints and event filters

Usage events are off by default for new endpoints. An endpoint created after October 25, 2026 with events null receives every type except usage.recorded, agent_usage.recorded and agent_usage.summary (per-minute agent usage is high-volume billing data). Turn them on with billing: true, or list them in events. Endpoints created earlier keep receiving every type, as before; publicHook reports billing: true for them.

Protected-action filters. decisions (default ["review", "deny"]) selects which protection.decision events an endpoint receives; add "allow" to receive every decision. actions (default null for every action) limits them to up to 50 action IDs, for example ["delete_report"] for a page-me-on-delete endpoint. Both are saved with POST /api/admin/webhook and shown in Settings › Webhooks.

A Site can have up to 10 endpoints. Each has its own URL, whsec_ secret, rotation state, legacy-header setting and event types. events is a list of event type names, or null (the default) for every type, including types added later. webhook.test reaches every enabled endpoint, or only the one named by hookId. Admin calls take an optional hookId: POST /api/admin/webhook {siteId, url, events, legacySignature} updates the first endpoint (the original single-endpoint behaviour), {..., create: true} adds an endpoint, {..., hookId} updates that endpoint, {hookId, disable: true} disables one and {hookId, remove: true} deletes it and cancels its pending deliveries (reason Endpoint removed). disable requires hookId when the Site has more than one endpoint (400 otherwise); without hookId it disables the only endpoint. webhook-rotate, webhook-revoke and webhook-test accept hookId too; without it they act on the first endpoint.

Delivery, cancellation and retry

Each delivery is bound to the endpoint's URL at the moment the event was emitted. Disabling an endpoint, or saving it with a different URL, cancels its pending deliveries; re-enabling it never revives them. Only a failed delivery can be retried by hand: POST /api/admin/webhook-retry {id} answers 409 for a delivery that was delivered, is still queued or was cancelled (cancelled deliveries, including those cancelled by consent withdrawal, are never sent). POST /api/admin/webhook-retry {endpointId, failed: true} queues every failed delivery of one endpoint at once (the "Retry failed" button) and answers {queued, skipped, hookId}; skipped counts the deliveries the endpoint can no longer take. Both retries and "Send test" are recorded in the audit log. Before every attempt, and on a retry, the endpoint must still exist, be enabled, have the same URL and still subscribe to the event type (webhook.test excepted); otherwise the delivery is cancelled, or the retry is refused with 409. Failed attempts retry with backoff: about 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, 2 hours and 4 hours after each failure (±20% jitter), eight attempts and about 7.7 hours in all, after which the delivery is failed and only a manual retry sends it. A 2xx from an endpoint brings that endpoint's waiting retries forward, so they go out as soon as it is back. Pending deliveries are never removed to make room: each Site keeps up to 10,000 settled (delivered, failed or cancelled) deliveries, counted per Site, and up to 50,000 pending ones; past that, its oldest pending deliveries are marked failed with reason backlog_overflow and can be retried by hand.

Ordering

Delivery order is not guaranteed in general. Retries, concurrency (two at a time per endpoint) and manual retries can deliver an older event after a newer one, so receivers should order by the payload's createdAt (and the event's own timestamps, such as changedAt) rather than by arrival, and ignore an event older than the state they already hold. One case is ordered for you: for session.verdict_changed and session.identified (per sessionId), account.seat_changed (per accountId) and action.schema_changed (per actionId), an endpoint receives one subject's events of one type oldest first. A newer one waits while an older one is still pending, which can delay it until the older one is delivered, fails or is cancelled. A manual retry of an older failed delivery is sent after any newer event that was already delivered.

Erasure

Erasing a user or account (Settings › Data & privacy) deletes deliveries about that subject in every status, so a pending one is never sent. A delivery that only lists the subject among others, such as an account entry in agent_usage.summary, keeps the others and loses the subject's entries; pending ones are signed and sent with the redacted body. Totals are not recalculated.

Deliveries are sent concurrently, at most two at a time per endpoint, four per Site and eight overall, in round-robin order across Sites, so slow endpoints hold up neither other Sites' deliveries nor more than half of the delivery slots. Each attempt has one five-second deadline covering DNS, connecting, TLS, sending and the response headers; a receiver that answers slowly, even one byte at a time, fails that attempt with reason timeout and is retried on the schedule above. While slots are free and deliveries are due, the collector keeps sending; it does not wait for its next five-second check. "Send test" on the Webhooks tab sends its test event at once, outside these slots, with the same deadline.

Consent withdrawal (the SDK's optOut(), which revokes the session) cancels the pending deliveries about that session (any payload naming its sessionId) with the reason Consent withdrawn, so they are never sent. Deliveries already sent are not recalled.

An endpoint's health (failing or ok, failures since its last success, the last success time and the last failure reason) is kept on the endpoint itself, so it stays correct after old deliveries are removed by retention. A new failing episode after a success raises a new "Deliveries failing" item in the bell.

Every attempt records the HTTP status it got (or none) and, when it failed, one fixed reason: local_url_refused, https_required, non_public_address, dns_failed, connection_refused, timeout, connection_reset, tls_error, http_status (a non-2xx answer), network_error or backlog_overflow (stopped by the pending backlog cap, without an attempt). The raw error text is never stored. The Webhooks tab, Settings › Channels and GET /api/v1/integration-status show the last delivery per endpoint with its status and reason.

Local development

On a hosted Telltale workspace, local addresses are always refused. To try a receiver running on your own computer, start a tunnel to it (for example cloudflared tunnel --url http://localhost:3000 or ngrok http 3000), paste the https:// address it prints as the endpoint URL, and press Send test event.

On a Telltale server you run yourself, localhost, 127.0.0.1 and [::1] URLs are refused with local_url_refused unless the Telltale server runs with ALLOW_LOCAL_WEBHOOKS=1. That setting is for development only: it allows plain http:// to those hosts and nothing else. For local URLs the Telltale server tries IPv4 first and falls back to the next address when a connection is refused, so a receiver listening on 127.0.0.1 is reached even when localhost resolves to ::1 first.

Endpoints must use HTTPS and resolve only to globally routable addresses. Private, loopback, link-local, shared, documentation, benchmarking and multicast IPv4 ranges are refused, as are IPv6 addresses outside 2000::/3, Teredo and other IETF protocol assignments (2001::/23), documentation (2001:db8::/32, 3fff::/20), 6to4 (2002::/16) and 3ffe::/16. IPv4-mapped (::ffff:0:0/96) and NAT64 (64:ff9b::/96) addresses are judged by their embedded IPv4 address. The connection uses the address that was checked.

Signing secrets and rotation

A new endpoint shows its whsec_ secret once. Saving the endpoint again changes its URL or settings but never rotates the secret. Rotate signing secret creates a replacement, also shown once, and keeps the previous secret signing for 24 hours: webhook-signature then carries one v1 entry per live secret, so receivers holding either secret keep verifying. Deploy the new secret, confirm deliveries, then revoke the retiring secret, or let it expire. At most two secrets can be retiring at once. Turning off the overlap stops every other secret immediately. Summaries show only secret IDs and expiry. Admin routes: POST /api/admin/webhook-rotate {siteId, grace, expectedSecretId} and POST /api/admin/webhook-revoke {siteId, secretId}.

Migrating from Observatory-Signature

Observatory-Signature and Observatory-Event-Id are the old signing headers from before Standard Webhooks; their names are historical (Renamed names). You only meet them on endpoints created before Standard Webhooks; there is no second header to support on a new endpoint.

The legacy headers will stop being sent on April 30, 2027 (sunset date), and verifyWebhook({rawBody, header, secret}) will stop accepting them then. Endpoints created before Standard Webhooks still also receive the deprecated Observatory-Event-Id and Observatory-Signature (t=timestamp,v1=hex HMAC-SHA256 of timestamp.body, keyed by the full secret string) headers, so existing receivers keep working. New endpoints send Standard Webhooks headers only unless you opt in. The legacy header is signed with the current secret only, so it has no rotation overlap. To migrate: switch the receiver to verifyWebhook({rawBody, headers, secret}) with the same secret, confirm deliveries, then clear "Also send the deprecated Observatory-Signature header" on the Webhooks tab (legacySignature: false). verifyWebhook({rawBody, header, secret}) still checks the legacy header during the window. Secrets issued before this change are base64url; Node and this verifier decode them, but some Standard Webhooks libraries need standard base64, so rotate once to obtain a canonical secret before switching to one of those.

Weekly agent report coverage

report.weekly covers the last complete week, Monday to Sunday (UTC). period.label names its dates ("Oct 20 – Oct 26") and period.title is the same week in words ("Last week (Oct 20 – Oct 26)"), so a message built from the payload can say which week it is. report.weekly includes observationStartedAt, the UTC day this site's observations began (or null when unavailable), and coverage for sessions, minutes, agent minutes, completed work and tool runs. Each coverage entry has from, the oldest retained boundary, and complete, whether retention covers the report period (null when unavailable). These are separate limits: retention coverage does not mean the site was observed before its observation start. Zero counts describe retained observations, not proof that no traffic occurred. These metadata contain no account, page, tool, session or user identifiers.