# Server API and recovery

Keep Site server keys in server-only configuration. Send `Authorization: Bearer YOUR_SERVER_KEY` to the Telltale server. The Bearer scheme is required (any letter case); a bare key without it is `401 unauthorized`. Never trust identity IDs supplied by the browser; derive them from your existing authenticated session and account membership.

## Verify a protected action

`POST /api/v1/verify` takes JSON with `sessionToken` and `actionId`. `actionId` is a stable identifier of 1–64 ASCII letters, digits, underscores or hyphens (case-sensitive). The [API reference page](/docs/api-verify-protected-action) has the full schema and a request you can try.

The response carries `sessionId`, `actionId`, `verdict`, `score`, `reasons`, `observedAt`, `stale`, `policy` and `decision`, with optional attribution fields:

- `verdict` is `human`, `agent` or `unknown`. `score` (1, 99 or 50) is a category, not a probability.
- `decision` is `allow`, `review` or `deny` under the action's configured policy.
- `agentEvidence` says what backs an agent `actionVerdict`: `verified` (a valid Web Bot Auth signature), `automation-trace` (webdriver flag, driver-defined trace or headless browser), `injected-input` (keys with no hardware delay, Chromium, heuristic-v4.28), `sustained-behaviour` (enough consistent behaviour evidence on pages with injected input, two independent signals), `behaviour-change` (the same after a Human reading, with device input seen to stop: a handover), `behaviour-corroborated` (heuristic-v4.28.1: two or more independent behaviour families agree on one page and the session shows no physical-device input), `cursor-driven` (heuristic-v4.30: Claude in Chrome's on-screen cursor moved to each spot and the clicks landed on it) or `behaviour` (behaviour patterns only); it is null otherwise. `confidence` is the band read from it: `high` for every value but `behaviour`, `medium` for `behaviour` (`low` is reserved), null when not an agent; a strength band, never a probability. High confidence follows the action's AI agent rule, Deny included. Medium confidence carries `behaviourOnlyDenyEnabled` and `behaviourOnlyReason`: `false` when it was held for review (the default; an AI agent rule of Deny gives `decision: review` with `heldInsteadOfDeny: true`), `true` when the Site blocks medium confidence so the AI agent rule applied in full ([Verdicts](/docs/verdicts)). The decision record and the `protection.decision` webhook carry the same fields. `billable` (the dashboard says chargeable) describes this reading, not a charge: it is true when `actionVerdict` is `agent` with High confidence, and false for Medium confidence, Human and Unknown. The verify call itself charges nothing; the usage row it writes is of kind `verification`, which is never chargeable work ([Usage metering](/docs/usage-metering)). It does not depend on identity either: a session with no identity can answer `billable: true`, and agent use counts toward one of your accounts only once that account is known (your backend called identify, or the completed work you report names it). Until then it stays under visitors not signed in. Telltale's own plans charge per identified user a month, never per verify.
- `stepUp` is present when `decision` is `review`: see [step-up for held actions](#step-up-for-held-actions).
- `stale` is true when the evidence is older than `maxAgeMs`. The decision is then the stricter of the stale branch and the current verdict's branch, so expired evidence can add review or deny but never loosen the decision. An agent that blocks the Telltale server cannot turn deny into allow.

Unknown is not human. A policy that allows unknown sessions is saved only with `acknowledgeUnknownAllow: true` (the dashboard checkbox), is audited, and shows a warning in Control › Rules. Likewise `stale: allow` is saved only with `acknowledgeStaleAllow: true` and is audited as `policy.saved_stale_allow`.

If no policy is saved for the `actionId`, verification still answers with the default policy (Human: allow, AI agent: hold for review, Unknown: allow until you save a rule that holds or denies it, No recent activity: deny; unknown still has its own branch and is never counted as human, so save a rule with unknown review or deny for anything sensitive) and says so: the response carries `warnings: ["policy_missing"]` (on `/api/v1/verify`) and an `Observatory-Warning: policy_missing; actionId="..."` header (these header names are historical, stable and kept on purpose so existing installs keep working; see [Renamed names](/docs/sdk#renamed-names)) (on `/api/v1/verify` and `/v1/verify`). A typo in `actionId` therefore shows up in your logs instead of passing silently. Integration status lists every `actionId` verified without a saved policy.

Browser tools check the same policy by tool name. A write tool (any non-GET action, or a workflow with a non-GET step) needs a saved policy before it can be enabled: without one, enabling returns 409 with code policy_required and a suggestedPolicy (human allow, agent allow, unknown review, stale review; also at GET /api/admin/policy-suggestion), and execution is refused. A reviewed read tool with no policy still runs.

### Compare identity with your authenticated user

verify also returns identity: the server-verified association for this session ({userId, accountId, verified}) or null. Your backend must compare it with the user and account it authenticated for the same request. If identity names someone else, the token belongs to another user's session (for example one captured before a logout and login as a different user). Treat that as review or deny, never allow, whatever the verdict. A null identity means the session was never associated; it is judged by its verdict alone. The browser SDK starts a fresh session when a different user identifies, and the Telltale server answers a conflicting identify with session_reinit_required. Your own comparison is still the authority for your actions.

### Agent name and how we know it (attribution)

attribution is optional and names the agent driving an agent session (`controller`, shown as the agent name) together with how we know the name (`strength`); naming an agent never changes the verdict. Signature verification is different: a currently valid Web Bot Auth signature from a reviewed operator is verdict evidence, so it turns an unknown or human session into agent (and is recorded as a takeover). attribution names a controller only together with its strength: `signed`, shown as Verified (a valid Web Bot Auth signature from a reviewed operator: https://chatgpt.com for ChatGPT agent, https://agent.bot.goog for Google-Agent or https://www.browserbase.com for Browserbase), `weak-marker`, shown as Unconfirmed (a renamable page artifact such as Claude in Chrome, Perplexity Comet, OpenClaw or the browser-use framework, named only beside an agent verdict) or `unattributed-automation`, shown as Not named or Unnamed agent (an agent verdict with no naming evidence). attribution is null otherwise. driver is a legacy label derived from attribution.

pageMarkers lists the renamable page markers seen in the evidence window, whatever the verdict. It is a diagnostic (a technical note), not attribution: a marker beside a human or unknown verdict does not show that its controller is driving, because overlays can outlive control and anyone can add them. When the verdict is agent and a WebDriver page exposes a public Playwright, Puppeteer or Selenium artifact, attribution names that framework at weak-marker strength with kind automation-framework. This names the framework, not an AI model or an agent category. Agents that publish no signature or marker stay unattributed. A missing marker or signature never means a person is present. A signature identifies the operator, not the user or AI model, and authorizes nothing. No confidence values are returned.

To check signatures on your own incoming requests, add request: {method, url, headers} to the verify body, with the url on an allowed Site origin and only the signature, signature-input, signature-agent and other covered headers. Cookie and authorization headers are rejected. signatureRequest(req, publicUrl) in /adapters/v1/server.js builds this object. The response then includes signatureCheck.status. Operator key directories are fetched live and cached by expiry. A verified signature keeps the session agent until the earlier of the signature's expires time and 10 minutes after it was last presented, so a long-lived signature that is no longer sent does not keep a session agent for a day. Only reviewed operator origins are fetched. The Signature-Agent origin must match exactly; a redirecting origin is a discovery failure. Signature-Agent may be the quoted form from the draft (sig1="https://agent.example") or an unquoted member (sig1=https://agent.example, or a bare host sig1=agent.example, which is read as https). It must always be a covered component (signature-agent;key="sig1"): a signature that covers only @authority is refused as signature-agent-not-covered, because an uncovered Signature-Agent could be swapped to name a different operator.

**Did the page send this request?** (Optional, technical only.) With `createTelltaleServer({..., correlateRequests: true})` and `verify({..., incoming: req})`, the server adapter also sends the incoming request's method, its path (no query) and the X-Observatory-Request header that the browser SDK adds to same-origin requests when the site sets `requestCorrelation: true`. You can also send it yourself as request: {method, path, correlation?} (with url and headers when you forward a signature too). Telltale looks for that request in the page's own request lane (method and path template within 5 seconds, or the exact header value) and stores the answer on the decision as pageSent: seen, not-seen, or not-checkable when the page's lane did not cover the moment without loss. A not-checkable answer is settled when the page reports within the next 30 seconds. It is never part of the decision, the verdict or a webhook: your own backend jobs, retries and server-to-server calls look the same as a request the page never sent. The page lane keeps method, path template, status class and timing only, for one hour.

If the forwarded headers (or the request to the Telltale server) declare a known crawler or fetcher in user-agent (for example GPTBot, ClaudeBot, ChatGPT-User, Claude-User, Perplexity-User, Googlebot, Google-Agent or Bingbot), the response includes crawlerIdentity {id, name, operator, kind, status, verified}, and declaredFetcher for the user-triggered fetchers. status is verified only when a collector request came from an IP inside the operator's published IP-range list, or, for Googlebot and Bingbot, from an IP whose forward-confirmed reverse DNS matches the vendor's hosts (see How it works); headers forwarded by your backend carry no verifiable IP, so they stay declared-unverified. A user agent alone is a claim: it never changes the verdict, names a controller or selects an agent category. Only fixed labels are stored, never the user-agent string or the IP address.

If the user agent carries a self-declared agent token (today Agent-NovaAct/, Amazon Nova Act), the response includes declaredAgent {id, controller, operator, basis, sourceStrength, spoofable: true}. Beside an agent verdict, attribution names that controller at strength declared, but only when the verify request sends the header Observatory-Attribution: declared (the current server adapter does). Without the header, attribution is computed as if no declaration was sent (unattributed-automation, or a framework or marker name), so clients written for the original strengths (signed, weak-marker, unattributed-automation), including the 1.0.0 adapter, keep working; declaredAgent is still returned. Beside human or unknown it names nothing. A declaration never changes the verdict or selects a usage category, and the user-agent string is never stored. A declaration expires from current attribution and the verify response after 60 seconds without a new matching request. A later explicit ordinary user agent clears it; an omitted forwarded header neither clears nor refreshes it.

The verdict comes from the live classifier, heuristic-v4.33, named in the `basis` value of every response; [Verdicts and authorization](/docs/verdicts) says what each verdict means and keeps the history of every revision in its changelog. Its timing checks count only interactions since the last change point in a page: a visibility switch, a controller marker appearing, or a pause over 10 seconds. Behaviour clues of two or more different families (timing, pointer and scrolling, page side effects, network) agreeing in one page, with no physical-device input, make the session AI agent at high confidence, never named; when the Site setting below is off they only keep it from reading Human. Human also needs timing a person can produce (keys tens of milliseconds apart, pointer movement before a mouse click), and an active agent marker (Claude in Chrome, Perplexity Comet, ChatGPT for Chrome, the Codex in-app browser, OpenClaw, browser-use, Stagehand or agent-browser), an agent-style UI flag or a published AI-agent provider address anywhere in the session holds it at unknown. Older SDKs do not send every fact, so their sessions are judged on what they send, and sessions recorded earlier keep the basis label they were stored with. A candidate rule (heuristic-v5) runs in shadow mode: it is scored for comparison only, the dashboard session view shows it as shadow, and it never changes the verdict your site receives; there is no switch that promotes it.

The server adapter at /adapters/v1/server.js validates the response, applies a five-second deadline and preserves caller cancellation. createTelltaleServer({endpoint,serverKey}) exposes verify(), requireAllowed(), identify() and recordUsage(). Download it from this Telltale server at /adapters/v1/server.js together with /adapters/v1/identifiers.js; the [Server adapters and raw HTTP guide](/docs/server-adapters) lists SHA-256 integrity for every file and a curl/fetch recipe for other languages. No npm package is published. requireAllowed() rejects review/deny and returns validated evidence on allow. Your application still owns authentication, account authorization and the protected action.

### Behaviour signals can call AI agent

Each Site has a setting, **Behaviour signals can call AI agent**, in the dashboard's Control › Rules. Behaviour signals are the guesswork clues: timing, pointer and scrolling, page side effects and network. When two or more different kinds agree, the session is called AI agent. That is the default, and Sites created before the setting existed behave the same way. Turned off, the same combination only keeps the session from reading Human: it reads Unknown at most, and only stronger evidence can make it AI agent. Turned on, a verdict that rests on behaviour alone (`agentEvidence: "behaviour"`) reads AI agent at medium confidence: held for review at a protected action by default (unless the Site blocks medium confidence, below) and never chargeable. [Verdicts](/docs/verdicts) has the rule itself.

Owners and admins change it in the dashboard, or with `POST /api/admin/behaviour-signals-setting` `{siteId, enabled}` (`enabled` must be `true` or `false`) on the loopback dashboard; it is an administration route, not part of the public contract. The answer is `{siteId, behaviourCallsAgent}`, and the Site object in the dashboard's state shows `behaviourCallsAgent` (`true` when never set). Your backend can read it with its server key in `GET /api/v1/integration-status` (`behaviourCallsAgent`, read-only; listed in `/openapi.json`). Each change is recorded in the audit log as `site.behaviour_signals_changed` with the new and previous value. It applies to evidence evaluated after the change: new uploads from open pages within about a minute.

### Block medium-confidence AI agent verdicts

Each Site also has **Block medium-confidence AI agent verdicts** (Control › Rules), off by default: a medium-confidence AI agent verdict (behaviour patterns alone, `agentEvidence: "behaviour"`) is held for review (`review`, `heldInsteadOfDeny: true`, step-up reason `behaviour_only_agent`) when the action's AI agent rule says Deny. Sites created before the setting existed behave the same way. Turned on, it follows the AI agent rule, Deny included. High confidence always follows the AI agent rule. Medium confidence is never chargeable, whatever the setting.

Only the workspace owner changes it, in the dashboard or with `POST /api/admin/behaviour-only-deny-setting` `{siteId, enabled}` on the loopback dashboard (`enabled: true` blocks; `false` holds for review, the default); admins and viewers get 403. The answer is `{siteId, behaviourOnlyDeny}`, and the Site object in the dashboard's state shows `behaviourOnlyDeny` (`false` when never set). Each change is recorded in the audit log as `site.behaviour_only_deny_changed` with the new and previous value. It applies to the next decision.

## Protected actions

Every verify decision is persisted: time, Site, session, actionId, decision, the policy snapshot that applied (its ID, each branch and maxAgeMs), the verdict and categorical score, staleness, attribution (a controller is stored only with its strength), the server-associated identity, whether it matched the user you sent, and an optional targetId. The response carries decisionId. /v1/verify records the decision too, but keeps its exact {verdict, score} body.

Optional verify fields:

| Field | Meaning |
| --- | --- |
| purpose | protect (default) or display. Use display for checks that only show status, such as a header badge. They are recorded but never alert, queue for review, send protection.decision or count in protection statistics or account verifications. |
| targetId | An opaque ID of the object acted on, chosen by your backend: 1–128 letters, digits, underscores or hyphens, for example report_1042 or inv_7Kq2. Never an email, a name or any other request value; values that do not match are refused with 400. |
| userId, accountId | The user and account you authenticated for this request. The decision records identityMatched (true or false) when the session has a server-associated identity. Your own comparison stays the authority. |

**Outcomes (informational).** POST /api/v1/decisions/{decisionId}/outcome {outcome: performed, blocked or failed, reason?} tells Telltale what your backend did. It never changes the decision. The first outcome wins; a different second one is 409 conflict. Adapter: reportOutcome(decisionId, {outcome}).

**Hosted review queue.** A review decision is held in Control › Approvals › Waiting. A reviewer approves or rejects it with a reason; who and when are recorded and audited. If your own policy is stricter than Telltale's (Telltale allowed, you hold), POST /api/v1/decisions/{decisionId}/review (adapter requestReview) sends it to the same queue; a denied decision cannot be reviewed. review.resolved is sent to your webhooks when a hold is resolved.

**Approval tokens.** Approving issues a one-time approval token bound to the session, the actionId and the targetId, valid for 15 minutes. GET /api/v1/decisions/{decisionId} (adapter getDecision) returns approvalToken while it can still be redeemed; it is derived, not stored, and never sent in a webhook. Redeem it from your backend with POST /api/v1/approvals/redeem {approvalToken, sessionToken, actionId, targetId} (adapter redeemApproval). A different session, action or target is 409 approval_mismatch; a second redemption is approval_used; after 15 minutes approval_expired. The answer carries `replayed`: an identical retry (same session, action and target) within 60 seconds, for example after a dropped connection, gets the original answer again with `replayed: true` instead of approval_used. Perform the write only on `replayed: false`, so two copies of one request never do it twice.

**An approval is evidence, not authorization.** It records that a person accepted the held action. Before you perform the write, run your own authorization again (the user still has the role, the object still exists), exactly as you would without Telltale:

```js
const {approvalToken} = await telltale.getDecision(held.decisionId);
const {replayed} = await telltale.redeemApproval(approvalToken, {sessionToken, actionId: 'invite_teammate', targetId: invitation.id});
if (replayed) return; // a retry of a request that already redeemed it: do the write once
if (!(await userIsOwner(invitation.requestedBy, invitation.orgId))) throw new Error('Not authorized'); // your rule, always
await sendInvitation(invitation);
await telltale.reportOutcome(held.decisionId, {outcome: 'performed'});
```

### Step-up for held actions

A held check can be settled by your own check instead of the hosted queue: a passkey or MFA re-authentication, Turnstile, or your own approval flow. Every verify answer with decision `review` carries a `stepUp` hint, and so do GET /api/v1/decisions/{decisionId} and the protection.decision webhook while the hold is waiting:

```json
{"decision": "review", "stepUp": {"reason": "behaviour_only_agent", "expiresAt": 1761400900000, "report": "/api/v1/decisions/decision_Qm9yZ2V0LW1lLW5vdA/step-up"}}
```

reason is `behaviour_only_agent` when the action's AI agent rule said Deny and the AI agent verdict is medium confidence, held for review (`heldInsteadOfDeny: true`, the default), `edge_evidence` when evidence your edge reported raised Allow to Hold ([Edge evidence](/docs/edge-evidence)), `policy` when the rule itself says Hold for review, and `backend` when your backend sent the check to review. Run your check, then report its result from your backend with the server key:

```sh
curl -sS -X POST https://app.trytelltale.dev/api/v1/decisions/DECISION_ID/step-up \
  -H "Authorization: Bearer $TELLTALE_SERVER_KEY" \
  -H 'Content-Type: application/json' \
  --data '{"sessionToken":"TOKEN","actionId":"export_report","targetId":"report_1042","result":"passed","method":"passkey"}'
```

It is checked like an approval: the same Site, session, actionId and targetId as the held check, still waiting, within 15 minutes of the hold (expiresAt). result is `passed` or `failed`; method is `passkey`, `mfa`, `turnstile`, `own_approval` or `other` (default). A pass approves the hold and uses it at once: the answer has `proceed: true` and no approval token is issued. A fail rejects it (`proceed: false`). The same report within 60 seconds gets the original answer again with `replayed: true`, so a retry after a dropped connection is safe. Refusals are 409 `step_up_not_held`, `step_up_mismatch`, `step_up_settled`, `step_up_expired` or `step_up_superseded` (a pass reported after the session gained a verified identity or an automation trace, under an AI agent rule that denies: verify again). Either result sends review.resolved with `via: "step-up"` and the method, and Control › Approvals › Waiting shows the hold settled as "Step-up: passkey" (or the method used). A reviewer and a step-up report cannot both settle one hold.

[Verdicts › What your customer sees](/docs/verdicts#what-your-customer-sees) has the recommended messages for a held or denied person. A passed step-up is evidence that your check succeeded, not authorization: run your own authorization before the write, as with an approval. The adapter's `guard()` runs an `onHold` handler and reports for you ([Step-up for held actions](/docs/step-up), with Express and Next.js examples); by hand:

```js
const held = await telltale.verify({sessionToken, actionId: 'export_report', targetId: report.id});
if (held.decision === 'review' && held.stepUp) {
  const ok = await yourPasskeyPrompt(user); // your own check
  const r = await telltale.reportStepUp(held.decisionId, {sessionToken, actionId: 'export_report', targetId: report.id, result: ok ? 'passed' : 'failed', method: 'passkey'});
  if (r.proceed && (await userMayExport(user, report))) await exportReport(report); // your rule, always
}
```

Consent withdrawal cancels actions held for review and unredeemed approvals for the session. Erasing a user or account removes their decisions.

**Read decisions.** GET /api/v1/decisions.json lists the Site's decisions newest first (created time, then decision ID), one page at a time: limit (1–1,000, default 500) sets the page size, and page.nextCursor goes back as cursor for the next page until it is null; page.total counts every match. from and to take Unix epoch milliseconds or ISO 8601 dates or date-times (from inclusive, to exclusive). Filters: actionId, decision, verdict, accountId, userId, sessionId, purpose; review, what became of a held decision (waiting, approved, rejected, expired or cancelled; rejected leaves out holds closed as expired); and q, a case-insensitive search of at most 100 characters over part of the account ID or account name, user ID, action ID or name, agent name or session ID. Filters and q apply to every page and to page.total, not only to the page returned. The dashboard shows them in the session and account drawers, Control › Approvals › History and Control › Rules › Incident timeline, with CSV and JSONL export and a SHA-256 manifest (labels and IDs only).

**Why unknown.** For an unknown verdict the decision records unknownCause: no_events, consent_held (the site holds the tag for its consent banner and the visitor has not consented, or consent was withdrawn), evidence_expired, inconclusive_evidence, legacy too_few_interactions or fresh_session. It explains the verdict and never relabels it. To reduce unknown decisions: forward Web Bot Auth headers in verify (request), verify when the user acts rather than on page load, mark protected forms with data-telltale, and review the maximum evidence age for low-risk actions.

**Retention.** Settings › Data & privacy sets per-Site retention: sessions (default 8 days), decisions (default 90) and the audit log (default 30), each up to 400 days; usage for charging your customers keeps at least 90. Longer retention keeps more about people for longer: choose the shortest period your obligations allow and describe it in your privacy notice. Erasure always removes records at once, and a session whose consent was withdrawn keeps only the default session retention.

## Look up a session later

Sometimes you only need the verdict afterwards, for example to check an exam attempt once the student has submitted it. Store the session ID with your own record. Later, your backend reads the session with GET /api/v1/sessions/{sessionId} and its server key.

In the browser, after your own sign-in check, send the session ID to a route the student is signed in to:

```js
const client = window.Telltale.getClient();
const sessionId = await client.getSessionId();
await fetch(`/api/exam-attempts/${attemptId}/session`, {
  method: 'POST',
  headers: {'Content-Type': 'application/json'},
  body: JSON.stringify({sessionId}),
});
```

On your server, save it only on an attempt that student owns, then check it when you mark the attempt:

```js
// Save: requireStudent and the attempt functions are your own code.
export async function saveSession(req, attemptId) {
  const student = await requireStudent(req);
  const {sessionId} = await req.json();
  if (typeof sessionId !== 'string' || !sessionId.startsWith('session_')) return new Response(null, {status: 400});
  return (await saveAttemptSessionId(student.id, attemptId, sessionId)) ? new Response(null, {status: 204}) : new Response(null, {status: 404});
}

// Check afterwards.
export async function checkAttempt(attemptId) {
  const sessionId = await getAttemptSessionId(attemptId);
  const r = await fetch(`${process.env.TELLTALE_ENDPOINT}/api/v1/sessions/${encodeURIComponent(sessionId)}`, {
    headers: {Authorization: `Bearer ${process.env.TELLTALE_SERVER_KEY}`},
  });
  if (r.status === 404) return {review: true, why: 'session not found or past retention'};
  if (!r.ok) throw new Error('Session lookup failed'); // a failed lookup is not a verdict
  const s = await r.json();
  if (s.everAgent) return {review: true, why: `AI agent activity (${s.agentEvidenceLabel})`, at: s.agentFirstSeen};
  if (s.actionVerdict !== 'human') return {review: true, why: 'could not confirm a person'}; // Unknown is not human
  return {review: false};
}
```

The answer has:

- sessionId and status: active, ended or consent_withdrawn; started, lastActivity and ended (Unix epoch milliseconds; ended is null while active, and for a withdrawal recorded before the time was kept).
- verdict and actionVerdict (agent, human or unknown) and verdictAt. While the session is active the verdict is the reading now, as verify computes it. Once it has ended it is the reading at its last activity, and that is final.
- agentEvidence (verified, automation-trace, injected-input, sustained-behaviour, behaviour-change, behaviour-corroborated, cursor-driven or behaviour, or null) and agentEvidenceLabel: Verified, Strong evidence (every high-confidence value but a signature), Behaviour or Unknown.
- everAgent, agentFirstSeen and agentLastSeen: whether the action verdict ever read AI agent in this session, and when. An analytics-only AI agent reading (shown in analytics, but not strong enough to select the AI agent rule or count as chargeable) doesn't count.
- agent: the named agent and how we know it ({name, operator, howWeKnow}), only when evidence names one. Otherwise it's null.
- identity: the user and account you bound with identify (verified: true), or else the browser's own claim (verified: false), or null.
- detectorVersion (for example heuristic-v4.27) and reasons, the same reasons verify gives.
- decisions: the session's decisions, newest first, at most 100 ({decisionId, actionId, decision, purpose, created, outcome, review}). decisionsComplete is false when there are more; GET /api/v1/decisions.json?sessionId= reads them all.
- retainedUntil: when the lookup stops answering.

There is no score or probability. Verdicts are categories.

**After consent withdrawal** the status is consent_withdrawn, the verdict is unknown with the reason "Collection permission was withdrawn", and the evidence fields (agentEvidence, everAgent, agent, detectorVersion) are withheld. The identity you bound and the decisions already taken stay listed, because they are your own records.

**Who can read it.** Only the server key of the session's own Site. A session ID is a reference, not a credential: without the server key it gets 401, and it never works in place of a session token (verify, identify, usage and the SDK all refuse it). Another Site's session, an unknown ID, an erased or deleted session and one past retention all get the same 404, so the answer never says which. Check that the signed-in user owns the record before you save or read its session ID. A session ID sent by the browser says which session to read, not who acted in it.

**How long.** A session can be looked up for as long as it is kept, counted from its last activity: a session with any AI agent activity for your plan's history period (Free 8 days, Starter 30, Growth 90, Enterprise by contract; never less than the session setting), any other session for the Site's session retention (default 8 days, 1 to 400 days in Settings › Data & privacy). `retainedUntil` gives the exact time. A session whose consent was withdrawn keeps at most the default 8 days. At the cutoff the lookup answers 404, even if the clean-up hasn't removed the row yet. Erasing a user or account removes their sessions at once. A session stays readable for the whole retention period: at least a day, and 8 days by default.

A lookup is a read. It never extends the session, changes its verdict or records a decision. It shares the server key's rate limit with every other server-API call and updates the key's last use in integration status.

## Integration status

GET /api/v1/integration-status (server key) returns the health of the Site that owns the key, the same document Settings › Channels shows:

- keys: every live server key ID with its status (current or retiring), expiry and lastUsedAt, so you can confirm traffic moved to a new key before the old one expires.
- verify: outcomes by code over the last seven days. allow, review and deny come from the decision history; failures that never reached a decision are counted by their public code (bad_request, forbidden, session_reinit_required, rate_limited, ...) once the server key identified the Site. A wrong or missing key cannot be attributed to a Site and is not counted. unsavedPolicyActions lists actionIds verified with no saved policy.
- webhooks: per endpoint, delivery counts and the last delivery with its HTTP status and a fixed reason category (see Webhooks).
- sdk: live browser sessions, the per-Site session cap and when evidence last arrived.
- database: ok or unavailable. GET /health also checks the database and answers 503 when it cannot.
- warnings: things to fix, such as policy_missing, webhook_failing, retiring_key_in_use, server_key_unused, session_capacity_near or retention_failing (the retention clean-up failed, so old records are not being removed; it retries every minute, so check the server log and free disk space).

It contains no identities, tokens or request values.

## Compatibility API (/v1)

The compatibility API (/v1) keeps the request and response shapes of the v1 agent-verification API that existing integrations were built on (verify, identify, session lookup and the discovered API export), so such an integration works against Telltale unchanged. New integrations use /api/v1, which also returns reasons, the decision, the policy and attribution and works with the server adapter.

POST /v1/verify, POST /v1/identify and GET /v1/sessions/{sessionId} accept the same bearer key as their /api/v1 counterparts. Requests and responses follow the v1 shapes in this server's OpenAPI document (/openapi.json, operations compatVerify, compatIdentify, compatDiscoveredOpenApi and compatGetSession; see the [compatibility API reference](/docs/api-compat-verify)):

- Request bodies are closed. An unknown property, a missing, empty or non-string sessionToken, or an invalid ID returns 400 bad_request. /v1/verify accepts only sessionToken and actionId, and actionId must match ^[a-z0-9_-]{1,64}$ (lowercase only; /api/v1 also accepts uppercase). To forward Web Bot Auth headers, use /api/v1/verify. /v1/identify accepts sessionToken with userId (and an optional accountId), or sessionToken with accountId alone. A sessionToken shaped like a publishable or server key returns 401 unauthorized.
- /v1/verify returns exactly {"verdict": "unknown", "score": 50}. The verdict is human, agent or unknown with the categorical score 1, 99 or 50. It runs the same evidence evaluation, policy and verification usage as /api/v1/verify; use /api/v1/verify when you also need reasons, decision, policy and attribution.
- /v1/identify returns exactly {"status": "accepted"}. IDs may be up to 512 Unicode code points, as on /api/v1/identify. Supplementary characters count as one code point; IDs are never truncated. Both server dialects retain the existing whitespace trimming behavior: leading and trailing whitespace is removed after validation, with no case folding or Unicode normalization. Use canonical stable IDs; values that differ only by surrounding whitespace name the same identity.
- GET /v1/discovery/openapi.json returns exactly the v1 DiscoveredOpenApiDocument shape: openapi "3.1.1", info {"title": "Discovered API", "version": "1"} and the reviewed actions as paths, with ETag/If-None-Match. It returns 503 discovery_unavailable while discovery is off for the Site, and 503 discovery_not_ready while discovery is on but nothing has been observed yet. Paths can be empty until an action is reviewed. /api/v1/discovery/openapi.json keeps its richer document with servers and Site title.
- GET /v1/sessions/{sessionId} returns exactly the v1 SessionResponse: {"sessionId": "session_...", "status": "ended", "identity": {"userId": "u_1", "accountId": null, "verified": true}, "verdict": "agent"}. status is active or ended; a session whose consent was withdrawn reads ended, with verdict unknown. verdict is the action verdict, as on /v1/verify; there is no score, because the v1 SessionResponse has none. identity is the one your server bound (verified: true), else the browser's claim (verified: false), else null. A session that isn't this Site's, or is unknown, erased, deleted or past retention, is 404 {"error": "session_not_found"}; a malformed ID is 400 bad_request; any method other than GET is 405 (plain text, Allow: GET). Use GET /api/v1/sessions/{sessionId} (see Look up a session later) for reasons, evidence strength, the named agent, agent history and decisions. A session stays readable for the Site's session retention (8 days by default).
- Every failure body is exactly {"error": "<code>"}: 400 bad_request, 401 unauthorized, 403 forbidden, 409 session_reinit_required, 500 internal_error, 503 config_unavailable or session_unavailable (503 discovery_not_ready or discovery_unavailable on the discovery document). 404 not_found and 429 rate_limited can also appear.

An invalid session token (malformed, truncated, edited, or not signed by this Telltale server) is 401 unauthorized everywhere; session_reinit_required is only for a token this Telltale server issued whose session can no longer be verified. session_reinit_required is 409 on /v1. On /api/v1 and /api/sdk it is 401 (with a matching code) for an expired, revoked or removed session and 409 (with the same code) when the token is already bound to a different identity. All of them mean the token can no longer be used: reset the browser SDK session and obtain a new token. The browser SDK recovers once on either. Only verify may retry 503 config_unavailable or session_unavailable, at most three attempts, and never the protected action.

## Associate identity

POST /api/v1/identify accepts sessionToken and userId and/or accountId. Server-verified IDs are nonempty strings of at most 512 Unicode code points (browser SDK identify stays at 128 UTF-16 code units). Success is {accepted:true}. Browser-provided identity is separate from this server-verified association. Call only after verifying your application's authenticated user and membership.

Optional accountTraits {name, plan, crmId, segment, ownerDomain} labels the accountId for the dashboard and the leadership report: name is 1–80 characters, plan and segment 1–40 letters, digits, spaces and . _ + -, crmId (your CRM's own account ID: Salesforce, HubSpot, Attio…) 1–64 letters, digits and . _ : / # - without spaces, ownerDomain the account owner's email domain only (such as acme.com; an email address is refused, so no address is ever stored); null clears a field, any other property is 400. Traits need accountId, are accepted only here (never from the browser SDK or /v1/identify), never change a verdict, attribution, billing mode or authorization, and are removed when the account is erased. With the adapter: identify({sessionToken, userId, accountId, accountTraits: {name: 'Acme Analytics', plan: 'Business', crmId: '0015g00000XyZAbAAN', segment: 'Mid market', ownerDomain: 'acme.com'}}). The CRM ID, segment and owner domain show in Accounts (a column and a filter), in the account drawer and its shareable report, and in the accounts CSV (CRM account ID, Segment, Owner email domain) and the ledger's accounts CSV (crmAccountId), so you can join Telltale's numbers to your CRM. They are labels only: no free text and no personal data beyond what identify already accepts.

Optional userTraits {email} or {emailDomain} (one of them, needs userId) lets you mark your team as known humans by email domain in [Your agent report](/docs/diagnostic-report). Only the domain after @ is kept, lower-cased; the address is never stored. It is never used for verdicts, authorization or chargeable use, null forgets it, and erasing the user removes it.

## Meter completed work

POST /api/v1/usage accepts sessionToken, eventId, actionId and units. eventId is a stable 8–128 character letter/digit/underscore/hyphen identifier. units is an integer from 1 to 1,000,000. Server-verified identity is required. Reusing an event ID with identical facts deduplicates; conflicting facts reject. Report only completed work. This is metering, not payment processing. The [Usage metering guide](/docs/usage-metering) covers event IDs, retries and reconciliation.

## Activity series for BI

GET /api/v1/activity.json returns interaction minutes per class (human, agent, unknown, mixed; a mixed minute is one in which the activity carried more than one verdict, for example when the verdict changed during that minute; it is never split or counted as human) and daily session totals by last-observed verdict, in UTC-day buckets (series=week for ISO weeks starting Monday 00:00 UTC). from and to work as on agent-usage.json (default the last 7 days: 7 whole UTC days ending today, clamped to the Site usage retention). Buckets are half-open and add up to totals. Compute a share as one class divided by total: it is a share of minutes or sessions, not a probability, and unknown is never counted as human. Session totals are whole UTC days kept 400 days as counts only; a bucket with no daily total has sessions null.

## Agent usage by category

GET /api/v1/agent-usage.json returns retained agent tool executions and agent session-minutes per account and category for a period. Query parameters: from and to (Unix epoch milliseconds or ISO 8601 dates; default the last 7 days: 7 whole UTC days ending today, from midnight UTC six days ago up to the next midnight UTC; the response's window says the period in words, such as "Oct 21 – Oct 27 UTC (last 7 days)"; from is clamped to the Site usage coverage and the response then says clampedToRetention: true, otherwise false. Coverage is the Site usage retention (90 days by default, up to 400 in Settings › Data & privacy), except right after retention is raised: rows already past the old retention were deleted, so coverage starts at the old floor and grows a day per day until it reaches the new retention), accountId (one account) and limit (1–500 account rows, default 100). Periods are half-open, [from, to): a unit exactly at to belongs to the next period, so back-to-back periods (to of one equal to from of the next) never count a unit twice. The response states periodBounds "[since, until)", coverageStart (the later of since and the usage coverage floor; agent tool executions and agent minutes both follow the Site usage retention, so the whole response covers the same range) and oldestRetainedAt (the oldest agent unit the Site still retains, or null). Chargeable units leave only by age or erasure, never by a record cap. Totals and categories always cover the whole period; accounts are paginated in stable (accountId, source, category) order, and page.nextCursor, passed back as cursor, continues the same period and account even when usage arrives between pages. Omit from and to (or repeat the original values) when you pass cursor; a cursor sent with a different from, to or accountId is rejected with 400. Categories are computer_use, browser_use, coding_agent, crawler or unattributed. Unattributed means agent evidence without a named controller; generic automation never selects a named category. Each unit records its account and category when it was ingested; metered or included follows the category's setting now, for every retained unit (each unit also keeps the setting in force when it arrived as billingMode). Signed agent_usage.recorded webhooks (see Webhooks) carry usageId, unit, accountId, accountSource, category, billingMode and meteredUnits. Owners and admins can send an agent_usage.summary webhook from the dashboard; it carries since, until, periodBounds, coverageStart, oldestRetainedAt and totals so it reconciles with this endpoint. series=day|week adds series, UTC-day or ISO-week buckets of the period (start, end, toolExecutions, sessionMinutes, metered, included) that add up to totals; compare=previous adds previous (the period of the same length ending at since, clamped to retention, or null) and change (unit count differences). No charges are created.

### Usage by named agent

agent-usage.json also returns agentsUsed (the number of distinct named controllers in the period) and agents, one row per controller and attribution strength: controller (a normalised controller name such as claude-in-chrome or chatgpt-agent, or null), strength (signed, weak-marker, ip-verified for a crawler or fetcher verified against its operator's IP list, or unattributed), category, toolExecutions, sessionMinutes, metered and included units, and lastAt (the latest unit in the period). A controller is named only from the signed or weak-marker attribution captured when the unit was ingested. All other agent usage is one row with controller null and strength unattributed; it is never assigned a name. The same controller seen at two strengths stays in two rows. agent_usage.summary carries the same agentsUsed and agents (with units, meteredUnits and includedUnits).

In the dashboard's admin state, each tool row in toolUsage[siteId].tools carries agents (one entry per named controller and strength that started it in the seven-day window: controller, strength, starts, lastUsedAt), agentCount (distinct named controllers), lastUsedAt (the latest start, by anyone) and lastUsedBy ({controller, strength}, or null when that latest start had no named controller, including human, unknown and unattributed starts).

### Per-account response

Owners and admins can label each server-associated account default, flagged or agent_seat (dashboard Accounts area, or POST /api/admin/account-response {siteId, accountId, response}). Each change is audited and sends a signed account.seat_changed webhook {accountId, previous, response, changedAt}. Like the billing mode, the response in force is snapshotted into every agent usage unit and metered usage row when it is ingested; later changes never rewrite earlier usage. agent-usage.json shows each account's currentResponse and byResponse units, agent_usage.recorded and usage.recorded carry accountResponse, and agent_usage.summary carries currentResponse and unitsByResponse. Responses apply only to server-associated accounts: units attributed to a browser-supplied accountId always snapshot default and show currentResponse default, even when a server account with the same ID is flagged or an agent seat. It is a label for your own systems, not a payment, a verdict or an authorization.

## Erase a user or account

To delete what the Telltale server holds about one of your users or accounts, open the dashboard's Settings › Data & privacy and enter the userId, the accountId or both. The same call is POST /api/admin/erase {siteId, userId, accountId} on the loopback dashboard; it is an administration route, not part of the server API, and is never reachable through the public collector origin. It returns {sessionsDeleted, recordsDeleted, deliveriesRedacted}.

Erasure removes every session, usage row, agent minute, tool execution, cursor, evaluation and discovery receipt tied to that ID, including rows recorded before the identity was known. Chargeable usage leaves a tombstone with no identity (the row ID and a hash of its facts), so a retried eventId is still answered as a replay. Erasing an accountId also clears its per-account response and removes the plaintext ID from seat audit entries. Webhook deliveries about the subject (any status, including pending retries) are deleted. A delivery that only lists the subject among others, such as the accounts in agent_usage.summary, is kept with the subject's entries removed (deliveriesRedacted counts them); a pending one is then signed and sent with the redacted body. Aggregate totals in such a payload are counts, not identities, and are left as they were. The audit log records the erasure with a short hash of the ID, not the ID itself.

**Where deleted bytes go.** On SQLite (the local and test database) the server turns on secure_delete, so the rows erasure removes are overwritten with zeros in the database file instead of lingering in free pages; older copies can remain in the write-ahead log until SQLite next checkpoints it (automatically as the log grows, and on shutdown) and in any backup you made earlier. On Postgres a deleted row stays on disk as a dead tuple, invisible to every query, until VACUUM (autovacuum runs it automatically) reclaims the space; the bytes can also stay in the write-ahead log and in backups and point-in-time-recovery copies for as long as the database provider keeps them.

## Export what is held about a user or account

To answer an access request (GDPR article 15) from one of your users, an owner or admin calls GET /api/admin/subject-export?siteId=…&userId=…&accountId=… on the dashboard (userId, accountId or both; viewers get 403 `owner_or_admin_only`). Like erasure it is an administration route, never reachable through the public collector origin. It answers a JSON download, `subject-export.json`:

```json
{"format": "telltale.subject-export", "version": 1, "generated": 1762160000000,
 "site": {"id": "site_…", "name": "Shop"}, "subject": {"userId": "u_1", "accountId": null},
 "counts": {"session": 2, "agentminute": 5, "decision": 1},
 "records": {"session": [], "agentminute": [], "decision": []}}
```

It finds records exactly as erasure does: every session carrying the identity (also those identified after they started, through the session-to-subject map), every row of those sessions (events and behaviour records, usage, agent minutes, executions, cursors, evaluations, receipts, workflow trials, detector history), rows that carry the identity themselves, decisions and notifications, webhook deliveries that name the subject, and for an accountId its label and plan, per-account response and its changes, account goals and alerts and seat audit entries; plus the tool evidence and tool-update entries taken from those sessions. `records` holds each kind that has rows; a session's token hash is left out. Exporting then erasing the same subject removes exactly what was exported. More than 50,000 records answer 413 `subject_export_too_large`. Each export is audited as `user.exported` or `account.exported` with the same short hash erasure records, never the ID itself.

## Delete a Site

The workspace owner deletes a Site from Settings › Data & privacy, or with DELETE /api/admin/sites/{siteId} and the JSON body {"confirm": "<the Site's exact name>"} on the dashboard. Deleting starts a **30-day safety window**. It answers {"deleted": true, "pending": true, "purgeAt", "records": n}: `purgeAt` is when the Site goes for good (epoch ms) and n counts every record of the Site now, the Site itself included.

From the answer on, the Site is gone for every normal caller: the SDK and server API refuse its public and server keys, a browser that starts a session on it is refused, it leaves the site list and every dashboard view (404), and nothing is sent for it any more (its webhook endpoints, scheduled reports, ledger and S3 exports, forwarding destinations, account alerts and analytics keys are set aside, and pending webhook deliveries are cancelled). Its records stay for the 30 days, under the Site's own retention settings, and every member sees "Pending deletion: restore or export until <date>" at the top of the dashboard (GET /api/admin/site-deletions lists them: siteId, name, deletedAt, purgeAt). During the window:

- **Restore** (owner): POST /api/admin/site-deletions/{siteId}/restore brings the Site back exactly as it was, with the same keys, settings, webhook endpoints and records. 409 `site_cap` when the workspace already has 20 Sites.
- **Export** (owner): the usage export (GET /api/admin/export?site=, screen exports with view=), GET /api/admin/report.csv and the report (GET /api/admin/report.html, GET /api/admin/diagnostic-report.html) still answer for the Site, with the plan's usual export limits. Every other route answers 404 for it.
- **Erase one person now** (owners and admins): POST /api/admin/erase works on the Site as usual. Erasure requests are never delayed by the window.
- **Delete now** (owner): POST /api/admin/site-deletions/{siteId}/purge {"confirm": "<the Site's exact name>"} skips the rest of the window.

When the window ends (or on Delete now) the permanent deletion runs. Everything stored for that Site goes: sessions and their events, behaviour and page records, usage, agent minutes, billing settings and tombstones, decisions and notifications, discovered actions, workflows, policies and labels, alerts and goals, webhook endpoints and their deliveries, preferences, caches and day rollups, Cloudflare records and the Site's own audit entries. Its settings go in one short transaction; its bulk records (sessions, events, usage, minutes, rollups, its audit entries) are then removed in batches of short transactions, so deleting a large Site never holds the database for long. A small Site deleted with Delete now is fully removed before the answer; otherwise the server's background maintenance finishes the job within minutes, carrying on after a restart. The workspace keeps audit entries with the Site's ID and name: `site.deleted` (with the restore date), `site.restored`, and `site.purged` (who deleted it and the number of records removed); they name no visitor. A Site's data is deleted the way erasure deletes it (see "Where deleted bytes go" above).

Refusals: 403 for anyone but the workspace owner; 400 `confirm_required` without confirm and 400 `confirm_mismatch` when it differs from the Site's name; 404 for a Site that is not in this workspace (404 `not_pending` to restore one that is not pending deletion); 409 `site_protected` for the built-in playground and public demo Sites.

A workspace can be deleted (DELETE /api/admin/teams/{id}) once it has no live Sites left (Sites pending deletion keep their own window). It answers {"deleted": id, "pending": true, "purgeAt", "windowDays": 30}: its members lose access at once and its invitations stop working, and for 30 days its owner can restore it (POST /api/admin/teams/{id}/restore; GET /api/admin/team-deletions lists the workspaces pending deletion that you own). Then its memberships, invitations, plan and audit log are removed. A workspace with a live paid subscription can't be deleted (409 `subscription_active`). Deleting your own account (below) removes the workspaces it leaves empty at once, with the records of their Sites still in a window.

## Transfer workspace ownership

The owner hands a workspace to one of its admins with POST /api/admin/transfer-ownership {"membershipId": "<the admin's membership id>", "confirm": "<the workspace's exact name>"} (the workspace is the one chosen with X-Team-Id). The new owner gets every owner right; the old owner stays as an admin. It answers {"transferred": true, "teamId", "owner": {"membershipId", "userId"}, "previousOwner": {"membershipId", "userId", "role": "admin"}} and writes one `team.owner_transferred` audit entry. Refusals: 403 `owner_only`; 400 `confirm_required` or `confirm_mismatch`; 404 `member_not_found`; 400 `transfer_to_self`; 409 `transfer_target_not_admin` (make a viewer an admin first); 400 `workspace_limit` when the new owner already owns five workspaces; 400 `transfer_unavailable` in local mode. Two transfers at once, or a transfer and a role change of the same person, never leave a workspace with two owners or none.

## Delete your account

Anyone signed in to the dashboard can delete their own account: GET /api/account/deletion shows what would happen, and POST /api/account/delete {"confirm": "delete"} does it (also when they belong to no workspace). A sign-in older than ten minutes needs the current password in the body ("password", password mode) or a fresh sign-in (401 `reauth_required`; a wrong password is 401 `password_incorrect`; ten tries per 15 minutes). Deleting is refused with 409 `account_owns_workspaces`, listing the workspaces, while the person owns a workspace that still has Sites or other members: delete its Sites or transfer ownership first. The built-in playground and public demo Sites don't count.

In one transaction the account's memberships, sign-ins, invitations addressed to its email, Cloudflare connections, pending Cloudflare attempts and Site preferences go, with every workspace it owned that is now empty (memberships, invitations and audit log included; the server's default workspace is kept without an owner, so owner set-up can claim it again). Approvals the person resolved stay, naming "Former member" instead of their email. The account itself is deleted. Each workspace they remain recorded in gets an `account.deleted` audit entry holding only a short keyed hash; older entries keep an opaque user ID that reads "Former member". It answers {"deleted": true, "workspacesDeleted", "membershipsRemoved", "identity"} and signs the browser out. With Supabase accounts, `identity` says whether the Supabase user was deleted at once (`deleted`) or will be retried by the server's maintenance until Supabase confirms (`pending`); until then a sign-in by that Supabase user is refused. Backups follow their own rotation, and Supabase's own auth audit log follows Supabase's retention.

## Webhooks

Signed Standard Webhooks deliveries, verification with verifyWebhook(), retries, secret rotation and migration from the old Observatory-Signature header (before Standard Webhooks) are covered in the [Webhooks guide](/docs/webhooks).

## Stable errors

[Errors and retries](/docs/errors) has the full table: every status and code on /api/v1 and /v1, what each means, what to do and which ones the adapter retries. In short:

- /api/v1 failures carry matching error and code fields: 400 bad_request, 401 unauthorized or session_reinit_required, 403 forbidden, 404 not_found, 409 conflict or session_reinit_required, 413 payload_too_large, 429 rate_limited, 500 internal_error and 503 service_unavailable. Any other 4xx status uses request_failed.
- The v1 endpoints of the compatibility API return only error (see above), and their 503 codes are config_unavailable, session_unavailable, discovery_not_ready and discovery_unavailable.
- Failures are not verdicts.

For unauthorized, check your server credential first, then replace only the session token (it may be malformed, edited or from another Telltale server). For session_reinit_required, return a recoverable response to the browser, reset its SDK session and obtain a fresh token; do not resend the old one. Preserve consent. Do not expose tokens or server keys in diagnostics.

The adapter in /adapters/v1/server.js accepts session_reinit_required with status 401 or 409. It retries verification only after a valid 503 body whose code is service_unavailable, config_unavailable or session_unavailable (the last two come from the v1 endpoints; /api/v1 itself sends service_unavailable): at most three total attempts, with jitter of 0–250 ms and then 0–500 ms, all within one five-second deadline. Cancellation stops body reads and backoff. A 408 or 429 from an intermediary is retried at most once, and only when its Retry-After (seconds or an HTTP date) still fits inside the same deadline; without Retry-After it fails. The adapter reports any code it does not know for that status, including request_failed, as unknown_error, and an error response that is not JSON as invalid_error_response. Malformed responses, transport failures, timeouts and other statuses never retry automatically. identify() and recordUsage() never retry. Never replay a protected application action because verification failed.

## Credentials and exports

The installation screen creates replacement keys with seven-day overlap by default and shows retiring expiry. Deploy the replacement, confirm traffic, then revoke the old key. Immediate replacement invalidates existing credentials. Keys are shown once; public summaries expose only identifiers and expiry.

GET /api/v1/discovery/openapi.json returns the Site's discovered customer API document (OpenAPI 3.1) and supports ETag/If-None-Match with 304. It has one operation per path and method. Reviewed GraphQL operations or JSON-RPC methods that share one are described as a `oneOf` over their full wire bodies (`query`, `operationName` and `variables`; or `jsonrpc`, `method`, `params` and `id`), told apart by an `operationName` or `method` constant and listed under `x-observatory-operations`; GraphQL documents are never exported, only their signature. Responses are keyed by the most frequent 2xx status observed (200 when none was), carry the observed media types, and have no content for an empty answer such as 204. Schemas are names, types and format labels only, with no examples or values; response lists carry no `maxItems` (request lists are bounded at 100 items), and a request object without `additionalProperties: false` was recorded with keys left out. /v1/discovery/openapi.json serves the same paths in the v1 document shape. GET /api/v1/discovery/workflows.json exports workflow definitions. Both require the server bearer key. These are customer application exports, not a generated contract for this server API.

## Public API contract

GET /openapi.json publishes the OpenAPI 3.1.1 contract for verification, server identity, completed-work metering and discovery exports. It is public and contains no Site credentials. Every operation under paths requires a Site server bearer key; the webhooks entry describes the signed event your endpoint receives. Documentation navigation, llms.txt and the installation prompt link to it.

The API explorer at /docs/api-explorer is built from this contract and sends real requests to the Telltale server that serves it. Enter a test Site's server key; the page keeps it in memory for the request only, never stores or logs it, and clears it when you leave. Requests have real effects, such as recording usage. The runnable explorer is for local development on the loopback dashboard only. When the Telltale server is reached through a public origin, the explorer is reference-only: it has no key field and no Send button, and shows each operation's example body and equivalent curl to run from your server. That collector also refuses server-API requests sent from a page on its own origin (403), because server keys must never be entered in a browser.

The contract covers these server integration endpoints, not dashboard administration or browser SDK transport. Workflow export items are intentionally generic objects; use the reviewed runtime manifest for executable input schemas. Server identity length follows JSON Schema character counting: at most 512 Unicode code points. Browser-supplied identity remains capped at 128 UTF-16 code units.

## Docs MCP server

POST /mcp is a read-only Model Context Protocol server over these guides, using the Streamable HTTP transport with JSON responses. It needs no key and returns no Site data. Tools: list-pages (no arguments) and get-page ({slug}, or {path} with the exact path list-pages returns, such as /docs/csp; "/" is the grouped index). get-page returns the page Markdown as both markdown and content. It supports protocol version 2026-07-28 (stateless requests with per-request _meta and the MCP-Protocol-Version, Mcp-Method and Mcp-Name headers; server/discover) and the initialize-based versions 2025-11-25 and 2025-06-18. It issues no sessions, offers no GET stream (405), rejects foreign Origin headers with 403 and does not accept JSON-RPC batches. Header names are case-insensitive and surrounding whitespace is ignored; an Mcp-Name sent as =?base64?…?= must be canonical padded Base64, and header and body mismatches return 400 with error -32020. A 2026-07-28 request whose _meta lacks the protocol version or client capabilities returns 400 with -32602. The tests in test/docs-mcp-conformance.test.js port the relevant checks from the official MCP conformance suite.

## Configuration

The Telltale server reads its settings from environment variables when it starts. Each value is checked, and a bad value stops the server with an error that names the variable. Flags take `1` (on) or are left unset (off).

The server's own settings are named `TELLTALE_*`. Before November 2026 they were named `OBSERVATORY_*` (for example `OBSERVATORY_SESSION_KEY`); the old names still work, and the server logs one line at start naming each old name in use and its new name. When both names of a setting are set, the `TELLTALE_` value is used and the log line says so. Rename them when convenient: the old names will be removed in a later release.

| Variable | Default | What it sets |
|---|---|---|
| PORT | 4317 | Port of the dashboard and collector. |
| HOST | 127.0.0.1 | Address the dashboard and collector listen on: an IP address (`127.0.0.1`, `::1`, `0.0.0.0`, `::`) or `localhost`. Set `0.0.0.0` in a container so its load balancer can reach it. `AUTH_MODE=local` refuses any address other than loopback. The public edge listener (EDGE_PORT) always listens on loopback. |
| PUBLIC_ORIGIN | http://127.0.0.1:<PORT> | Origin the dashboard is reached at. Local sign-in mode accepts only a loopback origin. |
| TELLTALE_MARKETING_URL | (unset) | Your public marketing site. Signed-out visitors to the docs, `/accuracy` and `/try` get a link back to it; unset, those pages name no home. Example: `https://telltale-landing-ten.vercel.app`. |
| AUTH_MODE | local | `local` (no sign-in, loopback only), `password` (accounts with a password stored by this server) or `supabase` (accounts on Supabase Auth: sign-up, email links, Google or GitHub, two-step codes; see "Sign-in with Supabase" below). `supabase` needs an https PUBLIC_ORIGIN unless it is a loopback origin. |
| SUPABASE_URL | (unset) | With `supabase`: the project origin, `https://<project ref>.supabase.co` (or its custom domain), with no path. Required. |
| SUPABASE_SECRET_KEY | (unset) | With `supabase`: a secret API key starting with `sb_secret_` (Project Settings › API Keys). Legacy `service_role` keys are refused. Server-only; never logged. Required. |
| SUPABASE_JWT_ISSUER | <SUPABASE_URL>/auth/v1 | The `iss` every access token must carry exactly. Set it only for a custom domain. |
| SUPABASE_JWT_AUDIENCE | authenticated | The `aud` access tokens must carry. |
| SUPABASE_JWKS_URL | <issuer>/.well-known/jwks.json | Where the project's signing keys are read (ES256 or RS256; HS256 is never accepted). |
| SUPABASE_ALLOW_INSECURE | (unset) | Flag, local tests only. Allows `http://` Supabase URLs on a loopback host. |
| SUPABASE_OAUTH_PROVIDERS | (none) | Comma list from `google`, `github`: the "Continue with…" buttons shown. Enable the same providers in Supabase first. |
| SUPABASE_RECHECK_SECONDS | 300 | How often a session asks Supabase whether the person still exists and is not banned (60–3,600). |
| TELLTALE_SIGNUP | open | `open`: anyone can create an account and gets their own workspace. `invite`: an account gets a workspace only through an invitation. |
| TELLTALE_SIGNUP_TAG_KEY | (unset) | With `supabase`: the publishable key (`ao_pk_...`) of an internal Telltale Site, kept in its own internal workspace, that records Telltale's own sign-up screen with the hosted tag (record only: nothing is ever blocked). Never a customer's key. See [Telltale's own sign-up page](/docs/data-retention#telltale-s-own-sign-up-page). |
| TELLTALE_OWNER_EMAIL | (unset) | The verified email that becomes owner of the server's default workspace at its first sign-in, while that workspace has no owner. |
| TELLTALE_MFA | optional | Two-step verification: `optional`, `owners` (required for owners and admins) or `all` (required for everyone). |
| DB_PATH | data/observatory.sqlite | SQLite database file. Without the TELLTALE_*_KEY variables below, the session and webhook keys are kept in the same folder. |
| STORAGE | sqlite | `sqlite` or `postgres`. Setting a DATABASE_* connection string without naming STORAGE stops the server, so it never writes to SQLite by mistake. |
| DATABASE_URL | (unset) | With `postgres`: connection string of the application role (for example `telltale_app`, without BYPASSRLS), which every request uses under row-level security. Required. Never logged. |
| DATABASE_MAINTENANCE_URL | (unset) | With `postgres`: connection string of the maintenance role (for example `telltale_maint`, with BYPASSRLS), which runs migrations, start-up, background work and the sign-in writes that cross workspaces. Required, and a different role from DATABASE_URL. Never logged. |
| DATABASE_SCHEMA | telltale | Postgres schema holding every table. `public` and Supabase's own schemas are refused. |
| DATABASE_POOL_SIZE | 6 | Most connections the application role opens (1–100). See "Postgres connections" below. |
| DATABASE_MAINTENANCE_POOL_SIZE | 4 | Most connections the maintenance role opens (1–100). |
| DATABASE_SSL | verify | `verify`: TLS with the server certificate and host name checked. `off` is accepted only for a database on this machine (localhost). |
| DATABASE_CA_FILE | (unset) | PEM file of the CA the database certificate must chain to. Supabase: Project Settings › Database › SSL configuration › Download certificate (Supabase Root 2021 CA). Its SHA-256 fingerprint is 80:70:25:AD:50:D4:ED:21:9D:2C:9C:7D:29:9C:00:4F:82:4E:B0:0C:F7:F6:5A:FE:F6:07:D0:7B:72:E6:CA:FA (`openssl x509 -in <file> -noout -fingerprint -sha256`). Unset: Node's built-in CAs, which do not include Supabase's. |
| DATABASE_STATEMENT_TIMEOUT_MS | 30000 | Postgres only. Longest one statement of a request may run (ms, 1,000–3,600,000, or 0 for no limit); background work (retention, sweeps, rollups) gets ten times as long. The database cancels it after this (503 `service_unavailable`), and the server gives up 5 seconds later even when the database or network never answers, closing that connection. Schema migrations at start-up are allowed 30 minutes. |
| TELLTALE_SESSION_KEY | (a key file beside DB_PATH) | Base64 of at least 32 random bytes: signs browser session tokens and keys the audit log's hashes. Required with `postgres` (every instance must hold the same key, and a redeploy must not mint a new one); optional on SQLite, where the `session-key` file beside the database is used when it is unset. Never printed. See "Server keys" below. |
| TELLTALE_SESSION_KEY_PREVIOUS | (unset) | The session key before a rotation: tokens signed with it keep verifying while it is set. Remove it 24 hours after the rotation. |
| TELLTALE_WEBHOOK_KEY | (a key file beside DB_PATH) | Base64 of at least 32 random bytes: encrypts stored webhook signing secrets. Required with `postgres`, optional on SQLite (the `webhook-key` file). Losing it makes every stored webhook secret unreadable. |
| TELLTALE_WEBHOOK_KEY_PREVIOUS | (unset) | The webhook key before a rotation: secrets encrypted with it still decrypt and are re-encrypted with the current key when read. |
| TELLTALE_EXPORT_AWS_ACCESS_KEY_ID | (unset) | Telltale's own AWS access key ID for [S3 export](/docs/s3-export), used only to call `sts:AssumeRole` on the role each customer creates; give it no other permission. Set it together with the secret below; unset, the export can be set up but writes nothing. |
| TELLTALE_EXPORT_AWS_SECRET_ACCESS_KEY | (unset) | The secret access key for TELLTALE_EXPORT_AWS_ACCESS_KEY_ID. Never logged. |
| TELLTALE_EXPORT_AWS_SESSION_TOKEN | (unset) | The session token when the export credentials are temporary. |
| TELLTALE_EXPORT_AWS_ACCOUNT_ID | (unset) | Telltale's AWS account ID: the trust policy customers paste names `arn:aws:iam::<account ID>:root`. |
| TELLTALE_EXPORT_AWS_PRINCIPAL_ARN | (unset) | A narrower principal for that trust policy (an IAM role or user ARN), used instead of the account root. |
| TELLTALE_VAULT_KEY | (a key file beside DB_PATH) | Base64 of exactly 32 random bytes: encrypts the Cloudflare/Zaraz connections' credentials. Required with `postgres` when the Cloudflare integration is configured (CLOUDFLARE_CLIENT_ID, CLOUDFLARE_CLIENT_SECRET and CLOUDFLARE_SCOPES all set); not needed otherwise. Optional on SQLite (the `cloudflare-key` file; its base64 is a valid value). Losing it means reconnecting Cloudflare. |
| TELLTALE_VAULT_KEY_PREVIOUS | (unset) | The vault key before a rotation: credentials sealed with it still open and are re-sealed with the current key when next used. |
| EDGE_PORT | (unset) | Loopback port of the optional public edge listener. Set it together with EDGE_ORIGIN. |
| EDGE_ORIGIN | (unset) | Exact HTTPS origin of the public edge. It serves only the public collector, SDK, server API and docs paths. |
| TRUST_PROXY | off | Number of trusted reverse-proxy hops (1–5, `true` means 1) whose X-Forwarded-For entries name the client. |
| ALLOW_LOCAL_WEBHOOKS | (unset) | Flag. Allows webhook endpoints on loopback and private addresses, for local development. |
| TELLTALE_SESSION_CAP | 100000 | Live browser sessions per Site (10 to 100,000,000). |
| TELLTALE_STORAGE_BUSY_MS | 10000 | Longest a storage call waits behind another request's transaction before it fails with 503 `service_unavailable` (1–600,000 ms). The health check waits at most 2 seconds. |
| LOG_FORMAT | text | `json` writes one JSON object per line on stdout and stderr (`time`, `level`, `msg` and fields), for CloudWatch Logs or another collector; `text` writes plain lines. Credentials, connection-string passwords, bearer tokens and email addresses are removed from every line. |
| LOG_LEVEL | info | `debug`, `info`, `warn` or `error`. |
| LOG_REQUESTS | errors | Which requests get a log line (method, route with IDs replaced, status, duration, request ID; never the query, body, headers or client address): `off`, `errors` (5xx answers and requests slower than SLOW_REQUEST_MS) or `all`. |
| SLOW_REQUEST_MS | 2000 | A request slower than this is logged as slow (50–600,000 ms). |
| METRICS_EMF | (unset) | `1` writes one CloudWatch Embedded Metric Format line a minute (namespace `Telltale`), which CloudWatch Logs turns into metrics: requests, 5xx answers, latency, storage errors, event-loop delay, database pool waits, maintenance lag and background delivery backlogs. Needs LOG_FORMAT=json. |
| OPS_METRICS_TOKEN | (unset) | When set (32–256 characters), `GET /ops/metrics` with `Authorization: Bearer <token>` answers the same counters as JSON for the operator. Unset: `/ops/metrics` is 404. Never served by the public edge listener. |
| SHUTDOWN_TIMEOUT_MS | 20000 | On SIGTERM or SIGINT the server stops accepting connections, finishes the requests in flight, the maintenance pass and queued writes, hands back the maintenance lease and closes the database; past this budget it exits at once (1,000–120,000 ms). Keep it below the platform's stop timeout (ECS: 30 s). |
| HTTP_KEEPALIVE_TIMEOUT_MS | 65000 | How long an idle keep-alive connection stays open (1,000–600,000 ms). Keep it longer than the load balancer's idle timeout (60 s on an AWS ALB), or the balancer can reuse a connection the server is closing and answer 502. |
| TELLTALE_BEHAVIOUR_RAW_MINUTES | 10080 | Minutes raw behaviour batches are kept (1–10,080, so at most 7 days; set it lower to keep them for less). Page request shapes (the page-HTTP lane) are kept for one hour. |
| TELLTALE_STALE_TOOL_DAYS | 14 | Days without an observed request before a discovered tool is marked stale (1–3,650). |
| TELLTALE_LABEL_MIN_SESSIONS | 5 | Sessions that must show a control label before it can name a tool input (2–1,000). |
| TELLTALE_SAMPLE_WORKSPACE | on | The built-in, read-only sample workspace every signed-in account can open from its empty screens (`off` removes it). It is generated on first use into its own SQLite file under the data directory (about 15 s, then once a day so its dates stay recent) and never shares storage with your workspaces. |
| TELLTALE_DETECTOR_HISTORY | (unset) | Set to 1 only on a dedicated local controlled-test server to retain bounded detector decision inputs. Requires loopback origin and no public edge. Off by default. |
| TELLTALE_RATE_SERVER_KEY | (see Stable errors) | Requests per minute per server key. |
| TELLTALE_RATE_SDK_SESSION | (see Stable errors) | Browser SDK requests per minute per session. |
| TELLTALE_RATE_SDK_EVENTS | (see Stable errors) | Evidence batches per minute per session. |
| TELLTALE_RATE_INIT_PER_IP | (see Stable errors) | Session starts per minute per client address and Site. |
| TELLTALE_RATE_INIT_PER_IP_ALL_SITES | (see Stable errors) | Session starts per minute per client address across Sites. |
| TELLTALE_RATE_INIT_PER_SITE | (see Stable errors) | Session starts per minute per Site. |
| TELLTALE_RATE_IP | (see Stable errors) | Other API requests per minute per client address. |
| TELLTALE_WBA_OPERATORS | (unset) | JSON object adding reviewed Web Bot Auth operators (origin to name). |
| TELLTALE_WBA_ALLOW_INSECURE | (unset) | Flag, local tests only. Allows HTTP key directories. |
| TELLTALE_CRAWLER_IP_LISTS | (published lists) | `off`, or a JSON object of crawler source IDs to list URLs. |
| TELLTALE_CRAWLER_IP_ALLOW_INSECURE | (unset) | Flag, local tests only. Allows HTTP crawler lists. |
| TELLTALE_CRAWLER_RDNS | on | `on` or `off`: background reverse DNS checks for declared search crawlers. |
| TELLTALE_NETWORK_RANGES | (published lists) | `off`, or a JSON object of connection-origin source IDs to list URLs. |
| TELLTALE_NETWORK_RANGES_ALLOW_INSECURE | (unset) | Flag, local tests only. Allows HTTP range lists. |
| TELLTALE_EDGE_JA4_HEADER | (unset) | Header name a Cloudflare rule writes the JA4 fingerprint into (edge listener only). |
| TELLTALE_NETWORK_PROBE_URL | (unset) | HTTPS `/probe` endpoint for the network lab page. |
| TELLTALE_NETWORK_DNS_ZONE | (unset) | DNS zone for the network lab page; needs the probe URL. |
| TELLTALE_OPENAI_API_KEY | (unset) | Key for optional wording suggestions in tool review. |
| TELLTALE_REFINEMENT_MODEL | (unset) | Model name for those suggestions. |
| TYPESAFE_API_KEY | (unset) | TypeSafe key for [AI summaries](/docs/ai-summaries) (Jev, the first provider). AI summaries stay off until a workspace owner turns them on. |
| ANTHROPIC_API_KEY | (unset) | Anthropic key for AI summaries: Claude Haiku answers when TypeSafe is not configured or fails. |
| TELLTALE_AI_TYPESAFE_URL | https://api.typesafe.ai | TypeSafe API address (a proxy). Must be https; http only on this machine. |
| TELLTALE_AI_ANTHROPIC_URL | https://api.anthropic.com | Anthropic API address (a proxy). Must be https; http only on this machine. |
| TELLTALE_AI_DAILY_CALLS | 50 | Provider requests per workspace per UTC day for AI summaries; summaries pause for the day when spent. |
| TELLTALE_AI_DAILY_TOKENS | 200000 | Provider tokens (in and out) per workspace per UTC day for AI summaries. |
| CLOUDFLARE_CLIENT_ID | (unset) | OAuth client for the Cloudflare and Zaraz install. |
| CLOUDFLARE_CLIENT_SECRET | (unset) | Its secret. |
| CLOUDFLARE_SCOPES | (unset) | Extra OAuth scopes to request. |
| TELLTALE_PLANS | off | `on` applies Telltale's plans and their soft limits ([Pricing](/docs/pricing)). Ignored with `AUTH_MODE=local`, where every workspace is self-hosted with no limits. Workspaces that exist when plans are switched on start the 14-day Growth trial from then; until the `STRIPE_*` variables are set, payments aren't open (Checkout and the portal answer `409 payments_not_open`). |
| STRIPE_SECRET_KEY | (unset) | Stripe key for plan billing (a restricted `rk_` key is preferred). Set with STRIPE_WEBHOOK_SECRET and STRIPE_PRICES, or not at all; needs `TELLTALE_PLANS=on`. |
| STRIPE_WEBHOOK_SECRET | (unset) | Signing secret (`whsec_…`) of the Stripe endpoint pointing at `/api/billing/stripe-webhook`. |
| STRIPE_PRICES | (unset) | JSON of Stripe price ids per plan: `{"starter":{"monthly":"price_…","yearly":"price_…","overage_monthly":"price_…","overage_yearly":"price_…"},"growth":{…}}`. Overage prices are optional. |
| STRIPE_FOUNDING_COUPON | (unset) | Coupon applied at Checkout for founding-partner workspaces (50 % off for 12 months). |
| STRIPE_METER_EVENT | telltale_identified_users | Event name of the Billing Meter that counts identified users past the allowance. |
| STRIPE_API_BASE | https://api.stripe.com | Stripe API origin; `http` only for a loopback test server. |
| TELLTALE_EMAIL_SENDER | (unset) | The configured email sender, once Telltale has one. Unset, plans describe the weekly agent report as "webhook; email coming soon". |

The server stops cleanly on SIGTERM or SIGINT: it stops accepting requests, saves pending counters, finishes the current background pass and closes the database.

### Postgres connections

With `STORAGE=postgres` the server checks before it opens anything, and refuses to start with a message that names the setting to fix: both connections use TLS with a verified certificate; DATABASE_URL's role does not bypass row-level security and is not a member of the maintenance role; DATABASE_MAINTENANCE_URL's role does; the schema is owned by the maintenance role; and none of `anon`, `authenticated`, `service_role`, `authenticator` or PUBLIC can use the schema, and none of its tables is in a publication, so Supabase's Data API and Realtime never reach Telltale's tables. Create the schema and both roles this way once, with an owner connection, before the first start. The schema's tables are created and upgraded by the server at start, as the maintenance role. Local sign-in mode still needs a loopback PUBLIC_ORIGIN whatever the storage.

Pool sizes: each pool opens connections only when it needs them and closes them after 30 idle seconds. The maintenance pool also serves request-time work (finding a Site or session before the request has a workspace, the activity refresh behind dashboard reads, counter flushes), so keep it at 3 or more. On Supabase, connect through the session pooler (Supavisor session mode, port 5432, `aws-<n>-<region>.pooler.supabase.com`, user `<role>.<project ref>`; copy the host from the Connect dialog). It is reachable over IPv4 on every plan; the direct host `db.<ref>.supabase.co` is IPv6-only unless the paid IPv4 add-on is bought. Use port 5432 (session mode), the mode Telltale is tested on, not 6543. The session pooler's pool size (Database › Connection pooling; 15 by default) applies to each database role separately, so the application and maintenance roles each have their own cap. Size so that `DATABASE_POOL_SIZE × instances running at once` stays within it for the application role, and `DATABASE_MAINTENANCE_POOL_SIZE × instances running at once + 2` for the maintenance role (keeping 2 for the operator's own tools), counting the second instance a rolling or bluegreen deploy runs for a moment. All of them together must also stay under the database's own connection limit (60 on Micro compute). The defaults (6 + 4) fit one instance at a time; one instance through a deploy, as on the AWS Lightsail template (deploy/README.md), uses 4 + 3: 8 and 8 connections at most against a cap of 15 each. The ECS Fargate template runs one task with no deploy overlap and uses 10 + 5 (12 of 15 for maintenance with the operator's 2); a second task needs 7 + 4 each first. A transaction holds its connection while it works, so a pool that is too small shows as requests waiting on the pool (503 storage_busy under load) while the database itself is idle: in a local measurement with about 2 ms to the database, a task at 4 + 3 accepted about 384 browser events a second and at 12 + 6 about 1,500.

A connection the database or pooler refuses for load (too many clients, Supavisor's circuit breaker, a network blip) is retried with backoff (0.1 s doubling to 2 s, with jitter) within the same wait bound as any other storage wait (TELLTALE_STORAGE_BUSY_MS, at least 10 s for a new connection), and the backoff is shared by the pool, so a burst of requests never becomes a burst of logins; a request that still gets no connection answers 503. A failed login (wrong password, unknown role) is never retried: Supavisor blocks the whole project for a few minutes after a burst of failed logins, so a misconfigured instance fails at its start-up checks instead.

GET /health also reports `"storage":"postgres"` and checks both pools.

### Server keys

Every instance of a server on one database must hold the same session key (browser session tokens signed by one instance are checked by another) and the same webhook key (stored webhook secrets are encrypted with it). With `STORAGE=postgres` they therefore come from TELLTALE_SESSION_KEY and TELLTALE_WEBHOOK_KEY, and the server refuses to start without them; key files on a server's own disk would differ between instances and be lost on every redeploy. Each is base64 of 32 random bytes: generate each once with `openssl rand -base64 32`, put the values straight into the host's secret store (for example `fly secrets set`), and keep no copy on disk. Keep them as safe as the database password: whoever holds the session key can sign session tokens.

To rotate one, set its `_PREVIOUS` variable to the current value and the key itself to a new one (`openssl rand -base64 32` again gives a fresh value), then restart every instance. The rotation is recorded once in every workspace's audit log as `key.rotated`. Remove TELLTALE_SESSION_KEY_PREVIOUS after 24 hours, when every session signed with it has expired, and TELLTALE_WEBHOOK_KEY_PREVIOUS once every webhook has been read (each delivery or settings view re-encrypts its secret). On SQLite the variables are optional; without them the keys stay in the `session-key` and `webhook-key` files beside the database, rotated as [Authentication and keys](/docs/authentication) describes (stop the server, move the file to `session-key.previous` or `webhook-key.previous`, start it again).

The Cloudflare/Zaraz integration seals its connections' credentials with a third key, TELLTALE_VAULT_KEY (exactly 32 bytes). It is required with `STORAGE=postgres` only when the integration is configured (all three CLOUDFLARE_* variables set), since nothing else uses it; generate it the same way (`openssl rand -base64 32`) if you turn the integration on later. Without the integration and without the key, Postgres servers write no key file and the vault refuses any use. On SQLite it is optional and the `cloudflare-key` file beside the database is used when it is unset; to move such a server to the variable, set it to the file's base64 (`base64 < data/cloudflare-key`). To rotate it, set TELLTALE_VAULT_KEY_PREVIOUS to the current value and TELLTALE_VAULT_KEY to a new one: credentials sealed with the previous key still open and are re-sealed with the new one the next time the connection is used. Remove the previous key once every connection has been used since (a connection whose credentials no key opens must be reconnected).


### Sign-in with Supabase

With `AUTH_MODE=supabase` people create their own accounts. Supabase Auth holds only their identity (email, Google or GitHub link, two-step factors); workspaces, roles, memberships and invitations stay in Telltale's own tables, and no token claim is ever used for a role. The Telltale server is the only Supabase client: the dashboard posts to `/api/auth/*` on its own origin (its Content-Security-Policy is unchanged), the server calls Supabase's Auth API, verifies every access token against the project's published keys (ES256 or RS256, with exact issuer, audience, expiry and role checks), and keeps the tokens encrypted in its own session row. The browser only ever holds the HttpOnly `ao_admin` cookie.

- **Sessions** last at most 24 hours, and end after 12 hours without use. Tokens are refreshed shortly before they expire, and every SUPABASE_RECHECK_SECONDS the server checks with Supabase that the person still exists and is not banned, so a person removed in Supabase loses the dashboard within minutes. If Supabase can't be reached, a session keeps working until its access token expires plus 5 minutes, then answers 503 `auth_unavailable`.
- **First sign-in**: with TELLTALE_SIGNUP=open a new account gets "My workspace" as its owner, unless invitations are waiting for that verified email (the person then chooses). With `invite`, an account without an invitation sees "No workspace yet". An existing password-mode account is linked to the Supabase account with the same email the first time that email is verified in Supabase; its stored password hash is then deleted.
- **No account enumeration**: sign-up, email-link, resend and reset requests answer the same whatever the address, and take at least 400 ms; a wrong password and an unknown email give the same answer. Emails are limited to one a minute and five an hour per address, and ten requests a minute per client address.
- **Two-step verification** uses an authenticator app (TOTP). TELLTALE_MFA=owners or all makes it required: a covered person whose session has not passed a code gets 403 `mfa_required` on the dashboard API. Wrong codes are limited to five per 15 minutes per person.
- **Audit**: `auth.signed_up`, `auth.account_linked`, `auth.account_relinked`, `auth.mfa_enabled`, `auth.mfa_disabled`, `auth.sessions_revoked` ("Sign out everywhere"), `member.email_changed` and `auth.owner_setup`, beside the existing `member.*`, `team.*` and `auth.login_failed`. They never hold an email, password, token, link, code or IP address.

Set up Supabase before switching: a secret API key; ES256 JWT signing keys; the Data API turned off (Telltale uses only its Postgres connection, and its tables are in the `telltale` schema, which must never be exposed); Site URL = PUBLIC_ORIGIN and the single redirect URL `<PUBLIC_ORIGIN>/api/auth/oauth/callback`; Email provider with "Confirm email", secure email change and secure password change on, minimum password length 12, and "Allow new users to sign up" on (Telltale decides who gets a workspace); the four email templates pointing at `{{ .SiteURL }}/#auth=confirm&type=<signup|magiclink|recovery|email_change>&token_hash={{ .TokenHash }}`; forwarding of the client IP (`Sb-Forwarded-For`) in Rate Limits, with TRUST_PROXY set so the server sees the visitor's address; and a custom SMTP sender before launch (Supabase's built-in sender allows only a few emails an hour). At start with `AUTH_MODE=supabase` the server refuses a missing or malformed SUPABASE_URL or SUPABASE_SECRET_KEY with a message naming the setting; the Supabase-side settings above (Data API off, redirect URL, email confirmation) are yours to check in the Supabase dashboard before you restart.

Moving a password-mode server: each existing account links to its Supabase account the first time the same email is verified in Supabase, so people sign up or sign in with an email link, Google or GitHub, or "Forgot password". To create the Supabase accounts in advance, ask jad@heypharos.com for the migration script. Rolling back to `password` works for accounts that still have a password hash here; linked accounts would need a password reset, which password mode has no email flow for.

After rotating or revoking a Supabase signing key, allow 20 minutes before relying on the change: Supabase's edge caches the key set for 10 minutes and this server for another 10.


## Controlled detector history

For a dedicated isolated test database, `TELLTALE_DETECTOR_HISTORY=1` records SDK init/re-init responses, batch responses, context results, event-prefix decisions and the detector verdict consumed by protected-action/display verification, tool checks and usage recording. It is disabled by default and refused with a public edge or non-loopback public origin. Do not enable it for an ordinary customer installation.

An authenticated dashboard reader can retrieve `/api/admin/detector-history?session=<session-id>` for a session in their workspace. The export declares its covered and missing decision paths and compares recorded categorical verdicts/bases and per-rule policy outcomes with replay. Event-prefix decisions share a batch clock and post-batch features; they are not independent historical observations and must not be used as latency samples.

History is bounded to 1,000 decisions or 2 MiB per session, with at most 100 retained histories per Site after normal pruning. It is retained for one day after its last recorded activity and removed on consent withdrawal or subject erasure. Missing initial state or exceeded limits are explicit gaps. A matching replay does not establish real-human accuracy, model identity, production latency, or complete dashboard-display coverage. Verification and tool traces replay their recorded rule choices, evidence-age boundary and agent-call strictness. They do not reproduce aggregate workflow outcomes or the customer application’s authorization. A tool-start trace preserves the detector verdict before the separate agent-initiated usage classification; Unknown is never relabeled Human. No raw typed text, credentials, identities or request bodies are recorded in the input snapshots.

