API reference
Every endpoint of the HTTP API: request and response shapes, error codes, limits, idempotency, dry runs, and what each plan allows.
Last changed Markdown versionllms.txt
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 walks through the five calls that record an action, push the facts and read the verdict. The 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. |
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_cursoris opaque; pass it back ascursor;nullmeans 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 | UNKNOWNandoutcome | policy, and return the raw value beside them asraw_verdict. - Idempotency. Recording an action is idempotent on
action_id; pushing facts is idempotent onraw_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 answers413 payload_too_largewithdetails.limit_bytes. - Client address. The address the edge sets in
X-Real-IP, neverX-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:
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
{
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[] }
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
{
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
{ name, email, company, message?, monthly_outcomes?, source? }
Response
{ ok: true }
POST/v1/public/gap-estimate
Request body
{ monthly_outcomes: number, unit_price: number, strict_rate: number }
Response
{ low: number, high: number, mid: number, assumptions: string[] }
GET/v1/public/status
Response
{
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
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
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
{
period,
vendors: VendorSummary[],
onboarding: {
connected: boolean,
contract_selected: boolean,
first_run: boolean,
invoice_imported: boolean
}
}
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
{
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 } }
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
{
source,
display_name?,
credentials?: { api_key?, subdomain?, access_token?, ... }
}
Response
Connection
Plus authorize_url for OAuth sources
GET/v1/connections/{id}
Response
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
{
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
{ 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[] }
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
Contract = ContractSummary & {
yaml: string,
compiled_sql: { vendor: string, strict: string },
versions: [{ version, created_at, status }]
}
POST/v1/contracts
Request body
{ yaml }
Response
Contract & { warnings: Weakening[], compared_to: { version } | null }
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
{
valid,
errors: [{ path, message }],
compiled_sql?,
plain_language?,
warnings: Weakening[],
compared_to
}
POST/v1/contracts/draft
Role: analyst.
Request body
{
action_type?,
contract_id?,
lookback_days?: 30,
lookahead?: "24h",
min_share?: 0.8
}
Response
{
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
{
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
{
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[] }
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
{ 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
{ 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
{
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.
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[] }
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
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 }
}
Breakdown = { count, by_reason: [{ code, label, count, explanation }] }
GET/v1/statements/{id}/claims?lens=&verdict=&reason=&q=
Response
{ items: ClaimRow[] }
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
{
claim: Claim,
subject_links: [...],
facts: Fact[],
verdicts: { vendor: Verdict, strict: Verdict },
label: TranscriptLabel|null,
explanations: { vendor: string, strict: string }
}
Claim = {
claim_id,
vendor,
contract_id,
subject_id,
subject_kind,
outcome_type,
claimed_at,
billing_period,
unit_price,
currency,
raw_ref,
connection_id
}
Fact = {
fact_id,
source,
subject_id,
subject_kind,
fact_type,
occurred_at,
attributes,
raw_ref
}
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
}
TranscriptLabel = {
id,
label,
confidence,
rationale,
model_id,
prompt_version,
labeled_at,
human_label,
sampled
}
Invoices & disputes
GET/v1/invoices
Response
{ items: Invoice[] }
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[] }
InvoiceLine = {
id,
sku,
description,
quantity,
unit_price,
amount,
contract_id,
matched_claim_count,
accepted: boolean
}
POST/v1/invoices/{id}/accept
Request body
{ lines: [{ id, sku?, quantity?, unit_price?, amount?, contract_id? }] }
Response
Invoice
GET/v1/invoices/{id}/reconciliation
Response
{
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[] }
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
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
{
items: [
{
label_id,
claim_id,
label,
confidence,
rationale,
excerpt_redacted: string[],
labeled_at
}
]
}
POST/v1/calibration/{label_id}
Request body
{
human_label: "customer_confirmed_resolved"|"customer_indicated_unresolved"|"abandoned"|"unclear"
}
Response
{ ok }
GET/v1/calibration/stats
Response
{
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
{
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). Roles: analyst or higher (API keys qualify) for POST /v1/actions, POST /v1/facts and POST /v1/actions/verify; viewer for the GETs. 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.
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.
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.
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 codeseffect_observed:…×4),lenses.policy.verdict = "FAIL"withreason_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'sfact_id/valuepoint 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-…", averify_actionjob queued atsettles_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.
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.
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.
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:
{
"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
{
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
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
{ 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
{
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
{
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
{
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):
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. 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
{
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
{
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
{
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.