# API reference

The HTTP API behind the app, the SDKs, the MCP server and the recorder. Every product route is under `/v1`; the base URL in production is `https://api.commitlayer.ai`. This page lists every endpoint by resource, with the request and response shapes as the server sends them. Shapes are written as types, not JSON: `{ id, name?: string, items: Thing[] }` means an object with `id`, an optional `name` and an array of `Thing`.

If you are starting out, the [quickstart](/docs) walks through the five calls that record an action, push the facts and read the verdict. The [action contracts](/docs/action-contracts) reference explains the YAML those calls are judged against.

## Authentication

Two credentials work on every route that needs one:

| Header | Who sends it | Where it comes from |
|---|---|---|
| `X-API-Key: <key>` | Agent runtimes, pipelines, scripts | Settings, API keys. Every plan. A key acts as an **analyst** when its scopes include `actions:record` and as a **viewer** otherwise; the presets (`recorder`, `evidence`, `reader`, `full`) are described under [Settings](#api-keys). |
| `Authorization: Bearer <token>` | The web app, the MCP server | A session JWT from `/v1/auth/*`, or an OAuth access token minted for an MCP client. |

The workspace (tenant) is derived from the credential; nothing in a request names it. Roles, lowest to highest: `counterparty`, `viewer`, `analyst`, `admin`, `owner`. Each endpoint below says the role it needs when it is not `viewer`.

## Conventions

- **Lists** accept `?limit=&cursor=` and return `{ items: [...], next_cursor: string | null }`. `next_cursor` is opaque; pass it back as `cursor`; `null` means the last page.
- **Dates** are ISO-8601 UTC strings. **Periods** are months, `YYYY-MM`. **Money** is `{ amount: number, currency: "USD" }`.
- **Verdicts** use one vocabulary everywhere: `VERIFIED | NOT_VERIFIED | UNVERIFIABLE`, under two lenses, `vendor | strict`. The action endpoints (`/v1/actions*`) present the same values with display labels, `PASS | FAIL | UNKNOWN` and `outcome | policy`, and return the raw value beside them as `raw_verdict`.
- **Idempotency.** Recording an action is idempotent on `action_id`; pushing facts is idempotent on `raw_ref`. A retried write is safe.
- **Body limits.** 64 KiB on `/v1/public/*` and `/v1/auth/*`, 50 MiB for multipart uploads, 4 MiB otherwise; a larger body answers `413 payload_too_large` with `details.limit_bytes`.
- **Client address.** The address the edge sets in `X-Real-IP`, never `X-Forwarded-For`. `GET /v1/public/whoami` → `{ ip }` shows it.

## Errors

Every error is `{ error: { code: string, message: string, details?: any } }` with the matching HTTP status. The codes you will meet on any route:

| Status | Code | Meaning |
|---|---|---|
| 400 | `invalid_request`, `invalid_fact`, `disposable_email` and others named per endpoint | The request is malformed or refused; the message says what to change. |
| 401 | `invalid_session` | The bearer is not a live session token: revoked by a sign-out, not issued by us, or without a subject. |
| 402 | `entitlement_limit` | A plan limit on features, connections, seats or contracts; `details.upgrade_to` names the tier that lifts it. |
| 402 | `trial_expired` | The trial ended before a payment method was added. `upgrade_to` is the workspace's own plan: the message asks to pay for it or choose another, so do not render it as a demand for a higher tier. |
| 402 | `allowance_exceeded` | Developer only: the monthly allowance and the grace after it (72 hours or 250 actions past it, whichever first) are used up. A new action is not recorded until a plan is chosen; everything recorded keeps settling. Paid plans are never refused: past the allowance, actions run under the plan's per-action overage, reported in the response's `allowance` block and invoiced on the 2nd of the following month as one Stripe invoice item (audit `billing.overage_billed`). |
| 403 | `insufficient_scope`, `source_not_allowed`, `role_above_own`, `own_membership`, `mfa_required`, `blocked` | The credential may not do this. `blocked` is a bot-trap ban on the caller's address (24 hours; a request for a scanner path such as `/.env` sets one). |
| 404 | `not_found`, `unknown_contract` | No such object. |
| 409 | `conflict`, `already_exists`, `source_served_by_connector` | The write contradicts what is stored; nothing changed. |
| 413 | `payload_too_large` | See body limits above. |
| 422 | `validation_error` | Field errors as `details: [{ type, loc, msg }]`; the input is never echoed. |
| 429 | `rate_limited`, `fair_use_exceeded` | `rate_limited`: the workspace's requests a minute (`requests`: every authenticated call; `writes`: `POST /v1/actions`, `POST /v1/actions/verify`, `POST /v1/facts`, OTLP ingestion) are spent for its plan. `fair_use_exceeded`: a Developer workspace passed its daily cap on recorded actions or pushed facts. Both carry `Retry-After` and `details.retry_after`; nothing was applied. The figures are on `GET /v1/public/pricing` (`tiers[].rate_limits`, `tiers[].fair_use`). |
| 503 | `busy` | Overloaded; the request was **not applied**. `Retry-After: 1`. The write path admits at most `MAX_CONCURRENT_WRITES` requests at once and a writer that finds no slot within `WRITE_SLOT_TIMEOUT_SECONDS` gets this answer without touching the database. |

## Metering

A **new** `action_id` on `POST /v1/actions`, or an inline action on `POST /v1/actions/verify` that is not a dry run, counts one verified action against the month of its `requested_at`. Re-recording an existing `action_id`, dry runs, verifying an already recorded action by id, the settlement re-checks and sandbox runs are never metered.

On a paid plan nothing is refused past the allowance. Metered responses carry:

```ts
allowance: {
  period, cap, used, over_by,
  mode: "within" | "unlimited" | "overage" | "grace" | "exceeded",
  grace_hours, grace_actions, grace_ends_at,
  overage_per_action, upgrade_to
}
```

`overage` is a paid plan past its allowance (billed per action); `grace` is Developer inside 72 hours of the first action past the allowance and at most 250 actions past it; `exceeded` is Developer after the grace, when a new action answers `402 allowance_exceeded` with the same object in `details`. The first action past the allowance is logged once as `allowance.exceeded`. `allowance` is `null` when the call re-records an existing action. The same object is on `GET /v1/auth/me` as `entitlements.allowance`.

## Public (no auth)

### `GET /health`

**Response**

`{ status: "ok", version, engine_version, db: "ok" }`

### `GET /v1/public/pricing`

**Response**

```ts
{
  trial_days,
  unit,
  allowance_policy,
  grace_hours,
  grace_actions,
  founding: {
    plan,
    price_monthly,
    months,
    seats,
    remaining,
    available,
    reason
  },
  included_everywhere: string[],
  compare: [{ group, label, note, values: { [tier]: boolean | string } }],
  tiers: [
    {
      id,
      name,
      for,
      support,
      price_monthly,
      price_placeholder,
      free,
      contact_sales,
      limits,
      overage_per_action,
      features,
      trial_days,
      rate_limits: { requests, writes } | null,
      fair_use: { actions_per_day, facts_per_day } | null
    }
  ]
}
```

`founding` is the Founding Builder offer read from Stripe's coupon (`remaining` seats from `times_redeemed`); it is `available: false` with a `reason` when Stripe is not configured or cannot be read, never guessed. `compare` is the pricing page's comparison table; a row true on every plan is in `included_everywhere` instead.

### `GET /v1/public/definitions`

**Response**

`{ items: Definition[] }`

```ts
Definition = {
  contract_id,
  version,
  kind: "outcome"|"action",
  lens_labels: { vendor: "vendor"|"outcome", strict: "strict"|"policy" },
  action_type: string|null,
  window: string|null,
  vendor,
  vendor_display,
  outcome_type,
  summary,
  source_citation: { url, retrieved, notes, title },
  unknown_fields: string[],
  lenses: {
    vendor: string[],
    strict: string[],
    preconditions?,
    limits?,
    expected_effects?,
    forbidden_effects?,
    window?: string[]
  },
  billing: { unit_price, currency },
  exclusions: any[]
}
```

Where  (plain-language rule lists; the extra `lenses` keys appear for `kind: action`)

### `GET /v1/public/definitions/{contract_id}`

**Response**

`Definition & { yaml: string }`

### `GET /v1/public/verify/{hash}`

**Response**

```ts
{
  found: boolean,
  kind?: "statement"|"verdict"|"dispute",
  statement?: {
    id,
    vendor,
    contract_id,
    contract_version,
    engine_version,
    period,
    generated_at,
    totals
  },
  verdict?: {...},
  chain_valid?: boolean,
  verdict_count?: number
}
```

A `verdict` match also covers action verdicts (`lens` is the engine name `vendor`/`strict`, `verdict` the raw vocabulary). `chain_valid` is present for statements and verdicts: for a verdict, every verdict of its run up to and including it re-hashes to the stored hash and links to the one before, from the run's genesis; a dispute package has no chain of its own and carries no `chain_valid`

### `GET /v1/public/sample-statement`

**Response**

`Statement`

Synthetic demo tenant, `synthetic: true`

### `GET /v1/public/sample-action`

**Response**

`ActionVerification & { synthetic: true }`

The demo tenant's showcase refund action `act_demo_refund_001` (FAIL: the Shopify order stayed active). `404 not_found` until the demo tenant is seeded. Shape under **Actions** below.

### `POST /v1/public/leads`

**Request body**

```ts
{ name, email, company, message?, monthly_outcomes?, source? }
```

**Response**

`{ ok: true }`

### `POST /v1/public/gap-estimate`

**Request body**

```ts
{ monthly_outcomes: number, unit_price: number, strict_rate: number }
```

**Response**

```ts
{ low: number, high: number, mid: number, assumptions: string[] }
```

### `GET /v1/public/status`

**Response**

```ts
{
  components: [{ name, status: "operational"|"degraded"|"down", checked_at }]
}
```

## Auth

### `POST /v1/auth/dev-login`

**Request body**

`{ email, tenant_name?, name?, plan? }`

**Response**

`Session`

Only when `AUTH_MODE=dev`

Plan applies only when a new tenant is created; the first member is always `owner`

### `GET /v1/auth/workos/authorize?redirect=`

307 to WorkOS AuthKit (when configured). The `state` is signed and good for ten minutes, and the same value is set as a cookie on the API host, so the callback completes only in the browser that started it; a callback with a missing, foreign or expired state lands on `/login?error=auth_failed`. The callback redirects to the `redirect` it was started with (a `${WEB_URL}/auth/callback` URL; `next=` on it survives) plus `code=`, a one-time code good for sixty seconds.

`POST /v1/auth/handoff { code }` → `{ token }`, once (`400 invalid_code` after); the web app's callback route makes that call server-side and sets the cookie, so the token never appears in a URL.

A **new account created with a password** (WorkOS `authentication_method: Password`) gets no session from the callback: the browser lands on `${WEB_URL}/login?notice=verify_email` and a link to `${WEB_URL}/verify-email?token=` is emailed (good once, for 24 hours; a new sign-in or a resend retires it; at most one a minute per address). `POST /v1/auth/verify-email { token }` → `Session` with the token, `mfa.method: "Password"`; `400 invalid_verification` when the link is unknown, used or expired (the address stays verified if a first click already landed, so signing in again works); `403 account_held` for a held workspace. `POST /v1/auth/verify-email/resend { email }` → `{ ok: true }` whatever the address. Google, GitHub, Microsoft, SSO, magic-link and passkey sign-ins arrive verified and never see the step; accounts that predate 0.9.4 are not asked.

### `POST /v1/auth/logout`

Revokes every session of the user: a token issued before the sign-out answers `401 invalid_session` from then on.

A bearer that is not a session token (another token the same key signs, or one without a subject) answers `401 invalid_session`.

`/v1/auth/*`, `/v1/public/*` and `/v1/oauth/*` are limited per client address (`ANON_RATE_PER_MINUTE`, default 60): `429 rate_limited` with `Retry-After`.

### `GET /v1/auth/me`

**Response**

```ts
Session = {
  token?: string,
  user: { id, email, name, mfa_enabled },
  tenant: { id, name, slug, plan, trial_ends_at, synthetic: boolean },
  membership: { role: "owner"|"admin"|"analyst"|"viewer"|"counterparty" },
  entitlements: Entitlements,
  is_platform_admin: boolean,
  impersonating?: boolean,
  mfa: { required: boolean, satisfied: boolean, method: string | null }
}
```

When `mfa.required` is true and `satisfied` is false, every product route answers `403` with code `mfa_required`; only `me`, `logout`, `switch-tenant` and the two enrolment endpoints work.

### `POST /v1/auth/logout`

**Response**

`{ ok: true, logout_url: string | null }`

The web app clears its cookie and, when `logout_url` is set (a session that came from the hosted sign-in), sends the browser there so the provider's session ends too and returns to the site; otherwise it goes to the home page. Never to the sign-in page, which would start a new hosted sign-in at once.

### `POST /v1/auth/mfa/enroll`

**Response**

`{ challenge_id, qr_code (data URI), secret, uri }`

For the signed-in user (hosted sign-in users only; `400 hosted_sign_in_only` otherwise). Shown once; nothing is stored until the first code verifies.

### `POST /v1/auth/mfa/verify`

**Request body**

`{ challenge_id, code }`

The `Session` with `mfa.satisfied: true` and `user.mfa_enabled: true`; `400 invalid_code` for a wrong code, `400 unknown_challenge` if enrolment was not started in this session.

### `POST /v1/auth/logout`

**Response**

`{ ok: true }`

### `POST /v1/auth/switch-tenant`

**Request body**

`{ tenant_id }`

**Response**

`Session`

```ts
Entitlements = {
  plan,
  limits: { connections, outcomes_per_month, contracts, seats },
  features: { [key: string]: boolean },
  usage: { connections, outcomes_this_period, contracts, seats },
  trial_ends_at: string|null
}
```

## Overview

### `GET /v1/overview?period=YYYY-MM`

**Response**

```ts
{
  period,
  vendors: VendorSummary[],
  onboarding: {
    connected: boolean,
    contract_selected: boolean,
    first_run: boolean,
    invoice_imported: boolean
  }
}
```

```ts
VendorSummary = {
  vendor,
  vendor_display,
  contract_id,
  billed: { count, amount },
  verified_vendor: { count, amount },
  verified_strict: { count, amount },
  not_verified_strict: number,
  unverifiable_strict: number,
  definition_delta: { amount, count },
  open_disputes: number,
  trend: [{ period, billed, verified_vendor, verified_strict }],
  latest_statement_id: string|null
}
```

## Connections

### `GET /v1/connections/catalog`

**Response**

```ts
{
  items: [
    {
      source,
      display,
      kind: "claims"|"facts"|"both"|"internal",
      auth: "oauth"|"api_key"|"upload"|"otel",
      scopes: string[],
      fields_pulled: string[],
      docs_url
    }
  ]
}
```

### `GET /v1/connections`

**Response**

`{ items: Connection[], trust_levels: { [level]: label } }`

```ts
Connection = {
  id,
  source,
  display_name,
  kind,
  status: "pending_auth"|"healthy"|"syncing"|"error"|"revoked",
  trust: "independent"|"shared"|"self_reported",
  scopes: string[],
  last_sync_at,
  watermark,
  health: { ok: boolean, message?: string, lag_seconds?: number },
  config,
  created_at
}
```

### `PATCH /v1/connections/{id}`

Role: admin.

**Request body**

`{ trust?, source_trust?: { [source]: level } }`

**Response**

`Connection`

`trust` is how independent the connection's facts are from the party being verified (SECURITY.md §16); `source_trust` overrides it per pushed source on the facts-API connection. Audit: `connection.trust_changed`.

### `POST /v1/connections`

**Request body**

```ts
{
  source,
  display_name?,
  credentials?: { api_key?, subdomain?, access_token?, ... }
}
```

**Response**

`Connection`

Plus `authorize_url` for OAuth sources

### `GET /v1/connections/{id}`

**Response**

```ts
Connection & {
  stats: { claims, facts, last_sync_duration_ms },
  recent_syncs: [
    { id, started_at, finished_at, status, rows_claims, rows_facts, error? }
  ]
}
```

### `POST /v1/connections/{id}/sync`

**Request body**

`{ backfill_days?: number }`

**Response**

`{ job_id }`

### `POST /v1/connections/{id}/reauth`

**Response**

`Connection`

Or `{ authorize_url }`

### `DELETE /v1/connections/{id}`

**Response**

`{ ok: true }`

### `POST /v1/connections/upload`

**Request body**

```ts
{
  columns: { <canonical>: <csv header> },
  vendor?,
  contract_id? (claims; the library contract the rows belong to, default generic_outcome_csv),
  outcome_type?,
  subject_kind?,
  fact_type?,
  source? (facts),
  provides?: string[
    
  ] (facts; fact-source categories such as conversations/csat/refunds, used by missing_fact_source),
  keep_text?: boolean
}
```

**Response**

`{ connection_id, rows, job_id: null }`

Multipart form: `file`, `kind` (`claims`|`facts`), `mapping` (JSON string: ), `save_mapping_as?`, `connection_id?`

Uploads are ingested synchronously

### `POST /v1/connections/upload/preview`

**Response**

```ts
{ headers: string[], sample_rows: any[][], row_count, suggested_mapping }
```

Multipart form `file`, `kind`

### `GET /v1/connections/mappings`

Saved mappings

## Contracts

Contract YAML is parsed with a safe loader and refused with `400` above `MAX_CONTRACT_BYTES` (128 KiB), with more than 16 anchors or 32 aliases, deeper than 32 levels, or above 50,000 values.

### `GET /v1/contracts`

**Response**

`{ items: ContractSummary[] }`

```ts
ContractSummary = {
  id,
  version,
  vendor,
  outcome_type,
  status: "library"|"draft"|"active"|"archived",
  source_citation,
  selected: boolean,
  updated_at,
  owner: "library"|"tenant"
}
```

### `GET /v1/contracts/{id}?version=`

**Response**

```ts
Contract = ContractSummary & {
  yaml: string,
  compiled_sql: { vendor: string, strict: string },
  versions: [{ version, created_at, status }]
}
```

### `POST /v1/contracts`

**Request body**

`{ yaml }`

**Response**

```ts
Contract & { warnings: Weakening[], compared_to: { version } | null }
```

```ts
Weakening = {
  severity: "warning",
  kind: "removed"|"loosened"|"changed",
  place,
  check,
  field?,
  from?,
  to?,
  message
}
```

(new version if id exists) — validates schema; `422` with `details.errors[]` on failure. `warnings` lists every way the new version accepts more than the one it replaces (a removed check, a raised `max`, an extended window, a shortened lookback on a negative check); saving with any emits `contract.weakened` and records the count in the audit entry.

### `POST /v1/contracts/validate`

**Request body**

`{ yaml }`

**Response**

```ts
{
  valid,
  errors: [{ path, message }],
  compiled_sql?,
  plain_language?,
  warnings: Weakening[],
  compared_to
}
```

### `POST /v1/contracts/draft`

Role: analyst.

**Request body**

```ts
{
  action_type?,
  contract_id?,
  lookback_days?: 30,
  lookahead?: "24h",
  min_share?: 0.8
}
```

**Response**

```ts
{
  yaml,
  contract: { id, version, window } | null,
  errors,
  warnings: string[],
  stats: {
    actions,
    effects: [{ source, type, share, max_per_action, included }],
    preconditions,
    params,
    window_p95_seconds
  }
}
```

A contract drafted from the recorded actions of one type and the facts observed on their subjects: effects seen after at least `min_share` of the actions become `expected_effects`, the p95 arrival of the last of them becomes the window, constant states before become preconditions, observed parameter maxima become limits. Nothing is saved. `400 nothing_to_draft` when no actions match.

### `POST /v1/contracts/{id}/select`

**Response**

`{ ok }`

Marks contract active for tenant; counts against entitlement

### `POST /v1/contracts/{id}/dry-run`

**Request body**

`{ period, version? }`

**Response**

```ts
{
  claims: number,
  vendor: Totals,
  strict: Totals,
  sample_reasons: { [code]: number }
}
```

### `GET /v1/contracts/{id}/diff?from_=1&to=2`

**Response**

`{ from, to, unified_diff: string }`

Note the query parameter is `from_`

### `GET /v1/contracts/primitives`

**Response**

```ts
{
  items: [
    {
      name,
      args: "none"|"duration"|"list"|"stage"|"status"|"label"|"sources",
      description,
      lenses_allowed: string[],
      fact_types: string[]
    }
  ],
  outcome_types: string[]
}
```

Dry-run response shape: `{ claims, available_sources: string[], vendor: { counts: { VERIFIED, NOT_VERIFIED, UNVERIFIABLE }, verified_amount, reasons: { [code]: number } }, strict: {...} }`

## Runs

### `GET /v1/runs`

**Response**

`{ items: Run[] }`

```ts
Run = {
  id,
  contract_id,
  contract_version,
  engine_version,
  period,
  status: "queued"|"running"|"succeeded"|"failed",
  started_at,
  finished_at,
  stats: {
    claims,
    facts,
    verified_vendor,
    verified_strict,
    not_verified_strict,
    unverifiable_strict,
    duration_ms,
    transcript_assist: { enabled, labeled, cost }
  },
  statement_id,
  error?
}
```

### `POST /v1/runs`

**Request body**

```ts
{ contract_id, period, version?, transcript_assist?: boolean }
```

**Response**

`Run`

### `GET /v1/runs/{id}`

**Response**

`Run & { logs: [{ ts, level, message }] }`

## Reconciliations

One vendor's claims for one period, checked against the tenant's facts. The object is keyed by (`contract_id`, `period`), the same key runs and statements use, so a run started here is an ordinary run and its statement an ordinary statement.

### `GET /v1/reconciliations?vendor=&period=`

**Response**

`{ items: Reconciliation[] }`

### `POST /v1/reconciliations`

**Request body**

`{ contract_id, period, version?, notes? }`

**Response**

`Reconciliation`

201; 409 `already_exists` with `details.id` when one exists for that contract and period; 400 `not_an_outcome_contract` for action contracts

### `GET /v1/reconciliations/{id}`

**Response**

`Reconciliation`

With `coverage`, `facts_connections`, `invoice?`, `disputes?`

### `PATCH /v1/reconciliations/{id}`

**Request body**

`{ invoice_id?, notes?, facts_connection_ids? }`

**Response**

`Reconciliation`

The invoice must be for the same vendor and period (400 `invoice_mismatch`); `""` detaches. `facts_connection_ids` names the fact connections this reconciliation relies on (step 2 of the wizard): each must exist and hold facts (404 / 400 `not_a_fact_source`); the run still uses every active connection's facts, the choice judges coverage and records what was checked.

### `POST /v1/reconciliations/{id}/claims/preview`

**Response**

```ts
{ headers, sample_rows, row_count, suggested_mapping, remembered }
```

Multipart form `file`

`remembered` is true when the vendor's last mapping fits the file's headers; `suggested_mapping` is then that mapping, otherwise a guess from the headers.

### `POST /v1/reconciliations/{id}/claims`

**Request body**

```ts
{
  columns: {
    subject_id,
    claimed_at,
    claim_id?,
    unit_price?,
    channel?,
    customer_email?,
    ...
  }
}
```

Multipart form `file`, `mapping?` (JSON ; omitted

The remembered mapping) → `Reconciliation & { upload: { rows, skipped, connection_id } }`. Rows are pinned to the reconciliation's contract, period, outcome type and subject kind; the mapping is remembered per vendor; 409 `already_run` once the reconciliation has been run.

### `POST /v1/reconciliations/{id}/facts/preview`

Multipart form `file`

As above for a facts file

### `POST /v1/reconciliations/{id}/facts`

**Response**

`Reconciliation & { upload }`

Multipart form `file`, `mapping` (JSON columns incl. `subject_id`, `occurred_at`, `fact_type?`), `source` (label, e.g. `helpdesk`), `provides` (comma-separated fact-source families, e.g. `conversations,csat`), `fact_type?` (default for rows without a column)

A one-off facts file for a system with no connector; it becomes a fact source for every later reconciliation.

### `POST /v1/reconciliations/{id}/run`

**Request body**

`{ transcript_assist? }`

**Response**

`Reconciliation`

(400 `no_claims` before any upload). Metered like `POST /v1/runs`.

### `DELETE /v1/reconciliations/{id}`

Role: admin.

**Response**

`{ ok, claims_removed }`

Removes the reconciliation and the claims uploaded into it; runs, statements and verdicts already produced stay in the hash chain.

```ts
Reconciliation = {
  id,
  vendor,
  vendor_display,
  contract_id,
  contract_version,
  outcome_type,
  subject_kind,
  period,
  status: "draft"|"ready"|"running"|"complete"|"failed",
  step: 2|4|5,
  claims: { count, amount, connection_id, file, mapping },
  facts_connection_ids,
  invoice_id,
  run_id,
  statement_id,
  notes,
  created_by,
  created_at,
  updated_at,
  run?: { id, status, started_at, finished_at, error },
  summary?: {
    billed,
    verified_vendor,
    verified_strict,
    not_verified: { vendor: Breakdown, strict: Breakdown },
    unverifiable,
    currency,
    delta,
    statement_hash,
    generated_at
  },
  exports?: { pdf, csv, json },
  coverage: {
    covered,
    selected,
    required: [
      {
        source,
        covered,
        connections: [
          { id, display_name, source, status, trust, last_sync_at }
        ]
      }
    ],
    connections: [
      {
        id,
        display_name,
        source,
        kind,
        status,
        trust,
        last_sync_at,
        provides,
        covers,
        selected
      }
    ],
    available_sources,
    signal_gaps: [{ primitive, needs, description }]
  },
  facts_connections: [...],
  invoice?: { id, status, total, currency, source_ref, line_count },
  disputes?: [{ id, status, lens, contested_amount }]
}
```

`status` is derived: `draft` until claims exist, `ready` once they do, then the run's state. `step` is 2 until a fact source is chosen or imported, 3 until claims are uploaded, 4 until the run completes, then 5. Audit: `reconciliation.created|updated|claims_uploaded|facts_imported|run_started|deleted`.

## Statements

### `GET /v1/statements?vendor=&period=`

**Response**

`{ items: StatementSummary[] }`

```ts
StatementSummary = {
  id,
  run_id,
  vendor,
  vendor_display,
  contract_id,
  contract_version,
  engine_version,
  period,
  generated_at,
  statement_hash,
  billed,
  verified_vendor,
  verified_strict,
  delta: { amount, count },
  has_pdf,
  transcript_assist_used,
  shared_with
}
```

Flat; the full `totals` object is on the detail endpoint

### `GET /v1/statements/{id}`

**Response**

```ts
Statement = {
  id,
  run_id,
  vendor,
  vendor_display,
  contract_id,
  contract_version,
  engine_version,
  period,
  generated_at,
  statement_hash,
  synthetic,
  totals: {
    billed: { count, amount },
    verified_vendor: { count, amount },
    verified_strict: { count, amount },
    not_verified: { vendor: Breakdown, strict: Breakdown },
    unverifiable: { vendor: Breakdown, strict: Breakdown }
  },
  delta: {
    amount,
    count,
    alternatives: [{ label, description, count, amount }]
  },
  transcript_assist: null | {
    used: true,
    labeled,
    sample_rate,
    agreement_rate,
    samples_reviewed
  },
  methodology: { contract_yaml, sections: [{ n, title, body_md }] },
  exports: { pdf: string|null, csv, json },
  shared_with: [{ email, expires_at }],
  chain: { first_hash, last_hash, count }
}
```

```ts
Breakdown = { count, by_reason: [{ code, label, count, explanation }] }
```

### `GET /v1/statements/{id}/claims?lens=&verdict=&reason=&q=`

**Response**

`{ items: ClaimRow[] }`

```ts
ClaimRow = {
  claim_id,
  subject_id,
  outcome_type,
  claimed_at,
  unit_price,
  vendor_verdict,
  strict_verdict,
  primary_reason_vendor,
  primary_reason_strict,
  has_label
}
```

### `GET /v1/statements/{id}/export.pdf|csv|json`

File

### `POST /v1/statements/{id}/share`

**Request body**

`{ email, expires_at?, dispute_ids?: string[] }`

**Response**

`{ grant_id, counterparty_url }`

Enterprise

### `GET /v1/statements/{id}/delta-report`

**Response**

`{ period, lenses: {...}, alternatives: [...] , csv_url }`

## Evidence

### `GET /v1/claims/{claim_id}`

**Response**

```ts
{
  claim: Claim,
  subject_links: [...],
  facts: Fact[],
  verdicts: { vendor: Verdict, strict: Verdict },
  label: TranscriptLabel|null,
  explanations: { vendor: string, strict: string }
}
```

```ts
Claim = {
  claim_id,
  vendor,
  contract_id,
  subject_id,
  subject_kind,
  outcome_type,
  claimed_at,
  billing_period,
  unit_price,
  currency,
  raw_ref,
  connection_id
}
```

```ts
Fact = {
  fact_id,
  source,
  subject_id,
  subject_kind,
  fact_type,
  occurred_at,
  attributes,
  raw_ref
}
```

```ts
Verdict = {
  verdict,
  lens,
  reason_codes: string[],
  evidence: [
    { source, subject_id, fact_id, field, value, occurred_at, sha256 }
  ],
  contract_id,
  contract_version,
  engine_version,
  computed_at,
  verdict_hash,
  prev_hash
}
```

```ts
TranscriptLabel = {
  id,
  label,
  confidence,
  rationale,
  model_id,
  prompt_version,
  labeled_at,
  human_label,
  sampled
}
```

## Invoices & disputes

### `GET /v1/invoices`

**Response**

`{ items: Invoice[] }`

```ts
Invoice = {
  id,
  vendor,
  period,
  source_ref,
  currency,
  total,
  parse_method: "csv"|"xlsx"|"pdf",
  status: "pending_review"|"accepted",
  line_count,
  created_at
}
```

### `POST /v1/invoices`

**Response**

`Invoice & { lines: InvoiceLine[], review_required: boolean }`

Multipart form `file`, `vendor`, `period`, `mapping?`

### `GET /v1/invoices/{id}`

**Response**

`Invoice & { lines: InvoiceLine[] }`

```ts
InvoiceLine = {
  id,
  sku,
  description,
  quantity,
  unit_price,
  amount,
  contract_id,
  matched_claim_count,
  accepted: boolean
}
```

### `POST /v1/invoices/{id}/accept`

**Request body**

```ts
{ lines: [{ id, sku?, quantity?, unit_price?, amount?, contract_id? }] }
```

**Response**

`Invoice`

### `GET /v1/invoices/{id}/reconciliation`

**Response**

```ts
{
  invoice_id,
  statement_id,
  rows: [
    {
      line_id,
      sku,
      contract_id,
      billed_qty,
      billed_amount,
      verified_vendor_qty,
      verified_strict_qty,
      exceptions: [{ claim_id, strict_verdict, reason }],
      variance_amount_vendor,
      variance_amount_strict
    }
  ]
}
```

### `POST /v1/invoices/{id}/disputes`

**Request body**

`{ line_ids: string[], lens: "vendor"|"strict", notes? }`

**Response**

`Dispute`

### `GET /v1/disputes`

**Response**

`{ items: Dispute[] }`

```ts
Dispute = {
  id,
  invoice_id,
  vendor,
  period,
  status: "draft"|"sent"|"credited"|"rejected"|"partial",
  lens,
  contested_amount,
  credited_amount,
  line_count,
  exception_count,
  package_hash,
  sent_at,
  resolved_at,
  notes,
  created_at
}
```

### `GET /v1/disputes/{id}`

**Response**

```ts
Dispute & {
  lines: [...],
  cover_letter_md: string,
  exports: { pdf, csv, json }
}
```

### `PATCH /v1/disputes/{id}`

**Request body**

`{ status?, credited_amount?, notes?, sent_at? }`

**Response**

`Dispute`

### `GET /v1/disputes/{id}/export.pdf|csv|json`

File

## Calibration (Scale+/Enterprise, transcript assist opt-in)

### `GET /v1/calibration/queue`

**Response**

```ts
{
  items: [
    {
      label_id,
      claim_id,
      label,
      confidence,
      rationale,
      excerpt_redacted: string[],
      labeled_at
    }
  ]
}
```

### `POST /v1/calibration/{label_id}`

**Request body**

```ts
{
  human_label: "customer_confirmed_resolved"|"customer_indicated_unresolved"|"abandoned"|"unclear"
}
```

**Response**

`{ ok }`

### `GET /v1/calibration/stats`

**Response**

```ts
{
  labeled,
  sampled,
  reviewed,
  pending,
  agreement_rate: number|null,
  sample_rate,
  by_label: { [label]: count },
  confusion: { [model_label]: { [human_label]: count } },
  spend_this_month,
  cost_cap
}
```

Queue items carry `excerpt_redacted: [{ role: "customer"|"agent", text }]` and `excerpt_purged: boolean`.

## Internal agents (OpenTelemetry)

### `GET /v1/otel/sources`

**Response**

```ts
{
  items: [
    {
      id,
      name,
      endpoint_url,
      endpoint_key_last4,
      mapping,
      last_seen_at,
      spans_received,
      claims_created
    }
  ]
}
```

### `POST /v1/otel/sources`

**Request body**

`{ name, mapping? }`

Source incl. one-time `endpoint_key`

### `PUT /v1/otel/sources/{id}/mapping`

**Request body**

`{ mapping }`

Source

### `POST /v1/otel/{endpoint_key}/v1/traces`

**Response**

`{ accepted: number }`

OTLP/HTTP JSON

Semantic convention documented in `docs/otel-convention.md`

## Actions

Action verification against `kind: action` contracts (reference: [action-contracts.md](/docs/action-contracts)). Roles: `analyst` or higher (API keys qualify) for `POST /v1/actions`, `POST /v1/facts` and `POST /v1/actions/verify`; `viewer` for the `GET`s. Every request is tenant-scoped by the token. Verdict labels are `PASS | FAIL | UNKNOWN`, lenses `outcome | policy`.

Common errors: `404 unknown_contract` (no such contract id), `400 not_an_action_contract` (the id names an outcome contract), `404 not_found` (no such action), `400 invalid_request` (neither `action_id` nor `action` sent, or a malformed value), `400 invalid_fact` (bad `source`/`fact_type` identifier). On a paid plan there is no `402` on recording or verifying an action: past the monthly allowance every action is still recorded and verified, and the response's `allowance` block says whether it fell within the allowance or into overage (at the plan's per-action rate). On Developer the grace past the allowance is bounded (72 hours or 250 actions): inside it the block says `grace`; after it a new action, inline or recorded, answers `402 allowance_exceeded` and nothing is stored, while re-recording an existing `action_id`, verifying by id, dry runs and the settlement re-checks of recorded actions continue. `429 rate_limited` and `429 fair_use_exceeded` (Developer's daily caps) carry `Retry-After`. A **new** `action_id` counts one verified action against the month of its `requested_at`; recording the same `action_id` again, `dry_run` verifications, verifying an already recorded action by id, and sandbox runs are never metered.

### Shapes

```
ActionIn = { action_id?: string(<=120), contract_id: string, contract_version?: number, workflow_id?: string, agent_id?: string,
             action_type?: string, subject_id: string, subject_kind?: string, requested_at?: ISO-8601, params?: object, channel?: string,
             compensates?: string }   // the earlier action this one corrects; see "Compensation" in docs/action-contracts.md
FactIn   = { source: string, subject_id: string, subject_kind?: string, fact_type: string, occurred_at: ISO-8601, attributes?: object, raw_ref?: string(<=200) }
LinkIn   = { subject_id: string, source?: string, linked_subject_id: string, linked_source?: string }   // two record ids that are the same case

Action   = { id, action_id, contract_id, contract_version, workflow_id, agent_id, action_type, subject_id, subject_kind,
             requested_at, params: object, channel, period: "YYYY-MM", compensates: string|null,
             resolution: { state: "compensating"|"compensated"|"compensation_failed", by, by_claim_id, at } | null,
             latest: null | { outcome: Verdict, policy: Verdict, status: "pending"|"settled", settles_at, computed_at } }

ActionVerification = {
  action: Action,
  contract: { id, version, display, window, kind: "action" },
  status: "pending"|"settled", settles_at,
  lenses: { outcome: LensResult, policy: LensResult },
  checks: { preconditions: Check[], limits: Check[], aggregate_limits: Check[], expected_effects: Check[], forbidden_effects: Check[], reversals: Check[] },
  summary: { expected: number, observed: number, headline: string },
  facts: Fact[],            // every stored fact for the action's subject id (oldest first, max 200; `text` attributes stripped)
  dry_run: boolean
}
LensResult = { verdict: "PASS"|"FAIL"|"UNKNOWN", raw_verdict: "VERIFIED"|"NOT_VERIFIED"|"UNVERIFIABLE", reason_codes: string[], reason_labels: string[],
               evidence: EvidencePointer[], verdict_hash, prev_hash, computed_at, explanation: string }
Check = { kind: "precondition"|"limit"|"aggregate"|"expected_effect"|"forbidden_effect"|"reversal", description, source: string|null, type: string|null,
          code: string, ok: boolean, observed: boolean, fact_id: string|null, occurred_at: string|null, value: any, message: string|null }
```

`Check.code` is the check's reason code: for expected effects it is `effect_observed:…` when `ok` and `expected_effect_missing:…` otherwise; for the other kinds it names the violation (`precondition_unmet:…`, `limit_exceeded:…`, `aggregate_exceeded:…`, `duplicate_effect:…`, `forbidden_effect_observed:…`, `effect_attr_mismatch:…`, `reversed:…`) whether or not it fired, so read `ok`. `value` is the parameter value for limits, the measured total for aggregates, `{expected, observed}` for an `effect_attr_differs` check that fired, and the matched fact's attributes for the other kinds. `ok` and `observed` differ: an observed forbidden effect is `observed: true, ok: false`; a precondition whose state record exists but does not match is `observed: true, ok: false`; a missing state record is `observed: false, ok: false`.

### `POST /v1/actions` — record an action (analyst, scope `actions:record`)

**A recorded action is immutable.** Sending an existing `action_id` again with the same action is a no-op that returns the action as recorded (safe to retry; optional fields may be left out). Sending an existing `action_id` with a different contract, subject, params, workflow, agent, type, channel, `requested_at` or `compensates` answers `409` with code `conflict` and changes nothing: record the new action under its own id, or record a compensating action. `requested_at` further ahead than `MAX_CLOCK_SKEW_SECONDS` (15 minutes) answers `400 invalid_request`.

Idempotent on `action_id` (`raw_ref = agent_api:action:<action_id>`): a repeat updates `requested_at`, `params`, `workflow_id`, `agent_id`, `subject_id` and `channel` of the existing action. Without `action_id` one is generated and every call creates a new action. Recording does not verify. Audit: `action.recorded`.

```json
POST /v1/actions
{
  "action_id": "act_demo_refund_001",
  "contract_id": "refund_workflow",
  "workflow_id": "wf_cancel_and_refund",
  "agent_id": "whatsapp-support-agent",
  "action_type": "refund_order",
  "subject_id": "order_10231",
  "subject_kind": "order",
  "requested_at": "2026-09-28T10:42:00Z",
  "params": {"amount": 180.0, "currency": "USD", "reason": "customer_cancelled"},
  "channel": "whatsapp"
}

201
{
  "action": {
    "id": "act_01m3mggydp4h27rn5v9km2jw9s",
    "action_id": "act_demo_refund_001",
    "contract_id": "refund_workflow",
    "contract_version": 1,
    "workflow_id": "wf_cancel_and_refund",
    "agent_id": "whatsapp-support-agent",
    "action_type": "refund_order",
    "subject_id": "order_10231",
    "subject_kind": "order",
    "requested_at": "2026-09-28T10:42:00Z",
    "params": {"amount": 180.0, "currency": "USD", "reason": "customer_cancelled"},
    "channel": "whatsapp",
    "period": "2026-09",
    "latest": null
  }
}
```

### `POST /v1/facts` — push facts from systems of record (analyst, scope `facts:write`)

An API key needs the `facts:write` scope (`403 insufficient_scope` otherwise; a `recorder` key never has it) and may push only for the sources it is bound to (`403 source_not_allowed`). A source a live connector serves, by its own name or by what it provides, cannot be pushed through any key (`409 source_served_by_connector`): a pushed fact never stands in for what the connector reports. Inline `facts` on `POST /v1/actions/verify` follow the same rules outside a dry run.

A fact's `occurred_at`, and any declared watermark, further ahead than `MAX_CLOCK_SKEW_SECONDS` (15 minutes) answers `400 invalid_fact`; the request is not applied. The server's own ingestion time is stored apart from `occurred_at` and cannot be set by the caller.

`{ facts: FactIn[], links?: LinkIn[], watermarks?: { [source]: timestamp } }`, 1–1000 facts per call. `watermarks` states that everything up to that time has been sent for a source; settlement waits on it instead of the clock (see `docs/action-contracts.md`, settlement), and advancing it brings forward any re-check waiting on the source. Idempotent on `raw_ref`; when omitted the engine uses `agent_api:fact:<sha256 of [source, subject_id, fact_type, occurred_at, attributes]>`, so identical facts are skipped and changed ones become new facts. `source` and `fact_type` must be lowercase identifiers (`[a-z][a-z0-9_.:-]{0,63}`). Pushing a fact marks its `source` as available for `missing_fact_source`. Returns `{ ingested, linked }`. Audit: `facts.pushed`.

`links` records that two ids are the same case, for example a support ticket and the order it is about. An effect observed on a linked subject counts for the action's subject (an agent that notifies the customer on the ticket has notified the customer about the order), and effects on linked subjects are never reported as orphans. Links are direction-free and idempotent. `POST /v1/actions/verify` accepts the same `links` inline.

```json
POST /v1/facts
{
  "facts": [
    {"source": "shopify", "subject_id": "order_10231", "subject_kind": "order", "fact_type": "order_status",
     "occurred_at": "2026-09-28T10:41:30Z", "attributes": {"status": "eligible", "total": 180.0}},
    {"source": "stripe", "subject_id": "order_10231", "subject_kind": "order", "fact_type": "refund_created",
     "occurred_at": "2026-09-28T10:42:07Z", "attributes": {"amount": 180.0, "currency": "USD", "refund_id": "re_1Q2x"}},
    {"source": "crm", "subject_id": "order_10231", "subject_kind": "order", "fact_type": "note_added",
     "occurred_at": "2026-09-28T10:42:09Z", "attributes": {"author": "whatsapp-support-agent"}},
    {"source": "messaging", "subject_id": "order_10231", "subject_kind": "order", "fact_type": "customer_notified",
     "occurred_at": "2026-09-28T10:42:11Z", "attributes": {"channel": "whatsapp"}}
  ]
}

201  {"ingested": 4}        // a second identical call returns {"ingested": 0}
```

### `POST /v1/actions/verify` — verify now (analyst)

`{ action_id?: string, action?: ActionIn, facts?: FactIn[], dry_run?: boolean }`. Send either `action_id` (a recorded action) or an inline `action` (recorded or updated first); inline `facts` are ingested before evaluation. Both lenses are evaluated synchronously against the action's own contract and the response is the full `ActionVerification`.

`dry_run` semantics: with `dry_run: true` the action, the facts and the verification are evaluated inside a savepoint that is rolled back — nothing is stored (no action, no facts, no run, no verdicts, no re-check job, no audit entry) and the response carries `dry_run: true`. For an already-recorded action, dry-run verdict hashes equal what a persisted verification over the same facts would produce; for an action first seen inline in a dry run, the internal `id` (which is part of the hash) is generated again when the action is really recorded. With `dry_run: false` (default) the action and facts are stored, a `Run` (`triggered_by: "action"`) with two chained verdicts is written, a `verify_action` job is queued at `settles_at` when the result is `pending`, and the audit log records `action.verified`.

The example is the repository's demo case (`tests/test_actions.py`): every effect happened except the Shopify cancellation. `facts` and the second lens are abbreviated.

```json
POST /v1/actions/verify
{
  "dry_run": true,
  "action": { ...ActionIn from POST /v1/actions above... },
  "facts": [ ...the four FactIn from POST /v1/facts above... ]
}

200
{
  "action": { "id": "act_01m3mggybvd8qj2hjzmb9m9j8b", "action_id": "act_demo_refund_001", "contract_id": "refund_workflow",
              "contract_version": 1, "workflow_id": "wf_cancel_and_refund", "agent_id": "whatsapp-support-agent",
              "action_type": "refund_order", "subject_id": "order_10231", "subject_kind": "order",
              "requested_at": "2026-09-28T10:42:00Z", "params": {"amount": 180.0, "currency": "USD", "reason": "customer_cancelled"},
              "channel": "whatsapp", "period": "2026-09",
              "latest": {"outcome": "FAIL", "policy": "FAIL", "status": "settled", "settles_at": "2026-09-28T10:57:00Z", "computed_at": "2026-09-28T17:20:41Z"} },
  "contract": {"id": "refund_workflow", "version": 1, "display": "Refund workflow (internal agent)", "window": "15m", "kind": "action"},
  "status": "settled",
  "settles_at": "2026-09-28T10:57:00Z",
  "lenses": {
    "outcome": {
      "verdict": "FAIL", "raw_verdict": "NOT_VERIFIED",
      "reason_codes": ["verified_condition_not_met", "expected_effect_missing:shopify.order_cancelled"],
      "reason_labels": ["Verified condition not met", "Expected effect not observed: shopify order_cancelled"],
      "evidence": [
        {"source": "whatsapp-support-agent:claim", "subject_id": "order_10231", "fact_id": null, "field": "claimed_at",
         "value": "2026-09-28 10:42:00", "occurred_at": "2026-09-28 10:42:00", "sha256": "f8d788fa…"},
        {"source": "whatsapp-support-agent:claim", "subject_id": "order_10231", "fact_id": null, "field": "absence",
         "value": "no shopify.order_cancelled effect observed within the window", "occurred_at": "2026-09-28 10:42:00",
         "sha256": "750c42a1…", "primitive": "effect_observed"}
      ],
      "verdict_hash": "f8f3d619…", "prev_hash": "e3dc8f91…", "computed_at": "2026-09-28T17:20:41Z",
      "explanation": "FAIL: Verified condition not met"
    },
    "policy": { "verdict": "FAIL", "raw_verdict": "NOT_VERIFIED", "reason_codes": ["verified_condition_not_met", "expected_effect_missing:shopify.order_cancelled"], "...": "...", "prev_hash": "f8f3d619…" }
  },
  "checks": {
    "preconditions": [
      {"kind": "precondition", "description": "latest shopify.order_status before the action had status=eligible", "source": "shopify", "type": "order_status",
       "code": "precondition_unmet:shopify.order_status", "ok": true, "observed": true, "fact_id": "fct_01m3mggybd933grdyt85r1nk9w",
       "occurred_at": "2026-09-28T10:41:30Z", "value": {"status": "eligible", "total": 180.0},
       "message": "Order was not eligible for a refund at the time of the action"}
    ],
    "limits": [
      {"kind": "limit", "description": "amount above 250", "source": null, "type": null, "code": "limit_exceeded:amount",
       "ok": true, "observed": true, "fact_id": null, "occurred_at": null, "value": 180.0, "message": null}
    ],
    "expected_effects": [
      {"kind": "expected_effect", "description": "stripe.refund_created observed within 15m of the action", "source": "stripe", "type": "refund_created",
       "code": "effect_observed:stripe.refund_created", "ok": true, "observed": true, "fact_id": "fct_01m3mggybfa0fs6e5zy6bshs06",
       "occurred_at": "2026-09-28T10:42:07Z", "value": {"amount": 180.0, "currency": "USD", "refund_id": "re_1Q2x"}, "message": "Payment refund was not created"},
      {"kind": "expected_effect", "description": "shopify.order_cancelled observed within 15m of the action", "source": "shopify", "type": "order_cancelled",
       "code": "expected_effect_missing:shopify.order_cancelled", "ok": false, "observed": false, "fact_id": null,
       "occurred_at": null, "value": null, "message": "Shopify order remained active despite a successful payment refund"},
      {"kind": "expected_effect", "description": "crm.note_added observed within 15m of the action", "source": "crm", "type": "note_added",
       "code": "effect_observed:crm.note_added", "ok": true, "observed": true, "fact_id": "fct_01m3mggybg86zjv6bpmh85fnbw",
       "occurred_at": "2026-09-28T10:42:09Z", "value": {"author": "whatsapp-support-agent"}, "message": "No note was added to the customer record"},
      {"kind": "expected_effect", "description": "messaging.customer_notified observed within 15m of the action", "source": "messaging", "type": "customer_notified",
       "code": "effect_observed:messaging.customer_notified", "ok": true, "observed": true, "fact_id": "fct_01m3mggybgbr8swkwncfdakvnr",
       "occurred_at": "2026-09-28T10:42:11Z", "value": {"channel": "whatsapp"}, "message": "Customer was not notified"}
    ],
    "forbidden_effects": [
      {"kind": "forbidden_effect", "description": "more than 1 stripe.refund_created effect(s) observed within 15m", "source": "stripe", "type": "refund_created",
       "code": "duplicate_effect:stripe.refund_created", "ok": true, "observed": false, "fact_id": null, "occurred_at": null, "value": null, "message": null},
      {"kind": "forbidden_effect", "description": "stripe.refund_created observed with amount above 250", "source": "stripe", "type": "refund_created",
       "code": "forbidden_effect_observed:stripe.refund_created", "ok": true, "observed": false, "fact_id": null, "occurred_at": null, "value": null, "message": null}
    ]
  },
  "summary": {"expected": 4, "observed": 3, "headline": "FAIL — Shopify order remained active despite a successful payment refund"},
  "facts": [
    {"fact_id": "fct_01m3mggybd933grdyt85r1nk9w", "source": "shopify", "subject_id": "order_10231", "subject_kind": "order", "fact_type": "order_status",
     "occurred_at": "2026-09-28T10:41:30Z", "attributes": {"status": "eligible", "total": 180.0}, "raw_ref": "agent_api:fact:20c506db…"},
    "…3 more…"
  ],
  "dry_run": true
}
```

Other outcomes with the same contract (see `tests/test_actions.py`):

- Refund of 400 on an order whose latest status was `fulfilled`, refunded twice, all four effects present → `lenses.outcome.verdict = "PASS"` (reason codes `effect_observed:…` ×4), `lenses.policy.verdict = "FAIL"` with `reason_codes = ["precondition_unmet:shopify.order_status", "limit_exceeded:amount", "duplicate_effect:stripe.refund_created", "forbidden_effect_observed:stripe.refund_created"]`, `summary.headline = "FAIL — Order was not eligible for a refund at the time of the action"`; the duplicate check's `fact_id`/`value` point at the **second** refund.
- Subject with no fact at all → both lenses `UNKNOWN`, `reason_codes = ["identity_unmatched"]`, `headline = "UNKNOWN — Subject not found in fact sources"`.
- Action requested just now with only the state and the refund pushed → `status: "pending"`, `headline = "PENDING — 1 of 4 expected effects observed so far; window closes 2026-…"`, a `verify_action` job queued at `settles_at`.

### `GET /v1/actions` — list (viewer)

Query: `contract_id`, `workflow_id`, `verdict` (`PASS|FAIL|UNKNOWN`, case-insensitive; matches when **either** lens of the latest verification has that verdict), `period` (`YYYY-MM`, the action's `requested_at` month), `limit` (default 50, max 200), `cursor`. Items are `Action` objects, newest `requested_at` first; only actions under action contracts are listed. Pagination is offset-based: `next_cursor` is an opaque string to pass back as `cursor`, `null` on the last page. Without a `verdict` or `workflow_id` filter the page is cut in the database and only its actions are evaluated; with one, the listing considers the 2000 most recent matching actions.

```json
GET /v1/actions?verdict=FAIL&period=2026-09

200
{
  "items": [
    { "id": "act_01m3mggydp4h27rn5v9km2jw9s", "action_id": "act_demo_refund_001", "contract_id": "refund_workflow", "contract_version": 1,
      "workflow_id": "wf_cancel_and_refund", "agent_id": "whatsapp-support-agent", "action_type": "refund_order",
      "subject_id": "order_10231", "subject_kind": "order", "requested_at": "2026-09-28T10:42:00Z",
      "params": {"amount": 180.0, "currency": "USD", "reason": "customer_cancelled"}, "channel": "whatsapp", "period": "2026-09",
      "latest": {"outcome": "FAIL", "policy": "FAIL", "status": "settled", "settles_at": "2026-09-28T10:57:00Z", "computed_at": "2026-09-28T17:20:41Z"} }
  ],
  "next_cursor": null
}
```

### `GET /v1/actions/summary?period=YYYY-MM` — per-workflow counts (viewer)

`period` defaults to the current month. One row per action contract that has actions; counts use the **policy** verdict of each action's latest verification (`pass + fail + unknown = total`; actions never verified count as `unknown`). `pending` counts actions whose latest verification is still inside the settlement window and overlaps with the other buckets.

```json
GET /v1/actions/summary?period=2026-09

200
{ "period": "2026-09",
  "workflows": [ {"contract_id": "refund_workflow", "display": "Refund workflow (internal agent)", "total": 1, "pass": 0, "fail": 1, "unknown": 0, "pending": 0} ] }
```

### `GET /v1/actions/{action_ref}/wait?timeout=30` — the completion gate (viewer)
Holds the request until the action's verdict settles or `timeout` seconds pass (clamped to 0..60; the SDKs loop for longer waits). Response: the same `ActionVerification` as the GET below plus `timed_out: boolean` and `waited_seconds: number`. Each poll evaluates the evidence that has arrived; once it settles, that verification is stored once (like a scheduled re-check) and returned; audit `action.awaited {outcome, waited_seconds}`. A timeout returns the provisional view with `status: pending`. See the completion-gate section of [action-contracts.md](/docs/action-contracts).

### `GET /v1/actions/{action_ref}` — one action with its verification (viewer)

`action_ref` is the `action_id` or the internal `id`. Returns an `ActionVerification` with `dry_run: false`. `lenses` are the **stored** verdicts of the latest verification (same `verdict_hash` as the verify response).

**The evidence freeze.** Once an action has settled, `checks`, `summary`, `sources`, `facts`, `settlement`, `reversal` and `previous` are returned exactly as the engine saw them when the verdict settled: `evidence_frozen_at` carries that moment, and nothing is re-evaluated on read. Facts recorded on the action's subjects after that moment are listed apart in `facts_after_settlement` (same shape as `facts`) and never folded into the check table, so the table cannot say an effect was seen when the verdict says it was not there in time. Two things stay live on a settled action: the trust label of each source (the operator's classification today, reflected in `sources`, `facts[].trust` and `summary.evidence_trust`) and `resolution` (a later compensating action), which the headline names.

While the action is **pending**, `evidence_frozen_at` is `null` and `checks`, `summary` and `facts` are rebuilt from the facts currently stored, so the table can be ahead of the stored provisional verdict until the action is re-verified; `facts_after_settlement` is empty. Verdicts stored before the freeze existed are read the same way. If the action has never been verified, this call verifies it now and stores the result. Audit: `evidence.viewed` for a person; API-key reads are not audited.

### `GET /v1/actions/orphans?contract_id=&period=`

Builder plan and above (`402` with `details.upgrade_to: "builder"` below it). The sandbox reports its own run's orphans on every plan.

Effects of the kinds the contract expects or forbids, observed on subjects that have **no** recorded action under the
contract in the preceding window (the wrong-customer and injected-instruction cases). A subject linked to an action's
subject (see `links` under `POST /v1/facts`) is covered by that action and is not an orphan. Returns
`{ contract_id, window, period, count, items: [{ fact_id, source, subject_id, fact_type, occurred_at, attributes }] }`.
Reported only; never turned into a verdict. Role: viewer.

### Webhook events `action.verified`, `action.pending`, `contract.weakened`

`action.verified` is emitted whenever a persisted verification settles: immediately for actions verified after their window has closed with every source caught up (including `POST /v1/actions`, which verifies on record), from the `verify_action` job when a `pending` action settles, and again when a later reversal changes a settled verdict. `action.pending` is emitted once, for the first provisional verdict of an action. Dry runs never emit. Delivered with the standard envelope and signature (`{ id, event, created_at, data }`, headers `X-Event: <event>` and `X-Signature: t=<ts>,v1=<hex HMAC-SHA256 of "<ts>.<body>" under the webhook secret>`), and posted to Slack when configured. Both carry the same `data`:

```json
{
  "id": "wdl_…",
  "event": "action.verified",
  "created_at": "2026-09-28 10:57:02",
  "data": {
    "id": "act_01m3mggydp4h27rn5v9km2jw9s",
    "action_id": "act_demo_refund_001",
    "contract_id": "refund_workflow",
    "workflow_id": "wf_cancel_and_refund",
    "outcome": "FAIL",
    "policy": "FAIL",
    "status": "settled",
    "headline": "FAIL — Shopify order remained active despite a successful payment refund",
    "reasons": [{"code": "verified_condition_not_met", "label": "…"}, {"code": "expected_effect_missing:shopify.order_cancelled", "label": "…"}],
    "settlement": {"settles_at": "2026-09-28T10:57:00Z", "deadline": "2026-09-30T10:57:00Z", "status": "settled", "waiting_on": [], "stale_sources": [], "next_check_at": null},
    "reversal": null,
    "previous": {"outcome": "FAIL", "policy": "FAIL", "computed_at": "2026-09-28T10:42:03Z"},
    "changed": false,
    "verdict_hash": "f31939ee…"
  }
}
```

`reversal` is `{ fact_id, source, type, occurred_at, description, message }` when a record named in `reversed_by` undid the action; `previous` is the verdict pair this run replaced and `changed` says whether it differs. The Slack and email line says pending or settled, what the verdict waits on, what reversed it and what it was before. `verdict_hash` is the policy-lens verdict hash and can be checked at `GET /v1/public/verify/{hash}`.

`action.compensated` is emitted when a later action recorded with `compensates: "<action_id>"` settles PASS: `{ id, action_id, contract_id, workflow_id, outcome, policy, resolution: { state, by, by_claim_id, at }, compensated_by: { id, action_id, contract_id, outcome, policy, headline, verdict_hash } }`. The compensated action's own verdict and hash are unchanged; `action.verified` payloads carry `resolution` and `compensates` as well.

`contract.weakened` is emitted when a saved contract version accepts more than the one it replaces: `{ contract_id, version, previous_version, actor, warnings: Weakening[] }`.

All four are selectable events for customer webhooks (`GET /v1/settings/webhooks` lists them) and are posted to Slack when the Slack integration's `events` list includes them or is empty.

## Agent sandbox

### `GET /v1/sandbox/scenarios`

Role: viewer.

**Response**

```ts
{
  items: [{ id, workflow, workflow_display, failure, description, recheck }],
  workflows
}
```

The 112 scripted scenarios from `docs/sandbox.md`.

### `POST /v1/sandbox/runs`

**Request body**

`{ scenario_id }`

**Response**

```ts
201 {
  run_id,
  scenario,
  agent: {
    agent_id,
    claimed_success,
    steps,
    notes,
    calls: [{ method, path, chaos }]
  },
  systems: { facts_ingested, unreachable },
  truth: { outcome, policy, reasons },
  verification: ActionVerification,
  agrees,
  corrected_by_recheck,
  orphans,
  verify_ms
}
```

Analyst

Runs one scenario inside the caller's workspace with the scripted agent: a fresh fake enterprise, the injected failure, facts extracted through it, the action recorded (`channel: sandbox`, subject ids prefixed with the run id so runs never share records) and verified. The eight sandbox contracts are copied into the workspace as unselected versions on first use. The agent id is `sandbox-agent:<run_id>`, so a per-agent aggregate sees only that run's actions. Sandbox runs are never metered. Audit: `sandbox.run`.

## Settings

Workspace settings. Reading needs `admin` unless stated; changing needs `admin`, and `owner` where stated.

### `GET /v1/settings/tenant`

**Response** the workspace: name, slug, plan, `retention_months`, `ip_allowlist`, and the flags the plan sets.

### `PATCH /v1/settings/tenant`

**Request body** `{ name?, retention_months?, ip_allowlist?: string[] }`. Send only the fields that change. `retention_months` other than 13 and `ip_allowlist` need Enterprise (`402`).

### `GET /v1/settings/members`

**Response**

```ts
{ items: [{ id, user_id, email, name, role, status, mfa_enabled, last_login_at, created_at }] }
```

### `POST /v1/settings/members/invite`

**Request body** `{ email, role }`. The invitation email says "sign in to accept", and the sign-in is the acceptance: every pending membership of that address becomes active when it signs in (audit `member.accepted`).

### `PATCH /v1/settings/members/{id}`

**Request body** `{ role }`. Nobody assigns a role above their own (`403 role_above_own`: only an owner mints or demotes an owner), and nobody changes or removes their own membership (`403 own_membership`), except by accepting their own invitation at the invited role.

### `DELETE /v1/settings/members/{id}`

Removes the membership. The same two rules as `PATCH` apply.

### API keys

One credential, one side of the line: a `recorder` key records actions and cannot push facts; an `evidence` key pushes facts for the sources it is bound to and cannot record actions; a `reader` key reads; a `full` key does everything and is for a backend you control end to end. The key that records an action can never also supply the facts that verify it.

### `GET /v1/settings/api-keys`

**Response**

```ts
{
  items: [{ id, name, prefix, created_by, created_at, last_used_at, revoked_at, scopes: string[], fact_sources: string[] | null }],
  scopes: [{ scope, description }],
  presets: { full, recorder, evidence, reader },
  preset_descriptions
}
```

### `POST /v1/settings/api-keys`

**Request body** `{ name, preset?: "full" | "recorder" | "evidence" | "reader", scopes?: string[], fact_sources?: string[] }`. Every scope when neither `preset` nor `scopes` is given.

**Response** `{ id, name, key, prefix, scopes, fact_sources, created_at }`. The `key` is shown once.

`400 invalid_request` for an unknown preset, an empty scope list, or a key with `facts:write` that does not name its `fact_sources` unless it holds every scope.

### `DELETE /v1/settings/api-keys/{id}`

Revokes the key at once.

### Webhooks

Customer webhooks receive the events listed by `GET /v1/settings/webhooks` (`action.verified`, `action.pending`, `action.compensated`, `contract.weakened`, and `test`). Every delivery carries the envelope `{ id, event, created_at, data }` and the headers `X-Event: <event>` and `X-Signature: t=<ts>,v1=<hex HMAC-SHA256 of "<ts>.<body>" under the webhook secret>`.

### `GET /v1/settings/webhooks`

**Response**

```ts
{
  items: [{ id, url, events, status: "active" | "disabled", failure_count, last_error, last_response_code, last_attempt_at, last_delivered_at, disabled_at, created_at }],
  events: string[]
}
```

Use `events` as the selectable list; an unknown event name is rejected with `400`. `last_error` says what the receiver last did, in words that name the fix.

### `POST /v1/settings/webhooks`

**Request body** `{ url, events: string[] }`.

**Response** the webhook plus `secret`, shown once.

The URL must be https, carry no credentials, and name a public host that resolves only to publicly routable addresses: `localhost`, private and link-local addresses and `.internal`-style names answer `400 bad_url`. The same check runs at every delivery. Repeated slashes in the path are collapsed.

### `POST /v1/settings/webhooks/{id}/test`

**Response** `{ delivery: { id, status, response_code, last_error }, webhook }`. Delivers a `test` event now and reports the receiver's answer; a success re-enables a disabled webhook.

### `POST /v1/settings/webhooks/{id}/rotate-secret`

**Response** the webhook plus a new `secret`, shown once. The old secret is retired at once and the failure count reset.

### `POST /v1/settings/webhooks/{id}/enable`

**Response** the webhook, active, with failures forgiven.

### `GET /v1/settings/webhooks/{id}/deliveries`

**Response** `{ items: [{ id, event, status, response_code, attempts, last_error, created_at }] }`.

### `DELETE /v1/settings/webhooks/{id}`

Removes the webhook.

Twenty consecutive failures disable a webhook (audit `webhook.disabled`); while disabled, one delivery an hour is still tried and any success re-enables it (audit `webhook.recovered`). A receiver answering `401` or `403` fails that delivery once, without retries: the secret on its side does not match.

### Billing

### `GET /v1/settings/billing`

**Response**

```ts
{
  plan, status, stripe_customer: boolean, subscription: boolean,
  trial_ends_at, trial_used, trial_days, current_period_end,
  upgrade_options: Tier[],
  founding: { plan, price_monthly, months, seats, remaining, available, reason },
  stripe_configured: boolean
}
```

`upgrade_options` is the public pricing tier list. `founding` is the same offer the public pricing endpoint carries, so the in-app upgrade cards show the price Checkout will charge.

### `GET /v1/settings/billing/preview?tier=`

**Response** `{ tier, name, price_monthly, current_plan, mode: "trial" | "paid" | "switch", trial_days, trial_ends_at, first_charge_on, trial_used }`.

`trial`: Checkout with the remaining trial days, first charge when it ends. `paid`: Checkout, charged today. `switch`: the existing subscription's price changes in place, prorated.

### `POST /v1/settings/billing/checkout`

**Request body** `{ tier }`.

**Response** `{ url, mode, founding: boolean }`: a Stripe Checkout URL, or for a switch the billing page with `?checkout=switched` and the plan already applied. A trial Checkout collects no card (`payment_method_collection: if_required`); a trial that ends without a card cancels the subscription rather than charging anyone. `founding: true` when the Founding Builder coupon was applied (Builder, while seats remain; Stripe's redemption cap decides, and a coupon Stripe refuses falls back to the list price in the same call).

Configuration problems answer `400 billing_error`; Stripe's refusals answer `502 stripe_error` with Stripe's message. `STRIPE_PRICE_<TIER>` may be a price id or a product id, resolved to its active monthly price.

### `POST /v1/settings/billing/portal`

**Response** `{ url }`: the Stripe billing portal for invoices, the card on file, downgrades and cancellation.

### Slack

### `GET /v1/settings/slack`, `POST /v1/settings/slack`, `DELETE /v1/settings/slack`

`POST` takes `{ webhook_url, events }`. Verdict events are posted to Slack when the integration's `events` list includes them or is empty.

### Incident and review channels (Growth and above)

### `GET /v1/settings/integrations`

**Response** `{ items: IntegrationItem[], available_events }`, one item per kind (`pagerduty`, `jira`, `github_checks`):

```ts
IntegrationItem = {
  kind, display, description, secret_label,
  fields: [{ name, label, placeholder?, required }],
  default_events, configured, status: "active" | "disabled" | null,
  events, config, failure_count, last_error, last_delivery_at, open_items
}
```

### `POST /v1/settings/integrations/{kind}`

**Request body** `{ config?, secret?, events? }`. The secret is required the first time and never returned; saving re-enables a disabled integration.

### `POST /v1/settings/integrations/{kind}/test`

**Response** `{ ok, message }`; `502 integration_test_failed` with the provider's answer.

### `DELETE /v1/settings/integrations/{kind}`

Removes the integration.

Deliveries run through the `deliver_integration` job. PagerDuty opens an incident on FAIL (UNKNOWN as a warning) and resolves the same incident on PASS or `action.compensated`. Jira opens one issue per failing action, labelled `commitlayer` and `commitlayer-action-<id>`, and comments on later verdicts. GitHub Checks posts a check run on the commit named by the action's `commit_sha` parameter (`repo` parameter or the configured default repository): in progress while pending, `failure`, `success` or `neutral` once settled, updated in place on re-verification. Twenty consecutive failures disable an integration until it is saved again. Audit: `integration.configured`, `integration.tested`, `integration.removed`.

### Single sign-on (Enterprise)

### `GET /v1/settings/sso`

**Response** `{ enabled, connections: [{ id, workos_connection_id, domain, status }], login_url }`. `login_url` sends a browser straight to the workspace's identity provider.

### `POST /v1/settings/sso`

Role: owner. **Request body** `{ workos_connection_id, domain? }`. `403 sso_disabled` until CommitLayer has enabled SSO for the workspace (Enterprise provisioning).

### Audit log

### `GET /v1/settings/audit-log?limit=&cursor=&action=`

**Response** `{ items: [{ id, ts, actor, action, object_type, object_id, ip, metadata }], next_cursor }`.

### `GET /v1/settings/audit-log/export.csv`

The same rows as a file.

### Transcript assist

### `GET /v1/settings/transcript-assist`, `PATCH /v1/settings/transcript-assist`

**Response** `{ enabled, monthly_cost_cap, spend_this_month, sample_rate, message_window, provider_configured, entitled, available, waiting_for }`; `PATCH` takes the same fields.

`available` is false until the workspace's first invoice with money on it has cleared (`waiting_for: "first_invoice"`); until then `enabled: true` answers `402 payment_required` and nothing is labeled. The default cap is $5 a month.

### Data and jobs

### `POST /v1/settings/data-deletion`

**Request body** `{ confirm: "DELETE" }`. **Response** `{ request_id, receipt_at }`.

### `GET /v1/jobs?status=`

**Response** `{ items: [{ id, type, status, attempts, run_at, last_error, created_at }] }`.

## MCP and OAuth

### `POST /mcp`

The MCP server (streamable HTTP, JSON responses). Bearer: an API key, an OAuth access token or a session JWT. Tools, resources, prompts, scopes and error shapes are in [the MCP reference](/docs/mcp). Every call is audited as `mcp.tool_called`.

### Authorization server

Served by the MCP SDK at the API root: `GET /.well-known/oauth-authorization-server`, `GET /.well-known/oauth-protected-resource/mcp`, `POST /register` (dynamic client registration), `GET /authorize` (PKCE S256; redirects to `${WEB_URL}/oauth/consent?req=`), `POST /token`, `POST /revoke`.

### `GET /v1/oauth/consent?req=`

Session bearer; any member but a counterparty.

**Response** `{ client: { id, name, uri, redirect_uri }, workspace: { id, name, role }, scopes: [{ scope, description, grantable }], expires_at }`. `400 invalid_consent_request` when the request is invalid or older than 10 minutes.

### `POST /v1/oauth/consent`

**Request body** `{ req, approve: boolean, scopes?: string[] }`.

**Response** `{ redirect_to }`: the client's redirect URI carrying `code` and `state`, or `error=access_denied`. Audit `oauth.approved`, `oauth.denied`.

## Counterparty

For users with the `counterparty` role: a vendor's people, who see only what was shared with them. Every view is audit-logged and every page is watermarked with `{ viewer_email, viewed_at }`.

### `GET /v1/counterparty/grants`

**Response**

```ts
{
  items: [{ id, tenant_name, expires_at, statements: [{ id, vendor, period, statement_hash }], disputes: [{ id, vendor, period, status, contested_amount }] }],
  watermark: { viewer_email, viewed_at }
}
```

### `GET /v1/counterparty/statements/{id}`

**Response** `Statement`, with the watermark.

### `GET /v1/counterparty/disputes/{id}`

**Response** the dispute view.

### `GET /v1/counterparty/statements/{id}/export.{pdf|csv|json}`, `GET /v1/counterparty/disputes/{id}/export.{pdf|csv|json}`

The same exports as the owning workspace sees.

## Admin

Platform administrators only (the addresses in `PLATFORM_ADMIN_EMAILS`).

### `GET /v1/admin/tenants`, `POST /v1/admin/tenants`, `PATCH /v1/admin/tenants/{id}`

`POST` takes `{ name, plan, owner_email, trial_days? }`. `PATCH` takes `{ plan?, limits?, features?, trial_ends_at?, sso_enabled?, counterparty_enabled?, name? }`.

### `POST /v1/admin/impersonate`

**Request body** `{ tenant_id, reason }`. **Response** a `Session` with a fresh token, audit-logged with the reason. `POST /v1/admin/stop-impersonation` returns a `Session` for the admin's home tenant.

### `GET /v1/admin/metrics`

Also accepts `Authorization: Bearer <METRICS_READ_TOKEN>` when that variable is set, for an external monitor; the token works on no other route.

**Response**

```ts
{
  collected_at,
  database: { bytes, wal_bytes } | null,
  queue: { [status]: n },
  sync_lag_seconds: { max, avg, connections, excluded },
  stalest_connections: [{ tenant, source, age_seconds }],
  runs_7d, error_rate_7d, pdf_missing, webhook_failures_24h,
  transcript_assist_spend_month, connections_in_error, active_tenants,
  cost: {...}, alerts: string[]
}
```

`database.wal_bytes` is the SQLite write-ahead log; above 256 MB an alert names it, because a long-running reader is then holding the nightly checkpoint back and every commit pays for it.

### `GET /v1/admin/connections`

**Response** `{ items: [{ connection_id, tenant_id, tenant, source, display_name, status, watermark, age_seconds, last_sync_at, has_credential, reauth, health_message, excluded }], counted }`: every live connection of a real workspace, stalest first. `excluded` (`no_credential`, `reauth`, `no_watermark`) says why a row is not counted in `sync_lag_seconds`.

### `GET /v1/admin/signup-refusals?status=&limit=`

**Response**

```ts
{
  items: [{ id, created_at, email, ip, country, reason, mode, detail, tenant_id, status, resolved_at, resolved_by }],
  held,
  guard: { country_policy, country_allow_domains, mx_check, reputation_check, reputation_min_confidence, geoip: { present, built_at, configured } }
}
```

`reason` is `disposable | no_mail_server | reputation | region_closed`; `status` is `refused` (nothing was created), `held` (the workspace exists, suspended), `approved` or `rejected`. `POST /v1/admin/signup-refusals/{id}/approve` opens a held workspace (active, owner emailed, audit `signup.approved`); `POST /v1/admin/signup-refusals/{id}/reject` keeps it closed (audit `signup.rejected`). Either on a row that is not held answers `409 not_held`.

The sign-in page can answer these refusal codes (`/login?error=` on the hosted flow, the error code on the development login): `disposable_email` (400), `no_mail_server` (400), `reputation` (403), `region_closed` (403, `details.country`), `account_held` (403, also from every product route while the workspace waits).

### `GET /v1/admin/jobs?status=&limit=&include_archived=false`, `POST /v1/admin/jobs/{id}/retry`

The queue. Dead engine jobs past thirty days carry `archived_at` and are listed only with `include_archived=true`; `retry` re-queues a job and clears `archived_at`.

### `GET /v1/admin/bans`, `DELETE /v1/admin/bans/{ip}`

`GET` → `{ items: [{ ip, reason, detail, created_at, expires_at, hits }] }`, unexpired bans newest first; `reason` is `scanner_path`, `robots_trap` or `form_honeypot`. `DELETE` → `{ ip, lifted }`; every API process honours it within `BAN_CACHE_SECONDS`. Audit `admin.ban_lifted`.

### `GET /v1/admin/leads`

The leads the public form collected.

### `POST /v1/admin/demo/seed`

Seeds the synthetic demo workspace.

## Billing webhooks

### `POST /v1/webhooks/stripe`

Signature-verified. Updates the workspace's entitlements from subscription, invoice and checkout events.

## Development only

`POST /v1/auth/signup` answers `404` outside development; production accounts are created by the hosted sign-in. `POST /v1/auth/dev-login` exists only when `AUTH_MODE=dev`.
