# Verify evidence before a protected action

`POST /api/v1/verify` · operationId `verifyProtectedAction`

For developers: your server sends this request with a server key; it is not a button in the dashboard.

Apply your own authenticated account authorization and the returned decision. Unknown is not human. Optionally forward the incoming request’s Web Bot Auth headers to receive signed attribution. The server adapter retries valid 503 service_unavailable, config_unavailable or session_unavailable responses, and once on a 408/429 whose Retry-After fits, at most three attempts within one five-second deadline; it never executes the protected application action.

## Authentication

Send `Authorization: Bearer YOUR_SERVER_KEY` (siteServerKey: Site server key in Authorization: Bearer <key> (the scheme is case-insensitive and required; a bare key is 401). Shown once on creation or rotation. Publishable keys cannot authenticate). Keep the server key in server-only configuration; never send it to a browser.

## Parameters

- `Observatory-Attribution` (header, "declared", optional): declared: also return the declared (self-reported user agent) attribution tier (strength declared, kind self-declared, evidence type declared-user-agent). Without it the response uses only the Attribution values listed in this document; clients generated from it keep working.

## Request body

Content-Type: application/json (required)

- `sessionToken` (string, required) · minLength 1, maxLength 250: Opaque browser session token; do not log.
- `actionId` (string, required) · pattern `^[A-Za-z0-9_-]{1,64}$`
- `request` (ForwardedRequest, optional): Your incoming request as the agent sent it: an allowed Site origin URL plus signature, signature-input, signature-agent and any other covered headers. Never forward cookie or authorization; they are rejected.
- `evidence` (EdgeEvidenceInput, optional): Outside evidence from your own edge or providers (/docs/edge-evidence). Server key only, never from a browser. Each source is kept separate and labelled; edge evidence on its own may lead to deny only for a Cloudflare-verified signed agent, otherwise to review at most. Nothing here can make a session human.
- `purpose` ("protect" | "display", optional): display marks a check that only shows status (a header badge). It is recorded but never alerts, queues for review or counts as protection.
- `targetId` (string, optional) · pattern `^[A-Za-z0-9_-]{1,128}$`: Opaque ID of the object acted on, chosen by your backend (for example report_1042). Never an email, name or other request value.
- `userId` (string | null, optional) · minLength 1, maxLength 512, pattern `\S`: Stable authenticated ID. Maximum 512 Unicode code points for server-verified identity (browser-supplied IDs stay at 128 UTF-16 code units); whitespace is trimmed.
- `accountId` (string | null, optional) · minLength 1, maxLength 512, pattern `\S`: Stable authenticated ID. Maximum 512 Unicode code points for server-verified identity (browser-supplied IDs stay at 128 UTF-16 code units); whitespace is trimmed.

```json
{
  "sessionToken": "PASTE_BROWSER_SESSION_TOKEN",
  "actionId": "export_report",
  "targetId": "report_1042",
  "userId": "usr_123",
  "accountId": "acct_456"
}
```

## Responses

- **200**: Success · body `Verification`
- **400**: bad_request: the request is invalid; message says what to fix and field names the request field · body `Error`
- **401**: unauthorized (missing or invalid server key, missing Bearer scheme, or a malformed or unsigned session token) or session_reinit_required (a token this collector issued whose session expired, was revoked or was removed); use the code to distinguish · body `Error`
- **403**: forbidden: the server key and session token belong to different Sites, or the call came from a browser page · body `Error`
- **404**: not_found: unknown path, or an ID in the path that is not on this Site · body `Error`
- **409**: conflict: the request contradicts what is already recorded; message says what · body `Error`
- **413**: payload_too_large: the request body exceeds 131072 bytes · body `Error`
- **429**: rate_limited: wait for Retry-After · body `Error` · headers `Retry-After`
- **500**: internal_error: fail the protected action; do not retry automatically · body `Error`
- **503**: service_unavailable: temporarily unavailable; only verify retries · body `Error`

### Success body fields

- `actionVerdict` ("human" | "agent" | "unknown", optional): The stricter verdict used for protection and billing. Enforce decision; analytics-only AI agent does not select the AI agent rule.
- `agentEvidence` ("verified" | "automation-trace" | "injected-input" | "sustained-behaviour" | "behaviour-change" | "behaviour-corroborated" | "cursor-driven" | "behaviour" | null, optional): What backs an AI agent actionVerdict: verified (a valid Web Bot Auth signature), automation-trace (webdriver flag, automation framework or headless browser), injected-input (keys with no hardware delay, on Chromium), sustained-behaviour (a consistent behaviour reading over about 20 s of activity, at least 10 input events, no Human reading in between, two independent behaviour signals), behaviour-change (after a Human reading, the same sustained evidence with physical-device input seen to stop: a handover), behaviour-corroborated (two or more independent behaviour families, keyboard timing, pointer and clicks, text insertion, agree on one page and the session shows no physical-device input) or behaviour (behaviour signals only). High confidence (every value but behaviour) follows the AI agent rule, Deny included, and is chargeable; medium confidence (behaviour) is held for review by default (the site owner may block it) and never charged. null when actionVerdict is not agent.
- `billable` (boolean, optional): Whether this reading is chargeable (counts toward billing): an AI agent actionVerdict at high confidence. Medium confidence (behaviour) is never chargeable.
- `heldInsteadOfDeny` (true, optional): Present when the AI agent rule said Deny but the verdict is medium confidence (behaviour alone) and the site holds medium confidence for review (the default), so decision is review.
- `confidence` ("high" | "medium" | "low" | null, optional): Strength band from agentEvidence, never a probability: high (verified, automation-trace, injected-input, sustained-behaviour, behaviour-change, behaviour-corroborated) or medium (behaviour). low is reserved. null when actionVerdict is not agent.
- `behaviourOnlyDenyEnabled` (boolean, optional): Present for a medium-confidence AI agent verdict (agentEvidence behaviour): false when it was held for review (the default), true when the site blocks medium confidence so the AI agent rule applied in full.
- `behaviourOnlyReason` (string, optional): Plain sentence saying which applied to a medium-confidence AI agent verdict.
- `stepUp` (StepUpHint, optional): Present when decision is review: settle the hold with your own check and POST its result to report, or leave it to the hosted queue.
- `analyticsOnly` (boolean, optional): Analytics-only: true when the evidence is enough to count the session as AI agent in reports but not to act on, so actionVerdict (used for protection and billing) stays unknown.
- `sessionId` (string, required)
- `actionId` (string, required) · pattern `^[A-Za-z0-9_-]{1,64}$`
- `verdict` ("human" | "agent" | "unknown", required)
- `score` (1 | 50 | 99, required): Categorical evidence score: human=1, unknown=50, agent=99. Not a probability.
- `reasons` (string[], required)
- `basis` (string, optional)
- `driver` (string | null, optional): Legacy label derived from attribution: the attributed controller, Unattributed automation for an agent verdict without naming evidence, or null. Prefer attribution.
- `attribution` (Attribution | null, optional)
- `attribution.strength` ("signed" | "weak-marker" | "unattributed-automation", required): signed: verified Web Bot Auth signature from a reviewed operator directory. weak-marker: a renamable page marker beside an agent verdict. unattributed-automation: agent evidence without a naming signal. Opt-in (request header Observatory-Attribution: declared, sent by the current server adapter): the value declared may also appear, meaning the client named itself in its user agent (self-declared, spoofable; attribution only, never verdict evidence, never a usage category), with kind self-declared and evidence type declared-user-agent. Without the header these values never appear and attribution is computed as if no declaration was sent.
- `attribution.controller` (string | null, required): Named only together with strength. Null for unattributed automation.
- `attribution.operator` (string | null, required): Web Bot Auth operator origin; present only for signed.
- `attribution.operatorType` ("direct" | "intermediary", required): Signed only, when the reviewed registry says. direct: the operator runs the agent. intermediary: a cloud browser platform that many customers' agents run on; the name is the platform, not the customer agent.
- `attribution.cloudRun` (object, required): Beside an agent verdict only: the session runs from a data-centre address with cloud-machine signs. A descriptor, never a name.
- `attribution.cloudRun.descriptor` ("Runs from a cloud computer", required)
- `attribution.cloudRun.strength` ("unconfirmed", required)
- `attribution.cloudRun.howWeKnow` ("Unconfirmed", required)
- `attribution.kind` ("operator" | "browser-extension" | "agentic-browser" | "framework" | "agent-framework" | "automation-framework" | null, required): automation-framework (Playwright, Puppeteer, Selenium) is named only beside an agent verdict in a WebDriver page; it names the framework, never an AI model or agent category.
- `attribution.observedAt` (number | null, required)
- `attribution.validUntil` (number | null, required): Signature expiry; signed attribution lapses then.
- `attribution.evidence` (object[], required)
- `attribution.evidence[].type` ("web-bot-auth" | "page-marker" | "framework-artifact", required)
- `attribution.evidence[].operatorType` ("direct" | "intermediary", optional)
- `attribution.note` (string, required)
- `pageMarkers` (object[], optional): Diagnostic only, not attribution: renamable page markers seen in the evidence window, whatever the verdict. attribution names a marker only beside an agent verdict.
- `pageMarkers[].type` ("page-marker", required)
- `pageMarkers[].marker` (string, required)
- `pageMarkers[].controller` (string, required)
- `pageMarkers[].registryName` (string | null, required)
- `pageMarkers[].provenance` (string, required)
- `pageMarkers[].observedAt` (integer | null, required)
- `crawlerIdentity` (object, optional): Present only when the request declared a known crawler or fetcher. A claim never changes the verdict.
- `crawlerIdentity.id` (string, required)
- `crawlerIdentity.name` (string, required)
- `crawlerIdentity.operator` (string, required)
- `crawlerIdentity.kind` (string, required)
- `crawlerIdentity.status` ("verified" | "declared-contradicted" | "declared-unverified", required)
- `crawlerIdentity.statusLabel` ("Checked" | "Claim didn't check out" | "Couldn't check", optional)
- `crawlerIdentity.verified` (boolean, required)
- `signatureCheck` (object, optional): Present only when request headers were forwarded. Failure or absence is not evidence of a human.
- `signatureCheck.status` ("verified" | "absent" | "malformed" | "insecure-transport" | "not-web-bot-auth" | "missing-parameter" | "unsupported-algorithm" | "target-not-covered" | "signature-agent-missing" | "signature-agent-not-covered" | "unsupported-discovery" | "unsupported-component" | "missing-component" | "expired" | "not-yet-valid" | "unreviewed-operator" | "directory-unavailable" | "unknown-key" | "expired-key" | "invalid-signature" | "replayed", required)
- `signatureCheck.operator` (string | null, required)
- `signatureCheck.operatorType` ("direct" | "intermediary", optional)
- `warnings` ("policy_missing"[], optional): Present only when there is something to fix. policy_missing: no policy is saved for this actionId, so the default policy (human allow, agent review, unknown allow, stale deny) decided; the Observatory-Warning response header says the same.
- `declaredFetcher` (object, optional): Present only when the collector or a forwarded request declared a user-triggered fetcher (ChatGPT-User, Claude-User, Perplexity-User) in its user agent. IP ranges are not verified, so it is a claim: it never changes the verdict, names a controller or selects an agent category.
- `declaredFetcher.id` ("chatgpt-user" | "claude-user" | "perplexity-user" | "google-gemininotebook", required)
- `declaredFetcher.name` (string, required)
- `declaredFetcher.operator` (string, required)
- `declaredFetcher.status` ("verified" | "declared-contradicted" | "declared-unverified", required): verified: the request address is in the operator's published list. declared-contradicted ("Claim didn't check out"): the list was fresh and the address is outside it. declared-unverified ("Couldn't check"): the list was stale or unavailable. None of them changes the verdict.
- `declaredAgent` (object, optional): Present only when the collector or a forwarded request carried a self-declared agent token in its user agent (Agent-NovaAct/). Anyone can send or remove it: it never changes the verdict or selects a usage category, and it names a controller (strength declared) only beside an agent verdict. The user-agent string is never returned or stored.
- `declaredAgent.id` ("nova-act", required)
- `declaredAgent.controller` (string, required)
- `declaredAgent.operator` (string, required)
- `declaredAgent.basis` ("vendor-documented", required)
- `declaredAgent.sourceStrength` ("medium", required)
- `declaredAgent.spoofable` (true, required)
- `observedAt` (number, required): Unix epoch milliseconds.
- `stale` (boolean, required)
- `decision` ("allow" | "review" | "deny", required)
- `policy` (Policy, required)
- `identity` (Identity | null, optional)
- `identity.userId` (string | null, required) · minLength 1, maxLength 512, pattern `\S`: Stable authenticated ID. Maximum 512 Unicode code points for server-verified identity (browser-supplied IDs stay at 128 UTF-16 code units); whitespace is trimmed.
- `identity.accountId` (string | null, required) · minLength 1, maxLength 512, pattern `\S`: Stable authenticated ID. Maximum 512 Unicode code points for server-verified identity (browser-supplied IDs stay at 128 UTF-16 code units); whitespace is trimmed.
- `identity.verified` (boolean, required)
- `decisionId` (string, optional) · pattern `^decision_[A-Za-z0-9_-]{8,64}$`: The persisted decision. Use it to report the outcome, request review or read the review state.
- `purpose` ("protect" | "display", optional)
- `targetId` (string, optional) · pattern `^[A-Za-z0-9_-]{1,128}$`
- `identityMatched` (boolean, optional): Present when you sent userId or accountId and the session has a server-associated identity: whether they match.
- `review` (object, optional): Present when the decision is review: the action is waiting in the hosted review queue.
- `review.status` ("pending", required)
- `edgeEvidence` (EdgeEvidence, optional): Present only when evidence was sent: what your edge reported, labelled per source with a strength. Recorded with the decision.
- `note` (string, optional)

Failures are not verdicts. Error bodies follow the `Error` schema: `code` is one of `bad_request`, `unauthorized`, `session_reinit_required`, `forbidden`, `not_found`, `conflict`, `identity_required`, `payload_too_large`, `rate_limited`, `internal_error`, `service_unavailable`, `request_failed`, `approval_invalid`, `approval_used`, `approval_expired`, `approval_mismatch`, `step_up_not_held`, `step_up_mismatch`, `step_up_settled`, `step_up_expired`, `step_up_superseded`, `agent_tokens_disabled`, `tool_not_approved`, `tool_not_read_only`. [Errors and retries](/docs/errors) explains each code and what to do.

## Example

```sh tab=curl
curl -sS -X POST 'https://app.trytelltale.dev/api/v1/verify' \
  -H "Authorization: Bearer $TELLTALE_SERVER_KEY" \
  -H 'Content-Type: application/json' \
  --data '{"sessionToken":"PASTE_BROWSER_SESSION_TOKEN","actionId":"export_report","targetId":"report_1042","userId":"usr_123","accountId":"acct_456"}'
```

```js tab=fetch
const response = await fetch("https://app.trytelltale.dev/api/v1/verify", {
  method: "POST",
  headers: {
    Authorization: 'Bearer ' + process.env.TELLTALE_SERVER_KEY,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    "sessionToken": "PASTE_BROWSER_SESSION_TOKEN",
    "actionId": "export_report",
    "targetId": "report_1042",
    "userId": "usr_123",
    "accountId": "acct_456"
  }),
  signal: AbortSignal.timeout(5000)
});
const result = await response.json();
```

```js tab=node
// server.js is the server adapter: download /adapters/v1/server.js (see Server adapters).
import { createTelltaleServer } from './lib/telltale/server.js';

const telltale = createTelltaleServer({ endpoint: "https://app.trytelltale.dev", serverKey: process.env.TELLTALE_SERVER_KEY });
const result = await telltale.verify({
  "sessionToken": "PASTE_BROWSER_SESSION_TOKEN",
  "actionId": "export_report",
  "targetId": "report_1042",
  "userId": "usr_123",
  "accountId": "acct_456"
});
```

Machine-readable contract: [openapi.json](https://app.trytelltale.dev/openapi.json). Request builder: the [API explorer](/docs/api-explorer) shows every operation as curl; it sends requests only on a local dashboard.
