Skip to content
Console
Browse documentation
Reference

REST API

Use Anectico's public API conventions and find every supported customer resource family.

On this page

The public REST API base is:

https://app.anectico.com/api/v1

Use an SDK for telemetry and the Anectico CLI for ordinary investigation or automation. Use REST when building a direct integration or a program of your own; Build an app on the REST API explains where such a program runs, which key it holds and which operations it may rely on, and four worked examples (a status board, a support page, a Slack bot and a nightly report) show the whole job, including polling, retries and refusals.

Stability

No /api/v1 operation carries a compatibility promise yet. Until Anectico's first supported release, routes and response shapes may change without a deprecation period, and every contract is experimental. Pin the exact Anectico release you tested and re-test before moving. At the first supported release, the release notes will name the exact set of operations that carry a compatibility and deprecation policy from that release on; an operation listed on this page is not stable just because it is listed.

Conditional saved reads

GET /api/v1/analytics/insights/{id} (latest or retained revision) and a ready GET /api/v1/analytics/results/{result_id} with omitted format return a quoted ETag. A matching If-None-Match returns bodyless 304 only after fresh authentication, project, scope, policy and owning lifecycle checks. Both responses use Cache-Control: private, no-cache, must-revalidate; applicable rate and checked headers remain on 304. Non-ready result states stay typed 200, with no-store and no tag. Refusals never become 304.

Supply one header of at most 1024 bytes: * alone or a comma-separated list of quoted tags, each optionally prefixed W/, compared weakly. Malformed syntax, empty list members and repeated fields are 400. See the exact validator and cache-disposal contract. Polling reads stored evidence and verifies current access and validity; it still reads payloads and serializes the public JSON. It runs no new measurement and charges no accepted-query work. Dynamic reads such as services have no such validator or exemption from aggregation cost.

The API contract

Anectico describes its REST API in OpenAPI 3.1, generated from the routes that actually serve requests and the code that actually writes their responses, never from a hand-kept list. Every deployment publishes the contract it serves, and your agent should build against that one, not against whatever the documentation site calls latest: GET /api/v1/api-contract (no credential, no tenant data) names the deployment's contract revision, the SHA-256 digests and immutable addresses of its contracts, and which operations it mounted. anectico api spec writes the verified contract, or a selection of operations, to a file; the get_rest_contract MCP action returns the manifest or up to 100 operations. See Pin the contract your app is built on. Three contracts are published:

  • The app contract lists the operations a program you host may build on. An operation is either supported, with its full request and response shapes, the credentials and exact scopes it needs, its cost, paging, polling and recovery rules, or restricted, published with the reason and the prerequisite that must pass before it can be supported. Do not build on a restricted operation yet.
  • The customer contract lists every app-data, customer-control and SDK operation the API can serve, with its audience (app data, customer control or SDK intake) and its app qualification.
  • The protocol contract lists the sign-in, discovery, MCP and receiver flows that keep their own credentials; none of them is an app-data operation.

An operation whose response shape has not been declared yet says so, instead of describing a guessed shape. Every operation's x-anectico extension carries its audience, qualification, reason and prerequisite. Like every contract before the first supported release, it is experimental: record the contract digest and the Anectico release you tested, and re-test when either changes. Each document is canonical UTF-8 JSON with sorted keys, so its SHA-256 digest is stable for the same contract.

JSON wire rules

Responses that carry a resource follow ProtoJSON with these choices, and the contract spells them out per field:

  • field names are snake_case;
  • every field is present: an empty list is [], an empty map is {}, a number or flag at its default is 0 or false, and an absent object is null; only a field that has a separate "set" state (a choice between alternatives, or an explicitly optional value) is left out when unset;
  • 64-bit integers are decimal strings, so no client loses digits above 2^53;
  • enum values are their full names (for example STATE_READY) in bodies; a query parameter's spelling is documented on the operation, and is sometimes the short lowercase form;
  • timestamps are RFC 3339 strings in UTC, durations are seconds such as "1.5s", and bytes are base64.

Request bodies accept what ProtoJSON accepts, which is a little more: integers for 64-bit fields, numbers for enums and null for any field.

Error and refusal shapes

Status Body Headers
Most failures {"error", "message", "details"?}; details.reason is a fixed vocabulary
429 request rate {"error": "rate_limited", "message", "retry_after", "rate_limit": {limit, requests_per_second, burst, remaining, blocked, checked, retry_action}}; limit names the bucket that refused (key, workspace, user or flood) Retry-After, X-RateLimit-*, X-Anectico-Workspace-RateLimit-*, X-Anectico-Rate-Checked
429 operation budget {"error", "message", "budget_refusal": {class, limit_kind, limit_value, resets_at, subject, owner_can_change, reason}} Retry-After, X-Anectico-Budget-Refusal: true
429 busy expensive read {"error": "read_admission_busy", "message", "details": {"reason": "READ_ADMISSION_BUSY"}}; the server was already running as many of this read as it admits (service snapshot, trace search by exact span attribute, replay steps and snapshots) and nothing was read Retry-After: 1
503 the ordinary envelope (unavailable, workspace_not_ready, write_outcome_unknown), a budget refusal that could not be checked, or {"error": "unavailable", "message", "rate_unavailable": {layer, reason, checked: false}} when the request rate could not be enforced on a route that must not skip it Retry-After

A read_admission_busy 429 is none of the limits below: the request is fine and the server is busy, so retry the identical request after Retry-After with back-off (Busy expensive reads).

Three different things share the word "limit", and each has its own marker. A rate_limited 429 is the request rate: how fast requests arrive. A budget_refusal 429 is the operation budget: how much accepted work a key may do per hour and day. A rate_unavailable 503 is neither: enforcement could not be made there, and nothing was measured. rate_limit.retry_action says whether waiting helps: wait_retry_after means wait retry_after seconds and retry once; owner_must_change_policy means an owner set that limit to zero and no wait will change it.

X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset describe only the caller's own request-rate bucket: burst capacity, the requests it would still admit, and the Unix time it is full again. A management key's requests are also counted against the bucket all of its workspace's management keys share, reported as X-Anectico-Workspace-RateLimit-Limit, -Remaining and -Reset. X-Anectico-Rate-Checked: false means the request was admitted without the request rate being enforced; no remaining or reset value is sent, so do not infer an allowance from its absence. It is independent of X-Anectico-Budget-Checked. Read-only requests keep working when the request rate cannot be checked; writes and exports are held by the operation budget, which stops them if it cannot be checked.

X-Anectico-Budget-Checked: false on any response means the operation budget could not be checked for that request: remaining capacity is unknown, not zero. A 504 or write_outcome_unknown on a write means the write may or may not have happened; read the resource before you retry.

Confirmed writes answer with a preview first and apply only when you repeat the identical request with its confirm token; their result is the same receipt the MCP tools return, with outcome set to preview, applied, replayed, rejected, partial or indeterminate. A one-time secret appears only in the first committed response; a replayed response carries metadata only.

Receipt operation describes the resource change: create, update, delete or invoke. Export retries and cancellation and report unsubscription use update. A human annotation preview uses update; its committed receipt reports whether the judgment was created or updated. A finding revision uses create because it saves a new immutable revision, while withdrawal uses delete for the removed lineage content. A withdrawal receipt's commit time is its withdrawal time; it is unknown when the response carries no such time.

Authenticate and select a project

Send an API key in X-Anectico-API-Key. X-API-Key and Authorization: ApiKey an_... are also accepted, while MCP uses bearer authentication. Prefer one documented header per client.

curl --fail-with-body \
  -H "X-Anectico-API-Key: $ANECTICO_API_KEY" \
  "https://app.anectico.com/api/v1/persons?project_id=$ANECTICO_PROJECT&search=buyer%40acme.example"

For organization-level credentials, many data reads accept project_id. A project-scoped key may omit a query-string project_id or repeat its exact signed project. Any non-empty conflicting query value, including one hidden in a repeated parameter, returns a stable 403 permission_denied before the product handler runs. The requested project is never silently replaced with the key's project. The same fail-closed response applies when a non-empty project_id field is malformed in a way the standard query parser would otherwise discard, such as an unescaped semicolon or invalid percent escape. A malformed unrelated query field does not change an otherwise exact or omitted selector. Organization-level credentials retain request-time project selection. Organization identity always comes from the authenticated principal, not from request input.

A row that has a page of its own names the project it belongs to, so an organization-level credential that read without project_id knows which project to name next. A person, an Issue, an Issue search match, a trace, a per-person LLM spend row and a session's related signals each carry project_id. A person profile and a person timeline carry the project_id of the person they resolved. Adding, updating and deleting a dashboard widget return dashboard_project_id. With a project-scoped key the value is always the key's own project. It is empty when the row cannot name one project: an identity key that matches no person and was read without project_id, a trace ID that was recorded in more than one project and was read without project_id, or LLM spend that combines one unmapped identifier across projects.

The person list's activity-impact lookup must succeed before an enriched page is returned. If that lookup fails, GET /persons returns a structured error (503 for a temporary outage, 403 for a permission refusal). Missing impact after a failed lookup is never presented as a successful page or as zero activity. A successful lookup can report measured zero activity.

Browser session requests

Session refresh (POST /auth/refresh) and logout (POST /auth/logout) return 403 permission_denied when the request has Sec-Fetch-Site: cross-site. This check runs before the media-type check or any session changes. An Origin header alone does not trigger this rejection. CORS rejects preflight requests from disallowed origins, but an actual POST from such an origin still reaches the endpoint; omitting CORS response headers prevents the browser from reading its response, not executing it. Session cookies use SameSite=Lax, so browsers do not attach the refresh cookie to a cross-site POST. Do not infer a 403 response from the Origin header or the cookie policy.

Refresh always requires Content-Type: application/json, including when the refresh credential comes from a cookie. Logout ignores the request body and accepts an absent or empty Content-Type whether or not a body is sent. A non-empty Content-Type on logout must be application/json. A charset parameter is allowed. A missing or empty content type on refresh, or a non-empty malformed or non-JSON content type on either endpoint, returns 415 unsupported_media_type. Prefer a bodyless logout request.

These examples describe POSTs that reach the endpoint, with a valid refresh cookie and authentication available. A browser preflight can prevent the POST from being sent. absent means the header is omitted; empty means no request body.

Endpoint Origin Sec-Fetch-Site Content-Type Body Status
/auth/logout https://evil.example absent absent empty 200
/auth/logout https://evil.example cross-site absent empty 403
/auth/logout absent absent absent {"x":1} 200
/auth/logout absent absent text/plain {"x":1} 415
/auth/logout https://evil.example absent application/json; charset=utf-8 not-json 200
/auth/refresh https://evil.example absent application/json {} 200
/auth/refresh https://evil.example cross-site application/json {} 403
/auth/refresh absent absent absent {} 415

Browser logout is POST /auth/logout. When a refresh cookie is present, success means its whole refresh family was revoked before the browser cookies were cleared. If that revocation is temporarily unavailable, logout returns 503 with Retry-After: 1 and leaves the cookies unchanged so the browser can retry; it never reports success while another copy of the refresh token remains usable. A browser refresh credential remains bound to the organization selected when that login session was created. Selecting another organization re-authenticates and creates a new session; an older refresh credential can renew only its original organization. If membership or plan validation is temporarily unavailable during refresh, retrying does not consume the presented credential.

Non-2xx JSON responses use {"error":"...","message":"..."}. A recognized failure that changes the safe retry action also includes a machine-readable details object. For example, a ticket create that may have reached the external provider but was not recorded in Anectico carries:

{
  "error": "internal_error",
  "message": "internal server error",
  "details": {
    "reason": "PROVIDER_EFFECT_UNRECORDED",
    "provider": "jira",
    "external_id": "PROJ-9",
    "external_url": "https://jira.example/browse/PROJ-9"
  }
}

The reason and facts remain available even when a server-fault message is redacted. Treat external_url as provider-authored data. On PROVIDER_EFFECT_UNRECORDED, retry the identical create with the same idempotency key; do not mint a new key.

Telemetry reads — traces, logs, metrics, errors and the searches over them — distinguish a fault from a capacity limit. A read the deployment could not answer at that moment answers 503, not 500: the request was well formed and the same request will usually succeed on a retry, so retry it with backoff. A 500 means the opposite — something is wrong with the request or the service, and retrying it unchanged will not help. Narrowing the time range and the filters of a 503-ing search makes it cheaper and more likely to succeed.

This includes a temporary connection failure while reading data, not only a capacity limit. The status describes the final outcome: a read that recovers during a bounded retry may still succeed; a read that does not need the unavailable dependency may also succeed. Cancellation and exhausted request deadlines keep their distinct status codes. A missing capability or a permanent configuration fault is not a temporary outage. Do not apply read retry advice to a mutation unless that operation's contract explicitly permits it.

Every 503 carries Retry-After (in seconds) and an unavailable error code, so the two are distinguishable without reading prose: a 503 body says "error": "unavailable" while a 500 says "error": "internal_error". Wait at least Retry-After before retrying. A 503 is also the answer when a request is shed because your organization already has too many reads in flight at once — retrying after the interval is the correct response to that, and issuing fewer concurrent reads is the way to avoid it.

For error advanced search, error resolve, ignore, reopen, assign, merge, and snooze, trace search and aggregation, log advanced search, filter, and context, metric query and histogram, and unified search and correlate, project_id can be supplied in the JSON request body or the URL query string. A nonempty query selector takes precedence when both are supplied; an absent or empty query selector preserves the body selector. Omitting both keeps the endpoint's existing default or required-project validation. A project-bound credential remains confined to its signed project regardless of either selector; the authenticated principal always supplies the organization.

POST /api/v1/search/correlate returns each correlated Issue's current lifecycle status, resolution time, resolver and note. Resolving or ignoring an Issue is reflected on the next correlation read; reopening it returns ongoing and clears the resolution time and resolver. The same behavior applies to MCP correlate_signals (which exposes status). The trace still selects the same Issue groups, and a trace without Issues returns an empty error list.

A malformed value is refused, not ignored. If you send a query parameter we cannot parse — a misspelled boolean, a non-numeric limit, an unparseable timestamp — you get a 400. Leaving a parameter out still means "no filter"; the distinction is between absent and present-but-invalid, because a typo that silently drops your filter returns a wider answer you have no way to recognize as wrong. For the same reason a PATCH whose body changes nothing — empty, or naming only fields that do not exist or cannot be edited — is refused rather than recorded as an update.

A read that ran out of time answers 504, and one you cancelled answers 499 — neither is a 500, because the service is healthy and the query was simply too expensive to finish. Narrow it before retrying; the same request will exceed the same deadline. A read whose result set is too large to answer honestly answers 429 rather than returning a truncated one: the trend matrix above 100,000 bucket-and-series rows, and the evidence and step-tree ceilings described later. In every one of these cases you are told the answer is incomplete rather than handed a smaller answer that looks whole.

Person profiles and statistics follow the same rule for their activity, replay-session, and group measurements. A failed measurement returns an error; it is never rendered as a measured zero or an empty membership list. Release detail, release lists, per-Issue release breakdowns, and merged-Issue metadata likewise fail when a required measurement or enrichment is unavailable. On a successful response, zero counts and blank optional metadata therefore describe the measured data rather than a dependency outage.

Issue reads follow that rule. GET /errors/groups accepts exact platform, service_name, mechanism, and release occurrence filters alongside project_id, environment, and time bounds. It also accepts query, a free-text search: a case-insensitive substring of an error's message, emitting service, or error class. Like every other filter there, it selects the individual errors recorded inside the window, so an Issue is returned when at least one of its errors in that window matches — and the per-Issue counts, first or last timestamps, ordering, and pagination.total_count all describe that same searched set. Search the whole matching set by following the returned cursor rather than filtering a page you already hold. GET /errors/groups/{group_id}/affected also requires the intended project_id for an organization-level credential; a missing group and a group owned by another project both return the same 404.

GET /errors/groups also accepts sort. It is last_seen by default — most recently seen first — and attention puts the Issues currently marked as needing attention first, then applies the last-seen order inside each of those two groups; the marked group carries no internal order of its own. sort never filters: it reorders exactly the set the other parameters matched, pagination.total_count is unchanged, and every Issue stays reachable by paging. Any other value is rejected with 400 rather than falling back to the default. Omit sort to use the default; a present empty value or repeated sort parameters return 400 invalid_request before a query runs, even when the repeated values are identical. A judgment landing between two pages can move a row across the page boundary, exactly as a new occurrence already can under last_seen.

An Issue in either response may carry four judgment fields:

Field Meaning
needs_attention true when the judgment reads this Issue as serious right now. Present on every Issue, but see below: false is not a judgment.
attention_score 0–3, where 0 is contained and 3 is a live outage. An order, not a measurement — comparable between two marked Issues, meaningless alone. Present only on a marked Issue.
judged_area The part of the application the failure comes from: auth, database, cache, queue, external_call, config, client_ui, or background_job. Absent when the suggestion was not confident enough to be worth having. Independent of needs_attention.
judgment_unavailable_reason Why the last attempt produced no judgment, for example no_provider or budget_exhausted. Present only when there is no judgment at all; absent both once judged and before the first attempt.

Four more fields travel with a marked Issue and are informational only: attention_level, judged_probabilities, judgment_model, and judgment_question_set record which rubric level, which distribution, which model, and which exact question wording produced the score. They are provenance for comparing two answers, not a severity label; nothing renders attention_level as one.

Judgments are produced after a read. An Issue returned for the first time carries none of these fields and picks them up on a later read, so treat every one of them as optional and absent rather than as a field that is merely empty. needs_attention: false means "not marked", never "routine": an unjudged Issue, an Issue below the bar, and an Issue whose judgment failed all report false, so the flag may promote an Issue and must never demote one. A judgment changes nothing about the Issue itself — never priority, status, assigned_to, notes, a lifecycle transition, or a notification — and no default ordering, filtering, or count depends on it.

Typed product measurements

POST /analytics/query, GET /analytics/results/{result_id} and GET /analytics/results/{result_id}/participants execute typed trends, ordered person funnels, exact-period retention, person-unit engagement (rolling activity, days-active frequency, lifecycle), funnel correlation (what separates a funnel's converted entrants from its dropped entrants; always an observational association, never a cause) or web analytics (visitors, sessions, page views, bounces, visible time and scroll depth from the browser SDK's page tracking, by page, entry page, exit page, referrer, channel or campaign parameter) or revenue (gross, refunds, net, transactions and payers per currency, from the amount and currency your events carry) or survey results (how many people were shown, answered or dismissed one in-product survey, and what they answered) or heatmaps (where visitors click on one page, which elements they click and how far they scroll, from the browser SDK's autocapture and page tracking) and read frozen evidence. Query and get responses contain exactly one payload — result for trends, funnel for funnels, retention for retention, engagement for engagement, correlation for funnel correlation, web for web analytics, revenue for revenue, survey for survey results or heatmap for heatmaps — and only when the status is PRODUCT_RESULT_STATUS_READY. Funnel step and outcome selections, retention cohort and return selections every engagement point, bin, and state selection, every correlation outcome-group and signal selection, every web visitors and bounced-visitors selection, every revenue payer and top-payer selection, every survey count's selection, and every heatmap total, cell, element, other-entry and scroll-band selection use the same participant route. GET /analytics/results/{result_id}/contribution accepts exact snapshot_id, selection_id, positive decimal ordinal, contribution_ref, and project_id query parameters. It returns bounded frozen event references — funnel chain steps, or a retention cohort's entry and exact-period returns — with first-entry attribution and one-person outcome and maturity facts under current source permissions. It does not return arbitrary event properties or a live timeline. Engagement mints no contribution reference at all: a contribution_ref prefixed pec1: is refused with 400 invalid_argument — page its selection with the participants route instead. Correlation has no contribution drill-down either: a contribution_ref prefixed pcc1: is refused the same way, and so is a web analytics reference prefixed pwc1:, a revenue reference prefixed pvc1: and a heatmap reference prefixed phc1:. Query and get responses carry manifest.snapshot.dataset_state, which says whether the people behind the result's numbers are already listed (PRODUCT_DATASET_STATE_MATERIALIZED) or are built by the first participants page, contribution read or export (PRODUCT_DATASET_STATE_ABSENT, a trend result nobody has drilled into yet — that first request takes longer, and the numbers are complete either way; the same result reads back as PRODUCT_DATASET_STATE_MATERIALIZED once that list has been built). While the state is PRODUCT_DATASET_STATE_ABSENT the same snapshot carries aggregate_qualifying_events and aggregate_subjects: the occurrences the definition selected, and the distinct people and accounts they belong to. Each event reference carries a replay field exactly when it has a session: available when a recording exists and this credential may open it, none_recorded when the occurrence was captured in a session with no saved recording, or withheld when this credential lacks session-replay read access; the field is absent entirely for an occurrence with no session at all. Query and get responses carry a status (PRODUCT_RESULT_STATUS_PENDING, PRODUCT_RESULT_STATUS_RUNNING, PRODUCT_RESULT_STATUS_READY, PRODUCT_RESULT_STATUS_FAILED, PRODUCT_RESULT_STATUS_CANCELED, PRODUCT_RESULT_STATUS_EXPIRED, PRODUCT_RESULT_STATUS_INVALIDATED) and, when failed, a failure_reason (PRODUCT_RESULT_FAILURE_REASON_EXECUTION_BUDGET, PRODUCT_RESULT_FAILURE_REASON_TOO_MANY_RUNNING, PRODUCT_RESULT_FAILURE_REASON_SOURCE_UNAVAILABLE, PRODUCT_RESULT_FAILURE_REASON_IDENTITY_UNAVAILABLE, PRODUCT_RESULT_FAILURE_REASON_INTERNAL, PRODUCT_RESULT_FAILURE_REASON_KIND_UNAVAILABLE, PRODUCT_RESULT_FAILURE_REASON_SOURCE_UNADMITTED, PRODUCT_RESULT_FAILURE_REASON_HISTORY_UNAVAILABLE, PRODUCT_RESULT_FAILURE_REASON_METRIC_OVERFLOW, or PRODUCT_RESULT_FAILURE_REASON_STORAGE_EXHAUSTED). A ready response carries its payload under the key for its kind — result for a trend, funnel for a person funnel, retention for a retention grid, engagement for engagement, correlation for funnel correlation, web for web analytics, revenue for revenue, survey for survey results, heatmap for heatmaps — and every one of the nine carries the same manifest, so the coverage verdict for a trend is at result.manifest.current.coverage and for the others at funnel.manifest.current.coverage, retention.manifest.current.coverage, engagement.manifest.current.coverage, correlation.manifest.current.coverage, web.manifest.current.coverage, revenue.manifest.current.coverage, survey.manifest.current.coverage and heatmap.manifest.current.coverage. Engagement's manifest.comparison is always absent — it refuses a comparison window outright. Read it under the key your request asked for; the field is never omitted for one kind and present for another. A measurement runs in the background: POST /analytics/query returns as soon as it is accepted, with a result_id and a status, and wait_millis (0–25000) is how long the call blocks for PRODUCT_RESULT_STATUS_READY before answering with the status that stands. A response that is not ready carries no payload and is a correct answer, not a timeout; poll GET /analytics/results/{result_id} for a final status. Four measurements may be pending or running per project — a fifth answers PRODUCT_RESULT_STATUS_FAILED with PRODUCT_RESULT_FAILURE_REASON_TOO_MANY_RUNNING and no result_id. POST /analytics/results/{result_id}/cancel stops one that has not completed; it needs the same permission as running a measurement, and the stopped measurement publishes nothing. Definitions use "version": 2: a kind-independent unit (person, or account with an account_type), trend series with stable ids, per-selection capture origins, one optional AND or OR filter_group of event-property filters, and one optional breakdown ({property, limit, source, account_type}). source is event (default), person (person unit only; needs persons:profile:read) or account (needs its own account_type and groups:read); a person or account source freezes the subject's current value at execution time through the owner's snapshot, never the event's own value, and both scopes are re-checked on every later read of the result. A definition the server does not execute returns 400 invalid_argument whose message starts with a QUERY_* refusal code, before anything runs. Engagement's own two codes are QUERY_ENGAGEMENT_INVALID (an unknown engagement.mode, a parameter belonging to another mode, a missing required parameter, or a window with no complete lifecycle bucket) and QUERY_ENGAGEMENT_LIMIT (a rolling_days/history_days above the supported ceiling, or a window exceeding bounds.max_engagement_days or bounds.max_lifecycle_buckets); the account unit is refused with QUERY_UNIT_UNSUPPORTED. A web definition is web: {dimension, interval, limit, service_name}; its codes are QUERY_WEB_INVALID (a breakdown, comparison window, event filter, filter_group or account filter, an unknown dimension or interval, an interval with a dimension, a limit without a dimension, or a bad service name) and QUERY_WEB_LIMIT (a limit above 100 or more than 100 interval periods). Reading any web result needs agents:content:read as well as the analytics scopes: without it the request is refused with 403. See measure web analytics. A heatmap definition is heatmap: {page, mode, viewport_width_bucket, service_name, include_rage_clicks, grid, limit} and accepts the person unit only; page is one declared route template and mode is clicks, elements or scroll. The viewport_width_bucket is optional and 0 is the smallest class, not unset. A heatmap has no interval. Its codes are QUERY_HEATMAP_INVALID (a missing or malformed page, an unknown mode or viewport class, a half-set grid, a field of another mode, a bad service name, a breakdown, comparison window, event filter, filter_group or account filter) and QUERY_HEATMAP_LIMIT (a grid above 50 columns by 200 rows or a limit above 100); the account unit is refused with QUERY_UNIT_UNSUPPORTED. Reading any heatmap result needs agents:content:read as well as the analytics scopes: without it the request is refused with 403. Its state is one of PRODUCT_HEATMAP_STATE_MEASURED, _IMMATURE, _INSUFFICIENT_EVIDENCE and _UNAVAILABLE, which discloses nothing. See measure heatmaps. A revenue definition is revenue: {events, origins, amount_property, currency_property, currency, interval, breakdown_property, breakdown_limit, top_payers} and accepts the person and the account unit; its codes are QUERY_REVENUE_INVALID (a breakdown of the definition's own, a comparison window without one currency, with an interval, a breakdown property or top payers, or overlapping the window, an unknown interval, a bad property or currency, a breakdown_limit without a breakdown_property, or an interval with a breakdown property) and QUERY_REVENUE_LIMIT (a breakdown_limit above 50, top_payers above 25 or more than 100 interval periods). A comparison window on one currency's totals adds revenue.comparison to the result: that window's own state, closed, accounting and the currency's totals, with revenue.manifest.comparison describing the window. Every amount in a revenue result is a decimal string with four fractional digits, never a number, and amounts of different currencies are never added or converted. Reading any revenue result needs agents:content:read as well as the analytics scopes (and groups:read for the account unit), and whoever can read it can page its payers and top payers. See measure revenue. A survey results definition is survey: {survey_id, question_id} (the survey is a launched survey of the project; question_id is optional) and accepts the person unit with person filters and audiences; its codes are QUERY_SURVEY_INVALID (a missing or malformed survey_id or question_id, a breakdown, a comparison window, a filter group, account filters or a filter that is not a person filter) and QUERY_SURVEY_NOT_FOUND (a survey that is not in the project, was never launched, or has no such question). The result under survey holds counts only, each {count, people, unresolved, selection_id}: totals (shown, responded, dismissed) and, per question, the options, the rating values with promoters, passives and detractors on a 0 to 10 scale, or the open-text answer count. No rate or score is computed. Open-text answers are paged by GET /api/v1/analytics/results/{result_id}/survey-answers with project_id, the question's selection_id (its text.answer_selection_id), limit (1 to 50; the default is 20) and cursor: the response is {result_id, snapshot_id, selection_id, question_id, answers: [{ordinal, respondent, text, answered_at, response_id}], has_more, next_cursor, total, expires_at, source_scopes, computed_at}, in answer order, with personal data redacted in text. Reading any survey result, or its answers, needs agents:content:read as well as the analytics scopes: without it the request is refused with 403. See run in-product surveys. POST /analytics/query/preview describes what POST /analytics/query would do with the same document, without running it. Its body is {project_id, definition} or {project_id, saved_insight: {id, revision}}: exactly one of the two, and no execution_key and no wait_millis, because a preview spends none. It needs the permissions of the run: analytics:read, persons:read and analytics:query (and insights:read for a saved insight), and a key that could not run the document is refused with the run's 403. The answer is {advisory, outcome, likely_refusals, query, sources, limits, history, size, checks, previewed_at}. It is advisory: it reserves nothing, reads no event data, estimates no size (size.state is not_estimated) and is not a quote, so a document with outcome PRODUCT_QUERY_PREVIEW_OUTCOME_NO_REFUSAL_KNOWN can still be refused or fail when it runs. A refusal in likely_refusals has a code, a stage, a reason, a remedy and missing_scopes; a limit in limits has limit, used (empty when only a run can tell), observed_now and when_hit. See Preview a measurement before you run it. GET /analytics/query-capabilities takes no parameters, needs analytics:read, and returns the accepted definition_version, each kind with its units (with the trend measures, whether person_filters are accepted, and the offered breakdown_sources), origins, filter_operators, filter_group_operators, bounds (including max_breakdown_limit, max_profile_breakdown_subjects, max_engagement_days, max_lifecycle_buckets and max_engagement_participant_rows) and refusal_codes. For the engagement kind, each entry also carries engagement_modes (rolling_active, days_active, lifecycle), the accepted rolling_days (7, 30) and lifecycle_history_days (30). For the web kind, each entry carries web_dimensions, web_channels, web_channel_rules_version and its intervals (day, week, month), and bounds adds max_web_rows, default_web_rows, max_web_buckets and max_web_participant_rows. For the revenue kind, each entry carries its intervals, revenue_amount_property and revenue_currency_property, and bounds adds max_revenue_currencies, max_revenue_breakdown_rows, default_revenue_breakdown_rows, max_revenue_buckets, max_revenue_top_payers, revenue_amount_scale, max_revenue_amount (a decimal string) and max_revenue_participant_rows. For the survey kind, each entry carries the person unit, and bounds adds max_survey_questions, max_survey_options, max_survey_participant_rows, max_survey_answer_page and max_survey_text_characters. See measure product events for the shared definition, retry key, exact selections, current source permissions and MCP and CLI equivalents.

Read-only SQL over events

Two routes run and describe one read-only SQL SELECT over a project's own events. Both are authenticated and non-cacheable. See Run read-only SQL over your events for the table, the limits and every refusal.

Method and path Scope Notes
GET /api/v1/analytics/sql/capabilities analytics:read available, the SQL dialect, the table and its columns (name, type, description, required_scopes), every limit, the function families, what a statement may not use and the error codes. Takes no query parameters: any is 400. Reads no event data
POST /api/v1/analytics/sql analytics:sql + analytics:read + persons:read body {"project_id", "statement", "from", "to", "limit"?}; from and to are required RFC 3339 instants, [from, to), at most 92 days apart; limit is the most rows to return (1,000 when omitted, at most 10,000)

A project-bound key may omit project_id. Request bodies are strict: an unknown field is 400 invalid_request. The answer carries columns (name, type), rows (each {"values": [...]}), truncated, statistics (slice_rows, slice_bytes, rows_read, result_rows, result_bytes and elapsed_ms, all decimal strings), properties (SQL_PROPERTIES_DECISION_INCLUDED, ..._SCOPE_WITHHELD or ..._POLICY_WITHHELD), the applied from and to, an execution_id and error, which is null for a result. Values follow the column's type: 64-bit integers and decimals are decimal strings, floats are numbers, timestamps are RFC 3339 strings in UTC and NULL is null.

A statement that is refused or fails is 200 with error set to {"code", "message", "position"}, empty columns and rows, and a code from SQL_ERROR_CODE_*; only a syntax error has a position. A missing scope is 403 with code PermissionDenied, a missing or invalid window is 400, and an organization that reached its limit of 20 runs a minute or 300 an hour gets 429 with {"error": "rate_limited"}. A run can take up to about a minute and a half, so allow the client at least two minutes. Every run is audited.

Resource catalog

Area Resource prefixes Main purpose
Customers and accounts /persons, /groups, /group-types, /account-segments Profiles, timelines, group accounts, watched segments
Raw observability /traces, /logs, /metrics, /services, /search, /stream/logs Search, correlation, aggregation, live logs, RED metrics, service map
Errors and delivery context /errors/groups, /releases, /sourcemaps, /symbols Issue triage, impact, lifecycle, deploys, readable stacks
Replay /replay, /replay-for-trace Recordings, snapshots, related evidence, trace lookup
Events and rollout /events, /analytics, /flags, /decide Event discovery, trends and triggerers, cohorts, flag configuration and evaluation
AI execution /agent-runs, /agent-sessions, /agent-turns, /agent-events, /telemetry-completeness, /llm, /evaluation Runs and their step graph, conversations and turns, step-level agent evidence, coverage by normalizer, scores, cost, pricing overrides, quality settings, quality evaluations and their provenance
Agent inventory and evidence /assets, /review-signals, /action-receipts, /receipt-chains, /abom-manifests Declared and discovered agents, what still needs a human, what an agent did and under whose authority, tamper-evidence checks, signed bills of materials
Agent control and provenance /quarantines, /content-policies, /authority-edges, /memory-items, /deployments, /config-snapshots Approval-controlled containment, content boundaries, authority, memory labels and propagation, immutable deployment and configuration provenance
Fleet and controlled execution /fleet, /agent-drive-attempts, /agent-session-replays Fleet posture, drive-attempt evidence, and recorded-session replay results
Detection and response /anomalies, /alerts, /slo, /incidents, /oncall, /escalation-policies Findings, rules, SLO burn, silences, alert history and acknowledgements, response, schedules, routing
Saved work and export /dashboards, /export Dashboards and widgets, background exports
Organization and access /projects, /account, /invitations, /settings, /usage, /users/me Projects, members, API keys, configuration, usage, contact methods
Integrations and delivery /connections, /notifications, /tickets, /issues Provider authorization, delivery channels and their message templates, Issue ticket links

Organization members

GET /api/v1/account/members requires members:read and lists members of your current organization. Set role to owner, admin, member, viewer, or developer to return only that role; enum spellings such as ROLE_OWNER are also accepted, case-insensitively. The role filter applies before limit, and pagination.total_count counts members matching that filter. Keep the same role while following a returned cursor to retrieve the complete matching roster. Omit role to include all roles. An empty, unknown, or repeated role returns 400.

Connections and provider actions

Connection create and patch requests may set customer-facing configuration, but provider-routing identity and outbound API destinations are server-managed. Requests containing base_url, installation_id, or team_id in connection metadata return 400; use the provider's verified connect or reauthorization flow to establish those values. A nested identity-mapping delete is also parent-exact: DELETE /connections/{connectionId}/identity-mappings/{mappingId} returns 404 when the mapping does not belong to that connection.

Slack buttons and modal submissions use the mapped member's permissions at click time. Removing alerts:acknowledge or errors:write takes effect for later clicks even when the Slack identity mapping still exists. A transient failure does not consume the action: the callback returns a retryable 503, and a repeated signed delivery may try the action again. Once the action succeeds, the same delivery is acknowledged without applying it twice. During a rate-limit dependency outage, both /webhooks/{provider} and /slack/interactivity fail closed with 503.

Groups

GET /groups/detail?project_id=&group_type=&group_key= combines the saved group profile with activity statistics. If those statistics cannot be measured, the request returns a temporary error; it never substitutes event_count: 0 or empty first or last-seen values. A successful zero therefore means the group was measured and had no activity.

Account coordinates are exact query values, including surrounding whitespace and dot-only keys. Use the same selectors on /groups/members, /groups/timeline, POST /groups/members/remove, POST /groups/members/move and DELETE /groups/detail. The former path-coordinate routes are removed.

For membership removal or move, send preview_only: true with the exact distinct_id (and to_group_key for a move). Review the returned complete before_json and proposed_json sets. After approval, repeat the same target with preview_only: false and the returned state_digest as expected_members_hash. A changed membership refuses application; prepare again and review the new proposal. The preview changes no membership.

Anomaly findings

GET /anomalies returns findings newest first. sort=actionability returns the same findings in a different order: the ones a ranking model judged most likely to be real faults first, and findings it has not ranked last. sort=detected_at is the default and restates it explicitly. Any other value is rejected with 400. Neither order filters — the set of findings a request returns does not depend on sort.

A ranked finding carries two extra fields. likely_real is true when the ranking is confident the finding is a real fault, and actionability_score is where it placed the finding on a 0–3 scale. likely_real: false means the finding was not promoted; it never means the finding is noise, and a finding that has not been ranked at all reports false for the same reason.

An unranked finding omits actionability_score entirely rather than reporting 0 — a zero would be indistinguishable from a finding that was measured and placed at the bottom. When the reason a finding could not be ranked is known, actionability_unavailable_reason carries it: no_provider (ranking is not configured for the organization), budget_exhausted (the plan's daily ranking cap is spent — see limits), or one of several transient values meaning the ranking service could not answer. The field is absent when a finding is simply waiting to be ranked.

Ranking is an aid to ordering, not a filter or a lifecycle state. Acknowledge and resolve behave identically for ranked and unranked findings.

Project configuration

POST /projects and PATCH /projects/{id} distinguish an omitted sampling_rate from an explicit zero. Omit it to use the create default (1.0) or preserve an existing project value. Send 0 to disable ingestion for that project.

Family policy and usage

GET /family-policy?project_id= returns the current organization policy, granted retention ceilings and both revisions. Every organization starts with a plan-derived default (full retention, 20% product / 80% technical). product_expired_before and technical_expired_before, when present, mark where a shortened window permanently removed history. GET /usage/families?project_id=&period=YYYY-MM adds the selected UTC month's measured product and technical counts, pooled corrections and enforcement status. Both require usage:read. A project selector authorizes that project's mirror of the organization policy and counts; it does not create or narrow the shared allowance. Missing measurements are omitted rather than reported as zero. Policy is current even when the selected month is historical.

PUT /family-policy also requires organization-wide settings:write. Send expected_revision, expected_entitlement_revision, a stable idempotency_key and all four choices inside values: product_retention_days, technical_retention_days, product_allocation and technical_allocation. Keep revisions and allocations as decimal strings to preserve 64-bit values. Both retention windows must be 7–365 days within their granted ceilings, and the two allocations must sum exactly to the existing pool. A stale revision is refused; read the policy again before preparing another change. An identical retry with the same key returns the original operation receipt for seven days; a changed request needs a new key.

Saved retention windows apply to queries, exports and new data within about a minute; stored data outside a shortened window is removed in the background. Allocations are admission thresholds, not a hard spending cap. See usage, model prices and decision scoring for complete examples and the confirmed MCP and CLI workflow.

Tickets

/issues/{group_id}/tickets lists or creates ticket links for an Issue; it is not the Issue-list surface. Use /errors/groups and /errors/groups/{group_id} for Issue triage and detail.

Organization-level ticket creation requires project_id; every caller supplies an absolute issue_url — the Issue's console page, /view/issue/{group_id}?project_id={project_id} — and an Idempotency-Key header (or idempotency_key body field). The key is scoped to one project — the same key in another project is a separate create. Reusing the key with the same request returns the existing provider ticket link; reusing it with a different request is rejected. When the provider accepts the request but returns no usable answer, the response is a retryable 503 and the create is briefly held while the provider's issue search catches up, so a retry inside that window reports that creation is already in progress rather than filing a second ticket. Its error details carry reason=PROVIDER_EFFECT_UNRECORDED; keep retrying the unchanged request with the same key. A create that already reached the tracker stays bound to the connection and destination it reached, so retries go there rather than to whichever connection is active now; if that destination is gone or repointed, the retry is refused with 400 naming the destination it is waiting for, instead of filing elsewhere. That refusal does not clear on its own — restore the connection to that target and retry with the same key. A provider 4xx that definitively rejects creation is also a non-retryable 400; repair the provider authorization, permissions or target before sending the request again. Provider timeouts, throttling and 5xx responses remain retryable. GET /tickets/{id} and Issue ticket lists expose truthful sync state and safe retry errors. Every ticket operation resolves to one explicit project. Organization-level credentials must send one consistent project_id; project-scoped credentials may omit it or repeat their exact signed project. A different non-empty query value returns the shared 403 permission_denied before routing. A conflicting create-body value, when the query is omitted or exact, retains the same generic 404 for real, foreign, and unknown projects before ticket creation is attempted. Jira and Linear OAuth start accepts non-secret metadata. Reauthorization also sends the exact existing connection_id; the callback refreshes that needs_reauth row with an optimistic version check rather than creating a duplicate. The callback is /api/v1/connections/oauth/callback and returns to /connections.

The callback only completes in the browser that started the flow. A caller without a browser requests a handoff for a person to finish:

POST /api/v1/connections/oauth/{provider}/handoffs
GET  /api/v1/connections/oauth/handoffs/{handoffId}
  • POST requires connections:write. It accepts the same non-secret metadata and optional connection_id as a start, and returns the handoff plus a handoff_url. A signed-in member opens that URL and continues in their own browser. That continuation is a start with only {"handoff_id": ...}: it must come from a person, not an API key, and it takes the provider, setup and reauthorization target from the handoff.
  • GET requires connections:read. It reports status:
    • awaiting_human: nobody has opened the handoff yet. It expires after 24 hours.
    • authorizing: a person continued and has 15 minutes to finish at the provider.
    • completed: carries the new connection_id.
    • failed: carries a failure_code.
    • expired: no connection resulted.

Connection metadata accepts only the documented fields for the selected provider. A provider's API address cannot be changed through a connection: requests containing base_url are rejected. Provider routing identities are set only by a verified provider callback and cannot be replaced by a create or update request. A connection revoked by an organization administrator cannot be reactivated with ordinary connection-write access. If a first Jira ticket request discovers setup metadata while an administrator edits the same connection, the newer edit wins and the request can be retried; discovery never overwrites that concurrent change.

Telemetry deletion

Telemetry deletion is project-exact and durable — not a display filter — on every signal that has it: DELETE /traces/{trace_id}, DELETE /logs/entry, DELETE /metrics/{metric_name}, DELETE /errors/groups/{group_id}, and DELETE /persons/{person_id}/errors. Each needs project_id as a coordinate rather than a filter — an organization-level credential must supply it and no route falls back to an organization-wide delete — plus the matching *:delete scope. The last two require errors:delete; the person route accepts a person ID or a distinct ID and resolves it to that person's full identity set before erasing, returning person_id and the distinct_ids it acted on so the reach of the erasure is visible rather than assumed. Deleting an Issue or a person's errors also suppresses matching occurrences that arrive later, and cannot be undone. Erasing a person's errors does not touch their traces, logs, metrics, events, or recordings; there is no single call that removes a person from every signal.

A successful delete means the suppression is durable and already in effect: from that response onward every read of the target is empty, including reads of copies still in flight when you called. It does not mean the underlying storage has finished being reclaimed — that happens in the background, and it is not something a read can observe. This is why the call answers promptly and does not hold while a large deletion is processed. Repeating a delete is safe and returns the same answer; it never reveals whether anything matched.

Telemetry ingestion uses the separate https://api.anectico.com host with OTLP /v1/traces, /v1/logs, and /v1/metrics plus diagnostic-event /api/v1/capture; it is documented separately because it does not follow normal resource CRUD.

Platform-operator /admin routes and provider callback and webhook routes are not customer integration surfaces and are intentionally excluded from this catalog.

Exact trace reads by ID do not add a hidden recent-time window; they search the retained data in the selected project. Detail reads return at most the earliest 1,000 spans by start time. Responses carry partial, returned_span_count, and total_span_count, emitted as JSON numbers for the counts. The flat GET /traces/{trace_id}/spans response uses span_count for the returned count alongside partial and total_span_count. The 64-linked-consumer discovery cap is disclosed as linked_traces_truncated: true; total_span_count is then zero because the full count is unknown. Spans retain their owning trace_id. Log rows, including unified-search hits, retain their canonical log_id and owning project_id for exact follow-up reads. Unified search names unsuccessful legs in summary.failed_sources and remains partial even with zero hits. Service maps report the exact time_range and window_narrowing_reasons (retention, range_limit) for narrowed requests. When partial is true, the server returns the bounded span prefix but withholds whole-trace timing, statistics, and critical-path answers rather than computing them from incomplete data. Agent-run detail uses the same three completeness fields for its enriched span list. Its run header is aggregated separately over the complete selection; a partial span list does not make the transcript complete. These detail reads have no continuation cursor for omitted spans.

Trace aggregates require the complete input and return HTTP 429 ResourceExhausted when the 100,000-span window limit is exceeded. Narrow the time range and retry. Trace and service-map critical-path analysis also returns HTTP 429 ResourceExhausted if its graph requires more than 100,000 path visits; no partially searched path is presented as the answer. Raw trace spans remain available through the span endpoint. Matching trace and span IDs in different projects are separate identities.

GET /traces/{trace_id}/critical-path analyzes parent relationships and span links between distinct spans available in the selected trace and time window. A detected cycle is reported as has_cycle: true, with HTTP 200 when the analysis completes within its limits. The returned path is a deterministic simple path: it never repeats a span to follow the cycle. Inspect the retained parents and links to diagnose the relationship; a successful ingest did not certify an acyclic graph. Missing or out-of-window spans are not invented, so has_cycle: false does not prove that unobserved relationships are cycle-free. When partial: true, whole-trace analysis is withheld; do not interpret the default cycle flag as a completed cycle check. The graph-complexity refusal above still applies to cyclic and acyclic inputs alike.

POST /traces/search evaluates trace-level filters before cutting the requested page. This includes minimum and maximum duration and both single- and multi-value service filters. Duration is the wall clock from the trace's earliest span start to its latest span end; service matching is an OR across the trace's complete service set. Both are evaluated over all spans for that trace in the requested organization, time window, and project, even when another filter selected the trace through one particular service, environment, or identity.

Search ordering also applies to the whole matching set before pagination. sort_by accepts start_time (the default), duration, span_count, service_count, or error_count; sort_order is asc or desc (the default). The measurements use the same complete span set described above. Equal values are ordered by project ID, then trace ID, ascending. Keep the same window, filters, and sort when following pagination.next_cursor. Pages are reads of current data, so newly arriving or deleted spans can change a later page.

MCP search_traces defaults to longest duration first; set sort: "start_time" for newest first. The CLI defaults to newest first. To find the slowest requests in a day, use anectico traces search --since 24h --sort-by duration --sort-order desc.

Follow a search result with the same window when opening its detail routes: GET /traces/{trace_id}, GET /traces/{trace_id}/spans, and GET /traces/{trace_id}/critical-path all accept project_id, start_time, and end_time. Omitting the times searches all retained data; supplying them reuses the narrower historical interval that produced the search result.

POST /search/unified fans a query out across the selected signal families. Its trace leg uses one case-insensitive union: trace ID, service name, operation name, distinct_id, or any span attribute value. A match in any one qualifies the trace before the per-source limit is applied, including an attribute-value match in an older trace. Attribute keys are not part of this free-text union; use the trace-search attributes object when an exact key and value pair is required.

Every field in that union is matched per span, and one matching span qualifies the whole trace. distinct_id is the one worth stating: a trace whose root span carries one person and whose child span carries another is returned for either, and the hit's own fields still describe the root span. Leading and trailing whitespace is stripped from the query before any of this, so a padded term matches exactly what the trimmed one does, and a query that is only whitespace applies no free-text filter at all rather than matching nothing.

GET /metrics/names returns at most 1,000 names. When more exist, truncated is true and total_count is 0 (unknown); when truncated is false, total_count is exact. Do not interpret the returned page length as the full metric-name cardinality when truncated is true.

Affected-customer search is available at GET /errors/groups/{group_id}/affected?project_id=&search=. The distinct_id half is evaluated before the 1,000-identity cap, so an older matching identity is prioritized into the candidate set. If an alias matched, that matching alias is returned as the row's distinct_id; when only the profile label matched, the match is visible in display_name and distinct_id remains the ordinary representative. Display-name matching happens only after permission-gated profile hydration and is therefore bounded by the 1,000-identity candidate page. totals_are_capped: true means the people, account, and pagination totals are floors over that bounded set and must be rendered with a +.

Telemetry time bounds are validated after authorization and before reads run. Trace search, detail, and aggregates, service health, RED, and maps, agent-run lists, LLM cost reads, error-group reads, and mixed search and correlation accept timestamps through 2262-04-11T23:47:16.854775807Z. A supplied later bound returns 400 InvalidArgument naming time_range.start or time_range.end. Person and account timeline windows use the same maximum; the end is inclusive. Unsupported timeline and agent-run page cursor timestamps return 400 naming the cursor. An explicit ancient timeline end returns an empty page, and a live timeline page cursor still takes precedence over that end.

Metric-only reads and unified searches selecting only metrics preserve nanosecond precision and accept timestamps through 2262-04-11T23:47:16.854775807Z. Metric windows include both bounds without rounding them to the chart interval. A later bound returns 400 InvalidArgument, naming time_range.start or time_range.end. Omitted bounds keep each endpoint's defaults, old starts may advance to your retention floor, and entirely expired windows remain empty.

Log patterns

Log search (POST /api/v1/logs/search/advanced), patterns, fields, and aggregates reject a supplied time bound after 2262-04-11T23:47:16.854775807Z with HTTP 400 InvalidArgument, naming time_range.start or time_range.end, before querying storage. The maximum itself is accepted. Normal defaults and retention clamping still apply; a non-empty window sent to storage must start no earlier than 1900-01-01T00:00:00Z. See log search time windows.

Both log search endpoints, and live tail (GET /api/v1/stream/logs), accept one typed field filter: the JSON tree filter or its text form filter_expression, never both (a stream takes them as query parameters, the tree URL-encoded). Page and total_count use the same filter. An invalid or over-limit filter returns 400 InvalidArgument; a filtered read that would scan more than 100,000,000 rows returns 400 FailedPrecondition; more than four concurrent filtered searches in an organization return 429. A condition on an attribute key the credential may not read returns 400 wherever it appears in the tree. See Filter logs by field for the grammar, fields, semantics and limits.

GET /api/v1/logs/patterns clusters a window's log messages into normalized templates and returns them largest first. Normalization replaces the variable parts of a message with placeholders — <UUID>, <IP>, <HEX>, <NUM>, <FLOAT> — so one template stands for every line that shares its shape. It takes logs:read.

Query parameters: project_id, start_time and end_time (RFC3339; omitting both searches all retained data), service_name, level, min_count and limit. min_count is the smallest cluster worth returning and is clamped to 1–1000, default 10; limit is clamped to 1–100, default 20. A value outside a range is clamped rather than refused; a value that is not an integer is ignored and the default applies.

The response carries patterns, total_patterns, total_logs (the records scanned in the window, which is what the per-cluster percentage is of), min_count echoing what was applied, and retrieved_at. The count fields have these JSON types:

Field JSON type Example Meaning
total_patterns number 3 Number of clusters returned in patterns, after applying min_count and limit.
min_count number 1 Applied minimum cluster-size threshold.
total_logs string "18" Total matching log records scanned, before the cluster threshold and result limit.
patterns[].count string "12" Number of log records in this cluster.

The bounded total_patterns and min_count fields use 32-bit integers. The record counts use 64-bit integers, which ProtoJSON encodes as decimal strings to preserve precision. Clients should retain those strings or use an integer type that can represent the full value; converting an arbitrary 64-bit count to a JavaScript Number can lose precision.

Each entry in patterns has template, count, percentage, service_name, level, sample (one representative raw message), first_seen and last_seen, plus four label fields:

Field Meaning
category One of request, auth, database, cache, queue, external_call, config, lifecycle, job, security, client_error, other. Absent unless the label was confident enough to be worth having.
severity_score 0–3, where 0 is ordinary traffic and 3 is an outage. Absent on an unlabeled template.
needs_attention true when severity_score cleared the "degraded or worse" bar. Always present.
label_unavailable_reason Why the last labeling attempt produced no label, for example no_provider or budget_exhausted. Absent both once labeled and before the first attempt.

Labels are produced after a read. A template returned for the first time carries none of the four and picks its label up on a later read, so treat every one of them as optional and absent rather than as a field that is merely empty.

Two readings the fields do not support. needs_attention: false means "not flagged", never "routine": an unlabeled template is false too, so the flag may promote a template and must never demote one. And a template with no category and no label_unavailable_reason has not been labeled yet — it is not a template that was examined and found uninteresting. Ordering is by count descending regardless of labels; nothing is filtered, hidden or reordered by them.

People having a bad time

POST /experience/struggling-people?project_id=PROJECT finds the known people who crossed a threshold in one window and returns them in order with the evidence. The body is optional. Its fields are lookback (a length such as "3600s") or start_time and end_time, signals (STRUGGLING_SIGNAL_ERRORS, STRUGGLING_SIGNAL_SLOW_TRACES, STRUGGLING_SIGNAL_FRICTION, STRUGGLING_SIGNAL_SLOW_PAGES), the thresholds min_error_events, slow_trace_ms, min_slow_traces, min_friction_events, poor_lcp_ms, poor_inp_ms, poor_cls and min_poor_page_samples, then min_population and limit. The window defaults to the last 24 hours and covers 5 minutes to 7 days. A value outside its range is refused with HTTP 400.

It needs persons:read. Each signal also needs the read scope of its own data: errors:read, traces:read, analytics:read, and metrics:read with analytics:read for slow pages. A signal the credential may not read has the state STRUGGLING_SIGNAL_STATE_WITHHELD and names the scope in missing_scopes; the rest is still answered. A signal that is not measured has no counts, which is not zero. The rule, the fields and the limits are in Find who is having a bad time.

People with a failure and no matching report

POST /experience/silent-people?project_id=PROJECT lists the known people who hit a failure in one window and have no matching report in the report sources that were checked, with what was observed of each afterwards. The body is optional. It takes the window and the failure fields of /experience/struggling-people (lookback or start_time and end_time, signals, and the thresholds), then report_sources (REPORT_SOURCE_PROBLEM_REPORTS, REPORT_SOURCE_SURVEY_ANSWERS), return_gap_minutes (1 to 1440, default 30) and limit. A value outside its range is refused with HTTP 400.

It needs persons:read. errors:read reads the error signal and the problem reports. analytics:read reads the friction signal and the survey answers. traces:read reads slow traces, and metrics:read with analytics:read reads slow pages. A report source that the credential may not read has the state REPORT_SOURCE_STATE_WITHHELD and names the scope in missing_scopes. Read answer first: SILENT_ANSWER_NO_REPORT_SOURCE and SILENT_ANSWER_NO_FAILURE_SIGNAL mean that the question cannot be answered, and then people is empty and population.report_split_known is false. The response never contains report text. The rule, the fields, the wording and the limits are in Find who hit a problem and has no report.

Browser traffic and performance

POST /experience/traffic?project_id=PROJECT and POST /experience/vitals?project_id=PROJECT accept required RFC3339 start_time and exclusive end_time, plus optional exact route and service_name. The window is past, ordered, at most seven days and within readable retention. Traffic needs analytics:read and persons:read; performance needs metrics:read.

Traffic counts every observed page view and nonempty session in the window. Performance counts the latest lifecycle snapshot per sample exported in the selected window, with exact nearest-rank quantiles. Totals and quantiles always cover the whole window; the largest 50 groups and the 50 newest examples are listed, and omitted_groups/omitted_page_views (traffic) or omitted_series/omitted_samples (performance) report the rest. partial, groups_truncated and samples_truncated identify list limits only. A window whose source rows exceed the read's scan limit returns 400 FailedPrecondition; narrow it. Route and service labels are the application's declared SDK labels and need no additional permission. See browser experience for exact fields, consent, session identity, label limits and interpretation.

POST /experience/session-evidence?project_id=PROJECT takes session_id and explicit RFC3339 start_time/end_time (past, ordered, at most seven days). It requires query:read; trace, error and replay legs independently enforce their own read scopes. Each returns a BROWSER_EVIDENCE_STATE_* state and bounded native references. NONE_VISIBLE, WITHHELD, UNAVAILABLE and OUTSIDE_RETENTION are different results. Trace and error references are window-bound; replay presence is session-bound and independently retained, and does not grant playback access.

Replay and group read boundaries

Replay reads are project-scoped even when two projects reuse the same session id. A snapshot response sets has_gaps: true if playback is incomplete, including when the recording exceeds the 1,000-chunk reconstruction cap. Treat its events as a usable partial stream, not as proof that every recorded chunk was returned. Chunk upload times are bounded by receipt time, and a reversed interval is normalized to a non-negative interval; client clocks cannot extend server-side retention.

Heatmap page copies

The masked page copies a heatmap is drawn over are read and deleted here. All are project-scoped (project_id).

Request Permission Returns
GET /api/v1/replay-page-backdrops?project_id=…&page=/pricing replay:read backdrops: for each page, service and viewport class its id, page, service_name, viewport_width_bucket, doc_width, doc_height, size_bytes, sha256, captured_at and settings_revision. page, service_name, limit (default 100, up to 500) and opaque cursor are optional. Continue through pagination with the same selectors
GET /api/v1/replay-page-backdrops/{id}?project_id=… replay:content:read backdrop as above and snapshot: the copy as base64 of a JSON document. 403 without the permission; 400 if the project's content policy withholds replay content
DELETE /api/v1/replay-page-backdrops/{id}?project_id=… replay:delete {"deleted": true}; 404 if there is none

A copy older than your plan's history window is not returned. The document is a tree of {"t": tag, "a": {attributes}, "c": [children]} nodes and text strings with its stylesheets in css; it contains no script and no address. Two further routes, POST /api/v1/replay-page-backdrops/offer and POST /api/v1/replay-page-backdrops, are the browser SDK's own: it asks whether a copy of a page is wanted and uploads one, with a replay:write key. The question names the page and no visitor, and its answer is the same for every visitor of that page. An accepted upload answers 200 with an empty object, whether or not the copy was kept: a repeat of a current copy is dropped without an error. The upload is refused with 400 while the project's page snapshot is off or if the document contains anything outside the allowed elements, attributes and style rules (the message names which rule), 409 if the settings changed since the SDK asked, 413 if the body is over the limit, 429 when requests from one address, or stored uploads for a key or a project, arrive too fast or an address's uploads keep being refused, and 503 when the route's rate limiter is unavailable. A copy is wanted and kept only for a page the project has recorded traffic for; an upload for any other page is answered 200 and not kept. A project keeps copies for up to 500 pages; past that the copy captured longest ago is dropped to make room.

Group property policy applies to filtering as well as returned fields. If access to group properties is denied, GET /groups?search= still searches visible identifiers but does not match a hidden property such as an account name. DELETE /groups/detail?group_type=&group_key= removes the current group from subsequent account-property reads after its dependency check succeeds; recreating that key creates a new current version.

Event and flag reads

Event discovery and triggerer reads are deterministic, project-scoped pages:

  • GET /events/names?project_id=&prefix=&property_key=&property_value=&limit=&cursor= returns events plus pagination. limit is 1–50 when present.
  • GET /events/triggerers?project_id=&event=&since=&until=&property_key=&property_value=&limit=&cursor= requires exact RFC 3339 since and until timestamps and returns canonical person IDs plus the recognizable distinct ID, with pagination. limit is 1–1,000 when present.

Trend measurements use POST /analytics/query with a typed definition and execution key; see Product analytics for frozen results and participants. A trend definition's trends.series holds 1 to 5 independently measured series (get_product_query_capabilities's bounds.max_series); a ready result's result.series[] carries one output per definition series, in order, and result.manifest.current/comparison carry the union population across every series rather than a per-series one. GET /analytics/results/{id}/contribution includes series_id — empty for a union-selection read, naming the series for a series-selection read.

trends.formulas holds 0 to bounds.max_formulas (3) named arithmetic outputs computed over trends.series; see Combine series with formulas for the expression shape, unit algebra and value states. A ready result's result.formulas[] carries one output per definition formula, in order, each with its checked unit, its operand series ids and a value per window — never a population or selection id, since a formula reads no source rows of its own. A series or formula id (never a population UUID) is the shape a metric watch's selector.output_id and a saved-widget's saved_insight.output_id take; passing that shape where GET .../participants, GET .../contribution or an audience endpoint expects a population selection_id returns 400 UNSUPPORTED_RESULT_SELECTOR before the UUID is even parsed — drill into one of the formula's operand series instead.

Malformed or duplicate limit, cursor, since, or until parameters return 400 before a query runs. Cursors are opaque and bound to the exact project and filters; preserve them unchanged. A project-scoped principal may omit project_id or repeat its exact project; a conflicting explicit query value returns 403 permission_denied before an analytics query runs.

POST /decide evaluates flags remotely. GET /flags/local-evaluation?project_id= downloads the equivalent local-evaluation snapshot. The snapshot body includes the full flag definitions plus schema_version, snapshot_version, updated_at, max_age_seconds, etag, and project_id. This endpoint is not paginated: one successful response contains every supported active definition for the project, including when there are more than 100 or 500 flags. Collection limit and cursor parameters do not slice it, and it has no pagination fields. A snapshot exceeding the 8 MiB response limit returns an error instead of an incomplete definition set. Use GET /flags for paginated management reads. Its HTTP response also sends ETag: "<etag>" and Cache-Control: private,max-age=<max_age_seconds>. Send that exact quoted value as If-None-Match; an unchanged snapshot returns 304 with no body. Validators are bound to their project and must never be reused after switching projects. An earlier cache created from the paginated response receives a new validator and a full snapshot on its next refresh, even if flag definitions have not changed.

Remote and local flag evaluation share scalar comparisons. For exact, is_not, in, not_in, and icontains, numbers use their shortest decimal spelling without an exponent or redundant fractional zeros; both 0 and -0 use "0". Strings keep their spelling: numeric 1e-7 matches "0.0000001" and numeric 10000000 matches "10000000", while "1e-07" and "1e+07" do not match those numbers. Boolean values remain distinct from numeric zero or one. gt and lt accept trimmed decimal or scientific numeric strings.

POST /flags creates a feature flag with flags:write. Organization-wide credentials must include a canonical, nonzero project UUID in the JSON body's project_id. For project-bound credentials, the signed project takes precedence. The project must currently be active and belong to the authenticated organization: a missing or malformed ID returns 400; an unknown, deleted, inactive or foreign project returns 404. Rejected requests create no flag. See Creating project-bound saved resources for ownership checks and dependency-error behavior.

Durable exports

Ordinary telemetry creation freezes omitted times to the last 24 hours. Explicit windows outside the history current technical retention keeps are refused. Execution, retry and download require the original window to remain readable under the current technical retention; an increased policy cannot renew an old export. Missing, changed, erased or expired evidence returns a failed precondition; unavailable current authority temporarily refuses access. An idempotent create replay returns the original job, without renewing access.

Individual telemetry deletions (including person-error deletion) and explicit content erasures invalidate earlier ordinary exports in the project; organization-wide jobs depend on all projects. This invalidates the whole file regardless of its filters. An unfinished erasure temporarily refuses source use. A new job can bind the current source after completion; an older job cannot be renewed by retry or policy loosening.

Ordinary jobs created with the content entitlement also retain the original project content-retention configuration. Changes require a new job. For protected content, completion caps expires_at at the earliest source timestamp plus the shorter transcript and tool window, checked before column projection. File creation never restarts that clock. Metadata-only source rows add no content deadline; missing source clocks or expired content refuse publication and transfer.

  • POST /api/v1/export?project_id= creates a CSV, JSON, or Parquet job. Send a stable idempotency_key in the JSON body or Idempotency-Key header. Keys are 1–128 canonical ASCII letters, digits, ., _, :, or -; whitespace, control characters, and Unicode lookalikes are rejected instead of normalized. The opaque query must name a supported signal and may carry RFC 3339 or integral Unix-second start_time/end_time values.
  • POST /api/v1/logs/export?project_id= is the logs convenience endpoint. Its optional format is csv, json, or parquet, and its time bounds are strict RFC 3339 values.
  • GET /api/v1/export/jobs?project_id=&status=&type=&limit=&cursor= returns newest-first signed keyset pages. Preserve a non-empty cursor unchanged with the same project and filters.
  • GET /api/v1/export/jobs/{id}?project_id= polls lifecycle, monotonic progress, attempt count, and expiry. Both job list and detail rows carry content_policy_reason, content_policy_withheld_classes and content_policy_default_posture. The reason is empty until the job runs, then allowed, policy, no_policy, resolver_error or unclassified. The default-posture flag distinguishes the standard posture from a policy you set. See Export data.
  • DELETE /api/v1/export/jobs/{id}?project_id= atomically cancels a pending, running, or retrying job.
  • POST /api/v1/export/jobs/{id}/retry?project_id= re-enqueues a failed job.
  • GET /api/v1/export/jobs/{id}/download?project_id= resolves where an unexpired completed result can be fetched. It returns a url on this API pointing at the route below, not a storage link: the address confers nothing on its own and is authorized afresh on every use. Result objects expire and are deleted 24 hours after completion.
  • GET /api/v1/export/jobs/{id}/content?project_id= returns the result file itself, streamed. It honours Range (answering 206 Partial Content) and sends the export's filename as an attachment. Authorization is re-checked continuously while the file transfers, so a permission removed mid-download ends that transfer as well as the next one.

Every export job {id} is the exact lowercase canonical hyphenated UUID returned at creation (8-4-4-4-12, 36 characters). Uppercase, compact, braced, URN, control-character or Unicode-contaminated, and oversized forms return 400 before a database lookup. A well-formed unknown or sibling-project UUID returns the same generic 404, so UUID validation does not reveal whether a job exists.

Creation requires export:write and the read scope of every signal the export's rows come from — a unified-search export carries trace and log rows and therefore needs traces:read and logs:read alongside search:read. Read and list requires export:read. Retrying and downloading require, in addition to export:write / owner-admin export:download, that the caller still holds those source read scopes at the moment of the request: an export does not stay reachable on the strength of the permissions its creator had when it was made. Downloading is further limited to the credential that created the job, or to an organization owner or admin; another credential in the same project sees the same generic 404 as an unknown job. Every customer request resolves to one verified project in the authenticated organization. REST export endpoints also require projects:read for that verification, including for project-scoped credentials. Those credentials may omit project_id or repeat their exact signed project. A different non-empty URL-query value returns the shared 403 permission_denied before routing. With an omitted or exact URL query, a conflicting top-level create value, nested export query, or logs-export body retains the export's same generic 404 without checking whether that project exists or running the export. Organization-level credentials retain explicit project selection. Sibling-project job IDs return the same 404 as unknown IDs for read, cancel, retry, download, and content. Unknown formats, malformed or reversed times, tampered or foreign cursors, and expired downloads fail closed.

Capture acknowledgements

Before dispatching a structurally valid capture batch, the service records durable attempt evidence. If that cannot be recorded, it returns DEPENDENCY_UNAVAILABLE with HTTP 503 and no events are dispatched; retry with backoff and original message IDs. Queue acceptance still does not establish query visibility or complete source coverage. Request-level counts describe attempts, including retries. After capture processes a project or organization deletion, later capture for that scope is refused with FORBIDDEN (HTTP 403), without dispatch or a retry instruction. Deletion propagation and physical cleanup are asynchronous.

POST /api/v1/capture returns a version 1 acknowledgement. HTTP 200 means a batch ledger was returned; inspect every item before treating it as delivered. The batch.received count includes all original inputs, including invalid ones. Each zero-based index appears once, and received = queued + rejected + unknown.

{"version":1,"request_id":"d561b395-f87f-4e8b-bf54-6c077551ab21","batch":{"received":1,"queued":1,"rejected":0,"unknown":0,"items":[{"index":0,"delivery":"CAPTURE_DELIVERY_QUEUED","code":"CAPTURE_FAILURE_CODE_UNSPECIFIED","retry":"CAPTURE_RETRY_NONE"}]}}
Item delivery Meaning and action
CAPTURE_DELIVERY_QUEUED The queue publisher acknowledged this attempt. Do not retry it. This does not prove storage and query visibility or a completed identity update.
CAPTURE_DELIVERY_REJECTED INVALID_EVENT or QUOTA_EXCEEDED refused the item before publication. Fix the input or quota condition; do not retry unchanged in a background loop.
CAPTURE_DELIVERY_UNKNOWN PUBLISH_UNCERTAIN means some or all attempted records may already be queued. Back off and retry only this item with its exact original message ID, timestamp, identity and payload.

Failure codes use the CAPTURE_FAILURE_CODE_ prefix. Retry values use CAPTURE_RETRY_: NONE, AFTER_CHANGE, SAME_MESSAGE_ID, or UNSAFE_WITHOUT_MESSAGE_ID. The last value means the original event lacked a message ID; assigning one now cannot deduplicate the uncertain attempt. Assign message IDs and timestamps before first delivery. A malformed or unrecognized response or a lost HTTP response also leaves delivery uncertain; a 2xx alone must never cause a client to discard events.

Failures before dispatch use a failure object containing code and retry instead of batch: invalid input is HTTP 400, invalid credentials 401, denied permission 403 where distinguished, oversized input 413, rate limiting 429, and unavailable dependencies 503. The last two use CAPTURE_RETRY_BACKOFF and Retry-After. These failures never claim any event was dispatched. Authentication paths may intentionally treat insufficient credentials as 401.

Ordinary product events are explicitly refused at 125% of the configured product allocation. They are not sampled. Valid identify and $groupidentify controls remain exempt from capacity exhaustion, but still require current authority. An organization whose allocations still need to be chosen is refused with a retryable 503; a usage measurement that has not refreshed never refuses. An exact message_id retry within seven days is not counted again and keeps its original timestamp; reusing a message_id with different content refuses that event alone with invalid_event. A successful ledger cannot establish complete instrumentation or quantify unique loss across retries.

The endpoint and Go, JavaScript, Python, iOS, and Android adapters (including bundled native bridge sources) are implemented in the current pre-production tree. The coordinated cutover still requires complete package and runtime qualification; this is not a claim that published SDK packages implement this response version.

Direct customer-property updates

SDK identify() is the usual profile-update surface. A CRM or batch importer that needs explicit source times, initialization, or deletion can send the complete raw capture wire with an analytics:write key:

{
  "events": [
    {
      "event": "crm_profile_updated",
      "distinct_id": "user_8842",
      "timestamp": "2026-07-30T12:30:00Z",
      "message_id": "crm-contact-8842-v17",
      "properties": {"source": "crm", "revision": 17},
      "set": {"plan": "enterprise", "locale": null},
      "set_once": {"signup_source": "web"},
      "unset": ["legacy_region"]
    }
  ]
}

properties belongs to the named product event and appears in its customer timeline payload. The top-level set, set_once, and unset fields mutate the canonical customer:

  • set stores literal JSON values. null is a present value; it does not delete.
  • unset is the only deletion operation.
  • For set/unset, the greatest (timestamp, message_id) wins independently per key. A lexicographically greater message ID breaks an equal-time tie.
  • set_once is deterministic initialization. The earliest candidate wins until any set/unset exists; an authoritative mutation always supersedes it.

The endpoint defaults an omitted timestamp to receive time and supplies an internal event ID when message_id is absent, but importers should provide both so retries and out-of-order delivery converge predictably. A key may appear in only one mutation field in an event. Empty or duplicate names and more than 250 combined mutation keys return a per-event validation error. unset is rejected on $groupidentify; v1 account properties do not support deletion.

Before an accepted event is published, Anectico recursively replaces detectable PII and credentials in properties, set, and set_once string leaves with [REDACTED], including values inside nested arrays and objects. A field whose separator-delimited or camel-case name ends in a credential concept (for example, initial_password, customer.api-token, or privateKey) has its complete value replaced with one placeholder, including numeric, boolean, null, object, and array values. Substring near-misses such as tokenizer, token_count, passwordless, and public_key remain available.

Run, unit-ID, and correlation properties preserve calendar-valid compact timestamp tokens such as 20260804-195200, even when those digits coincidentally satisfy a card checksum. The exception is token-specific and key-aware: actual card numbers, emails, and credentials in the same value are still redacted, ordinary free-text properties do not receive the exception, and credential-classified properties are always replaced as a whole.

Stable identity and correlation keys (distinct_id, anon_distinct_id, session_id, message_id, group_type, group_key, and the exact top-level properties.$groups map) are not rewritten; they must instead be printable and at most 200 bytes. A nested $groups field receives the normal redaction policy. Property keys are at most 512 bytes, string leaves are at most 32,768 bytes, maps and arrays contain at most 250 entries, and nesting is at most 16 levels. Control characters are rejected throughout. Event names and property keys whose text itself contains detectable sensitive material are rejected rather than renamed. Validation remains best-effort per batch: valid siblings are sanitized and queued while each invalid event receives an indexed refusal, and error and log paths never echo the rejected property content.

The reserved identify event performs the same canonical-person mutation and alias linking but remains a rowless control event. Use an ordinary named event, as above, when the source payload must also be visible in the customer timeline.

Agent inventory and evidence

Every endpoint below requires a project, and the agents:read scope except where a section says otherwise — the two registration endpoints require agents:write instead. They answer five questions about the agents running against your systems.

Fleet health

GET /api/v1/fleet?project_id={projectId}&limit=100&cursor={nextCursor}

This is the server-owned Fleet view: agents, workflows and A2A peers ordered by 24-hour run count, then name and asset id. Each row carries declared or discovered status, environment, last sighting, run and non-completed-run counts, last run, open review-signal count, and optional live-containment metadata. Runs are attributed by stable observed identity rather than display name, so same-named agents remain separate. Each row's agent.observation_keys is the sorted set of immutable observed identities currently attributed to that asset. Use those values for run drill-through; name is a display label and is not a unique selector.

The first page fixes window_start and window_end; every opaque continuation preserves that activity window. limit defaults to 100 and may be at most 200. has_more is derived from the same ordered population as the returned rows, and next_cursor is present only when it is true. total_agents, total_shadow_agents, and the run totals describe the complete measured population, not only the current page. unmatched_runs and unnamed_runs report activity that could not be assigned rather than silently dropping it.

A successful response with total_agents: 0 means the fleet was measured and is empty. If an inventory or activity source cannot be measured, the endpoint returns 503; it never substitutes a healthy-looking empty page. The endpoint requires agents:read. Live-containment metadata is read only with quarantine:request or quarantine:approve; otherwise containment_visibility is FLEET_CONTAINMENT_VISIBILITY_WITHHELD, not "none contained". A fleet request does not accept an environment filter because observed identity already includes the environment boundary.

Service-health pictures

GET /api/v1/services and GET /api/v1/services/{name}/red accept format=json (the default) or format=png, supplied once. PNG uses the identical authorized owning selection, project, environment and resolved window, with one read. The snapshot remains a bounded top list: filters run before sorting and limiting; eligible, matched, returned and omitted service counts stay exact. Explicit cursor and offset parameters, including empty values, are refused.

Service activity is entry spans per second, error rate is a fraction, and snapshot p50 and p95 are milliseconds. Requested and effective windows and the retention floor qualify the picture. Outside retention is unavailable, not measured zero. The current series read lacks effective-window qualification, so requesting its PNG returns a JSON refusal. Ordinary series JSON retains its complete returned set; MCP separately keeps at most its oldest 96 buckets. No latency series is filled from snapshot durations, and no service-health proof URL is created.

A successful PNG has Content-Type: image/png, exactly 960 by 540 pixels and at most 262144 bytes. Both representations have Cache-Control: no-store. Malformed or repeated formats return 400 before the read. Owning authorization and query errors and request cancellation retain their ordinary statuses. Unsupported or invalid presentation returns 422, byte refusal 413, render capacity 429, and render deadline 503; capacity and deadline include Retry-After. Safe JSON refusals carry error: "image_refused", message, browser render_status, optional render_reason, and independent image_outcome: {image_status, image_reason}. The image status is unsupported, too_large or unavailable, with a closed reason such as native_layout_unsupported, render_capacity or render_deadline. An image-only refusal preserves a ready browser projection. No blank success image is returned. Service reads require services:read; rendering adds no scope and no second measurement charge.

Outbound A2A drive

These authenticated endpoints provide the explicit one-message outbound control plane:

PUT  /api/v1/assets/{assetId}/driver
POST /api/v1/assets/{assetId}/drive
GET  /api/v1/agent-drive-attempts/{attemptId}?project_id={projectId}
GET  /api/v1/agent-drive-attempts?request_id={requestId}&project_id={projectId}

The two writes require agents:write; the two attempt reads require agents:read. PUT accepts scope.project_id, endpoint, connection_id, and enabled. Enabling atomically binds an absolute HTTPS endpoint on that agent asset to an active saved A2A bearer credential. Disabling sends nothing and leaves the pair off.

POST .../drive accepts scope.project_id, a required caller-generated request_id (UUID), a required message_text of at most 65,536 UTF-8 bytes, and optional A2A context_id and task_id of at most 1,024 UTF-8 bytes each. It has no endpoint field and rejects unknown fields. The endpoint and selected credential are held stable for the call; redirects are not followed. Calls have a 15-second deadline, a 1 MiB response limit, a 64 KiB limit on the complete JSON-encoded A2A request, and a per-project limit of 10 per minute.

The response carries the SDK-decoded A2A reply_json bytes on success (base64 in the REST JSON representation) and an attempt record in every expected outcome. Not enabled, missing credential, rate limited, invalid target, encoded request too large, agent protocol error, and unreachable are distinct outcome enum values. A reflected saved bearer is discarded as an agent error with no reply bytes or digest. A failure to read or record a prerequisite is an unavailable API response, never one of those refusals. The exact GET returns actor, asset, resolved endpoint, outcome, content digests, and timestamps; it returns neither message content nor credential material.

request_id is the retry key. A repeat with the same request_id in the project sends nothing and returns the recorded attempt with duplicate: true and no reply_json; the same id with a different asset, message, context_id or task_id is a 409. An attempt still DRIVE_OUTCOME_STARTED may or may not have reached the agent. See Drive one registered agent for the complete workflow, response table and retry guidance.

Recorded-person session replay

These endpoints replay a real recorded session against an enabled agent and read its durable, turn-by-turn comparison:

POST /api/v1/assets/{assetId}/session-replays
GET  /api/v1/agent-session-replays/{replayId}?project_id={projectId}
GET  /api/v1/agent-session-replays?request_id={requestId}&project_id={projectId}

The POST requires agents:write and agents:content:read. It accepts scope.project_id, session_key, a required caller-generated request_id (UUID), and confirm_real_person_content_export, which must be true on every request. A repeat with the same request_id exports nothing and returns the recorded replay with duplicate: true; the same id with a different asset or session is a 409. It has no destination, persona, prompt override, or turn-limit field. The recording is limited to eight turns, and each project may start three replays per hour. Turns also consume the outbound drive limit. A recording that cannot be replayed exactly is stopped rather than shortened or rewritten; this includes fragmented content, conflicting identities, a final run not explicitly recorded as completed, and an absent or ambiguous final response.

The result reports the source session, canonical person, target asset, every recorded turn, original and replayed response text, exact comparison details, and total, attempted, sent, and compared counts. AGENT_SESSION_REPLAY_STATUS_COMPLETED means every turn was sent and compared; AGENT_SESSION_REPLAY_STATUS_STOPPED and AGENT_SESSION_REPLAY_STATUS_REFUSED always carry a reason. Target containment and export and storage decisions bind before the first replay-progress record; they are checked again before each send. The person's identity links the result but is not sent to the target agent. A result-recording failure is returned as AGENT_SESSION_REPLAY_STATUS_STOPPED with a stage-specific persistence_*_failed reason, not as a generic availability response.

The GET requires agents:read. Message content additionally requires agents:content:read and the current read policy. When content is withheld, the status, counters, comparison kinds, digests, and sizes remain visible. See Replay a real recorded session for the workflow and result semantics.

Assets — declared and discovered agents

What agents exist, and which ones did you declare?

GET /api/v1/assets?kind=agent&shadow=true
GET /api/v1/assets?kind=tool&related_asset_id={agentAssetId}
GET /api/v1/assets/{assetId}
PUT /api/v1/assets/{assetId}/ownership
POST /api/v1/assets/{assetId}/reviews
PUT  /api/v1/assets/{assetId}/data-classification

kind accepts agent, workflow, tool, model, skill, mcp_server, a2a_agent, model_router, prompt, retrieval_corpus, memory_store, sandbox, gateway, workload_identity and policy.

agent, workflow, tool and model are the four kinds an inventory row can have: every asset is recorded from telemetry, and telemetry records exactly those four. The other eleven names are valid as component kinds in a signed bill of materials (POST /api/v1/abom-manifests), where you declare what an agent depends on, but nothing records them into the inventory. Filtering the inventory on one of them is therefore refused with 400 and a message naming the kind, rather than answered with an empty page that could be mistaken for "none were seen".

model is the model that served a call — a provider plus a model name. model_router is a different thing: whatever chose the model. The two are separate kinds on purpose, and a call whose served model differs from the requested one records the served model without asserting a router.

An asset with shadow: true was created from telemetry rather than from a declaration you made — something ran that you never registered. shadow_evidence_ref points at the observation that created it. Omitting the shadow parameter returns both kinds; passing false returns only the ones you declared. A shadow record's name is evidence of what a workload called itself, never an identity you should authorize against.

related_asset_id narrows to the assets an observation linked to the one you name. It works in both directions from a single parameter: an agent's id lists the workflows, tools and models that agent uses; a tool's or model's id lists the agents using it. The link is recorded only where one span named both sides, so an empty result means "nothing linked them in telemetry", never "unused". An id that is not a UUID is rejected with 400.

kind, lifecycle_state, approval_state and risk_tier may each be repeated or comma-separated. Values inside one parameter are OR-ed and different parameters are AND-ed, so ?kind=agent&approval_state=unreviewed means "unreviewed agents". Omitting a parameter does not narrow on that field, and an unrecognized value is rejected with 400 rather than quietly ignored — so a misspelled filter never widens the page into the whole inventory without saying so. Ordering is newest first by last_seen_at, paging is cursor-based, and the page limit defaults to 100 with a maximum of 200.

Assign or clear the current owner with:

PUT /api/v1/assets/{assetId}/ownership
Content-Type: application/json

{
  "scope": { "project_id": "project-id" },
  "owner_team": "platform",
  "owner_principal": "user:owner-42"
}

Both owner fields are replaced. An empty value clears that assignment, and both empty explicitly leave the asset unassigned. The organization and asset ID come from the authenticated principal and URL; values for either in the body cannot redirect the write.

Record or reopen an approval decision with:

POST /api/v1/assets/{assetId}/reviews
Content-Type: application/json

{
  "scope": { "project_id": "project-id" },
  "approval_state": "APPROVAL_STATE_APPROVED",
  "risk_tier": "RISK_TIER_HIGH",
  "reason": "Matched the reviewed deployment and tool inventory"
}

APPROVAL_STATE_UNREVIEWED, APPROVAL_STATE_APPROVED and APPROVAL_STATE_REJECTED are accepted; APPROVAL_STATE_UNSPECIFIED is rejected. risk_tier is required and is the reviewed blast radius: RISK_TIER_UNASSESSED, RISK_TIER_LOW, RISK_TIER_MEDIUM, RISK_TIER_HIGH or RISK_TIER_CRITICAL. RISK_TIER_UNSPECIFIED is rejected — send RISK_TIER_UNASSESSED to record that the blast radius has not been assessed, which is also the value an asset carries until its first review. The response returns the updated asset with approver_principal, approval_reason, reviewed_at and risk_tier. The approver is derived from the authenticated credential and cannot be supplied in the body, and a reason is required. Reviewing an asset changes its approval decision and risk tier only: approving a quarantined asset does not activate or otherwise un-quarantine it.

This review is the only writer of risk_tier, so ?risk_tier=high returns the assets a reviewer has actually placed in that tier — never an inferred one.

Record what data an asset handles with:

PUT /api/v1/assets/{assetId}/data-classification
Content-Type: application/json

{
  "scope": { "project_id": "project-id" },
  "data_classification": "DATA_CLASSIFICATION_RESTRICTED",
  "reason": "Reads the cardholder corpus"
}

DATA_CLASSIFICATION_UNCLASSIFIED, DATA_CLASSIFICATION_PUBLIC, DATA_CLASSIFICATION_INTERNAL, DATA_CLASSIFICATION_CONFIDENTIAL and DATA_CLASSIFICATION_RESTRICTED are accepted; DATA_CLASSIFICATION_UNSPECIFIED is rejected. Send DATA_CLASSIFICATION_UNCLASSIFIED to record that you looked and found nothing to classify — that is a decision, and it is stored with your name on it. It is also the value every asset carries until somebody classifies it, which is why the response distinguishes the two: an asset nobody has examined reports data_classification: "DATA_CLASSIFICATION_UNCLASSIFIED" with an empty classified_by.

The response returns the updated asset with data_classification, classified_by, classification_reason and classified_at. The classifier is derived from the authenticated credential and cannot be supplied in the body, and a reason is required. Classification is an independent axis: it changes no approval state, risk tier, owner or lifecycle state, and reviewing an asset does not change its classification.

All three writes require agents:write. Reads and filters continue to require agents:read.

These endpoints are scoped by project, not by environment: they take no environment parameter, and supplying one is rejected rather than ignored. Each asset record still reports the environment it was seen in.

What still needs somebody to look at it?

GET /api/v1/review-signals?status=open
GET /api/v1/review-signals?asset_id=...

A review signal is the other half of a discovery. GET /api/v1/assets?shadow=true tells you an undeclared agent is running; the review queue tells you whether anybody has dealt with it — the status (REVIEW_SIGNAL_STATUS_OPEN, REVIEW_SIGNAL_STATUS_ACKNOWLEDGED, REVIEW_SIGNAL_STATUS_RESOLVED, REVIEW_SIGNAL_STATUS_DISMISSED), who resolved it and when, and last_seen_at, which moves forward while the thing is still active.

Each signal names its kind — REVIEW_SIGNAL_KIND_SHADOW_ASSET for something running that you never declared, REVIEW_SIGNAL_KIND_ABOM_DRIFT for a deployment whose configuration in the field no longer matches the manifest that attests it, REVIEW_SIGNAL_KIND_CONFIG_UNREGISTERED for an attested deployment with no registered configuration at all, so nobody can tell whether it drifts, REVIEW_SIGNAL_KIND_KILLED_ASSET_CREDENTIAL when telemetry from an API key resolves to an agent under an active kill containment but that key was outside the containment's approved set, REVIEW_SIGNAL_KIND_POISONED_MEMORY_ITEM when somebody has labelled a memory item quarantined, REVIEW_SIGNAL_KIND_UNREVIEWED_ASSET / REVIEW_SIGNAL_KIND_UNOWNED_ASSET for an asset that has stood in the inventory past the review window with no approval decision, or with neither an owner team nor an owner principal, and REVIEW_SIGNAL_KIND_REVOKED_AUTHORITY_EXERCISED when a mediated action evidenced a delegation edge that had already been revoked — plus a severity, a subject_ref identifying what the signal is about, and a detail_code: a short lowercase code you can group by, never a provider message. For the killed-credential kind, subject_ref is the non-secret API-key id, asset_id is the agent, evidence_ref is the containment id, and opened_at / last_seen_at are the source sighting times.

You do not have to watch this queue to hear about any of these: every kind's open and clear transitions are sent to every enabled notification channel in the organization. You can also create a rule with the review_signals source to target a project, kind, severity, or subject and choose its notification or escalation path. Repeated sightings refresh one signal and do not send another opening.

Every kind clears by itself once somebody deals with it, so a rule filtered on status=open stops without anybody clearing the alert by hand. shadow_asset and unreviewed_asset clear when POST /api/v1/assets/{assetId}/reviews records an approval or a rejection; unowned_asset clears when PUT /api/v1/assets/{assetId}/ownership sets either owner field; a poisoned_memory_item signal clears when the item is labelled trusted again; the drift kinds clear when a matching configuration is registered; and a killed-credential signal clears when the key is revoked or the containment lifted — it still never contains or revokes anything automatically.

kind, status and severity may each be repeated or comma-separated. Values inside one parameter are OR-ed, and different parameters are AND-ed, so ?kind=shadow_asset&status=open means "open shadow-asset signals". Omitting a parameter does not narrow on that field; an unrecognized value is rejected rather than quietly ignored, so a filter that does not apply is never silent. Ordering is newest first by last_seen_at, paging is cursor-based, and the page limit defaults to 100 with a maximum of 200 — the same rules as /assets.

A resolved signal stays in the queue as history. A later sighting of the same still-undeclared agent refreshes an open signal but never reopens one somebody closed.

Which telemetry belongs to which agent?

GET /api/v1/assets/attribution?observation_key=...&observation_key=...
GET /api/v1/assets/{assetId}/attribution

The first direction maps observation keys from your telemetry onto their current asset; the second returns every observation key that currently maps to one asset. The response carries an availability field, so a lookup that could not be answered is distinguishable from one that was answered with no match.

Which versions of an agent has anybody seen?

GET /api/v1/assets/{assetId}/versions?limit=...&cursor=...

Every version observed in telemetry or declared at deployment, newest first, with its content digest and first and last sighting, plus current_version_id naming the one in force. Follow next_cursor for older versions.

Each mapping also carries credentials: which API key produced that telemetry, when it was first and last seen doing so, and how many sightings carried it. This is what turns "quarantine this agent" into something you can act on — the key named here is the one to revoke.

Each entry states its source, and the distinction is the point:

source What it means
api_key An API key produced this telemetry, and api_key_id names it. This is the only entry a revocation can act on.
bearer_token A signed-in session produced it. There is no key to revoke; the control is the person's access.
dev_org_header A development-only trust setting accepted it, with no credential at all. Nothing can revoke it — if you see this outside development, that setting is on where it should not be.

An empty credentials list is not "no credential was used". It means nothing was recorded for that observation key — telemetry that arrived before this was tracked, for instance. Telemetry that provably carried no key is a present entry naming bearer_token or dev_org_header, never an absent one, so "we know there was none" and "we do not know" are never the same answer.

Containing a misbehaving agent

How do I stop it?

GET  /api/v1/quarantines
POST /api/v1/quarantines
GET  /api/v1/quarantines/{quarantineId}
POST /api/v1/quarantines/{quarantineId}/approve
POST /api/v1/quarantines/{quarantineId}/reenforce
POST /api/v1/quarantines/{quarantineId}/lift
POST /api/v1/quarantines/{quarantineId}/withdraw

POST /quarantines requires quarantine:request and asks for an asset, an observation key, or one API key to be freeze_writes (the default), revoke_credentials, or kill — see Permission scopes for what each one does. Send exactly one of asset_id, observation_key, or api_key_id, plus a required reason; an optional environment narrows the target and an optional request_id makes a retried request idempotent instead of opening a second one. A request touches no credential by itself and answers 201 with status: "pending".

POST /quarantines/{quarantineId}/approve requires quarantine:approve and takes no body — the target and action were fixed by the request, and an approver who could edit either would be approving something other than what was reviewed. The platform refuses an approval made by the same principal that made the request. Approving is what resolves the target to its exact credential set and disables it, and answers 200.

POST /quarantines/{quarantineId}/reenforce requires quarantine:approve, takes no body, and re-runs the parts of an active containment recorded as failed — a credential that could not be reached, or a mint ban that could not be installed. It approves nothing: the action, the target and the credential set are the ones the two principals already agreed, and only rows marked failed are re-attempted, because re-issuing a freeze over an already-frozen credential reports a weaker outcome than the one already recorded. The response carries retried, the number of things re-attempted; 0 means there was nothing left to repair and is a success. Use this rather than lifting: a lift restores the credentials and re-opens the mint path, which for a kill re-opens the agent in order to repair the thing meant to keep it shut. Anything that is not active answers 400.

POST /quarantines/{quarantineId}/lift also requires quarantine:approve, takes a required reason, and restores whatever a freeze reduced. It does not restore a revoked credential — revoke_credentials and kill have no undo; mint a replacement key instead. Nor does it free a credential another active quarantine still covers: that credential comes back on the last lift that releases it, and until then it is reported with detail_code held_by_another_quarantine. Two lifts issued at the same moment are safe — whichever of them ends up last still releases the shared credential, so simultaneous lifts cannot leave an agent contained by nothing.

Both lift and withdraw return the reason they were given, as lift_reason and withdraw_reason on the quarantine, alongside lifted_by/lifted_at and withdrawn_by/withdrawn_at. Each is capped at 1024 characters.

POST /quarantines/{quarantineId}/withdraw retracts a request nobody has approved yet, takes a required reason, and answers 200 with status: "withdrawn". It reaches no credential — there was nothing to reach — and its point is that the request stops occupying its target: while a containment is pending, no second one can be raised against the same agent, and a request that is never approved would otherwise block it indefinitely. It is refused with 400 on anything that is not pending; an approved containment ends with a lift, which restores credentials.

The scope it needs depends on whose request it is. Retracting your own takes only quarantine:request, because a pending request enforced nothing and retracting it restores capability to nobody. Retracting somebody else's takes quarantine:approve: it suppresses a containment before a second principal has considered it, which is the same grade of decision as refusing one.

The withdrawn request keeps its request_id. Re-sending that id returns the withdrawn record rather than opening a new containment — the same idempotency that protects a retry — so raising the request again means choosing a new request_id.

Two refusals are worth knowing before you script this. A request_id already used by a different principal answers 409 and tells you nothing about their request — pick another id. And an api_key_id target that names no key in your organization and in the containment's project answers 404 at approval, when the credential is checked, rather than producing a quarantine that claims to have contained it. A key created with org_wide belongs to no project and stays reachable from any of them.

GET /quarantines/{quarantineId} and GET /quarantines (repeatable status, plus limit) accept either scope — a requester has to be able to see the state of their own ask. Both return the same shape: status (pending / active / lifted / withdrawn), the per-credential outcomes under credentials, and unenforceable_sightings — telemetry sightings that carried no credential the platform could act on, which is why a fully-approved quarantine can still report unknown rather than verified for a resolved credential's own enforcement state.

See Contain an agent for what each action actually does to a credential — freezing stops a typical SDK key's telemetry, revoking has no undo, and kill's "no new credential ever again" reading is not something the platform enforces — and for a worked request → approve → verify → lift walkthrough.

Content policy

What may this project's recorded content be used for?

GET /api/v1/content-policies
GET /api/v1/projects/{projectId}/content-policy
PUT /api/v1/projects/{projectId}/content-policy

A project's content policy is a grid: six kinds of recorded content — model_transcript, tool_arguments, replay_dom, person_properties, memory_item, group_properties — against four boundaries — store (recorded at all), read (served to a reader), export (included in a bulk export), judge_transfer (sent to an evaluation model). Each cell is allow, allow_metadata (you may know the content exists and how large it is, never its bytes) or deny.

The two GETs require governance:read and return every cell, denied ones included, so you can render the whole grid without knowing the vocabulary in advance. GET /content-policies returns one policy per project in the organization, newest-changed first, and accepts limit and an opaque cursor; follow next_cursor until it is empty. A credential restricted to one project sees only that project, including on organization-wide policy and audit listings.

A project-scoped credential is pinned to its signed project on every policy, retention, refusal-audit, and erasure route, including routes whose project is written in the path. It cannot select a sibling project by replacing {projectId}. The policy list is narrowed to that same project; an organization-scoped credential retains the organization-wide list and explicit path selection.

Every project has a policy, including one you have never opened, and a project with no policy at all is answered as a fully-denied grid rather than a 404 — "no policy" and "denies everything" are the same state. A policy carries a version that increases on every change, plus updated_by.

PUT requires governance:write and replaces the grid; it does not merge. Send the complete set of cells you want to hold — a cell you omit, and a cell you send as deny, are the same thing. Pass the expected_version you last read to have the write refused with 409 if somebody changed the policy meanwhile; omit it to write unconditionally. An unrecognized class, boundary or decision is refused with 400 rather than dropped, so a typo can never quietly remove a row.

{
  "expected_version": 3,
  "rules": [
    { "content_class": "model_transcript", "sink": "read", "decision": "allow" },
    { "content_class": "model_transcript", "sink": "judge_transfer", "decision": "deny" }
  ]
}

A change takes up to 30 seconds to apply everywhere.

How long is each kind of recorded content kept?

GET /api/v1/projects/{projectId}/content-policy/retention
PUT /api/v1/projects/{projectId}/content-policy/retention

A retention window is a per-kind number of days, using the same six content_class values as the grid above. GET requires governance:read, PUT requires governance:write — the same two permissions, because this is the same control along a different axis.

Unlike the grid, the GET returns only the kinds that have a window. A kind with none is genuinely absent, and that means "kept for as long as the organization keeps it anyway". No default number is synthesized, because a number here is an instruction to delete and you must never be shown one you did not choose.

retention_days is at least 1 and at most 365 — the platform storage ceiling. Your organization’s effective history may be shorter; this setting does not extend it. A window only ever brings deletion FORWARD; a longer value is refused with 400 rather than stored as a promise that cannot be kept. 0 is refused too: it is what an omitted field looks like, and it must never be read as "keep nothing".

PUT replaces the whole set. A kind you omit has its window removed and returns to the default — which is how you undo a tightening, and the reason this is not a PATCH. An empty retention array clears every window. set_by and set_at are response-only and ignored on input.

{
  "retention": [
    { "content_class": "model_transcript", "retention_days": 7 },
    { "content_class": "replay_dom", "retention_days": 30 }
  ]
}

Shortening a window is the one change on this page that cannot be undone: widening a denied cell brings content back, and widening a window brings nothing back. Every change is recorded with what it was before, who made it and when.

A window that has passed is enforced on a schedule, so content can outlive its window by a few hours before the next pass removes it. What the pass removes is the CONTENT, not the record: for transcripts and tool arguments the prompt, completion, arguments and results are erased from the trace and the trace itself — its timing, status, service and token counts — is untouched. The same erasure reaches the other places that kind of content is kept: agent steps, a metric's exemplars and labels, a log line's attributes and resource, an error's tags, extra and request headers, the tags on the issue it rolls up into, and a product event's properties. For session replay the recording is the content and is removed outright. Windows on the two customer-property kinds are accepted and audited and are not yet enforced; they are reported as windows the organization could not act on rather than silently ignored.

What has this project's policy refused?

GET /api/v1/projects/{projectId}/content-policy/access-audit

Requires governance:read. Lists the times this project's policy WITHHELD content, newest first. Optional filters: content_class, sink, principal_id, since, until (RFC3339), limit, and the opaque cursor returned by the preceding page (default 200, maximum 1000). An omitted filter means every value rather than the unset one, so a bare call returns the whole project; an unparseable since or until is 400 rather than silently ignored. Preserve the cursor unchanged with the same filters; next_cursor is empty on the final page. The cursor includes a stable tie-breaker, so refusals sharing one timestamp are not skipped.

{
  "records": [
    {
      "record_id": "9c1f…",
      "scope": { "org_id": "…", "project_id": "…" },
      "content_class": "CONTENT_CLASS_MODEL_TRANSCRIPT",
      "sink": "CONTENT_SINK_EXPORT",
      "decision": "CONTENT_DECISION_DENY",
      "reason": "policy",
      "default_posture": false,
      "principal_kind": "CONTENT_ACCESS_PRINCIPAL_KIND_API_KEY",
      "principal_id": "…",
      "subject_ref": "",
      "occurred_at": "2026-08-28T09:14:02Z",
      "recorded_at": "2026-08-28T09:14:03Z",
      "count": 1
    }
  ],
  "next_cursor": "eyJ…"
}

count is how many identical refusals the entry stands for, and it is 1 for everything a person or a credential did. Refusals taken while recording content are different in kind: that decision is taken once per event on the ingest path, so a project whose policy refuses transcripts would produce one entry per event forever. Those are grouped by the minute, keeping the kind, the boundary and the reason, and count is how many there were. An entry with no count means one. If you are adding up what your policy has refused, add up count rather than counting entries.

reason says whose decision it was, and only one of its values means the control working as you configured it: policy is your own stored decision, no_policy means the organization default answered because this project had not been set up yet, resolver_error means the policy could not be read at all, no_project_scope means the request named no project, and unclassified means the caller could not say what it was sending. default_posture is the same distinction as a flag.

decision is CONTENT_DECISION_DENY or CONTENT_DECISION_ALLOW_METADATA. The second is still a refusal: existence, size and digest travelled and the bytes did not.

subject_ref is present only where the refused call named one person; it is empty for a decision about a payload rather than about a subject. occurred_at is when the decision was taken and recorded_at is when it was written down — they differ by a second or two because recording never delays the request it describes.

This is not an access log. A permitted read writes no entry, because a permitted read is every successful read in the organization. So an empty page means nothing was withheld — never that nothing was read — and this endpoint cannot answer who has viewed a given person's content.

Erasing a person's recorded content

Erase one person's content now

DELETE /api/v1/projects/{projectId}/content/{class}/{subjectRef}

Requires governance:write. Supported classes are model_transcript and tool_arguments; subjectRef is the person's distinct_id inside the project. The body requires a reason and a canonical operation UUID:

{"reason":"Account owner requested removal, ticket 4192","operation_id":"649ebca1-0452-45b7-aad3-29411ad0f6cd"}
{"rows_affected":12,"action_id":"erasure:649ebca1-0452-45b7-aad3-29411ad0f6cd","already_erased":false,"receipt_missing":false,"operation_id":"649ebca1-0452-45b7-aad3-29411ad0f6cd"}

The clear removes that person's class-specific attributes from subject-addressable telemetry and preserves surrounding records and other people's content. The original decision has a durable audit record, including a first zero-row clear. Retrieve its receipt with GET /api/v1/action-receipts/{actionId}.

Exact retries reuse the operation UUID and return the original count and action identity. already_erased means the original operation cleared zero rows; retries do not change it to true. Reusing the UUID with changed scope, subject, class, requester or reason is a 409 conflict. A new deliberate clear requires a new UUID. After durable completion, an exact retry does not clear later arrivals. Pending recovery can clear arrivals before completion.

receipt_missing: true means the physical clear completed but its receipt acknowledgement remains pending. Recovery retries the same frozen receipt without repeating the completed clear. The stable action identity remains available; persistent failures require operator attention.

replay_dom answers 501 here: recordings are selected by age across a project with no per-person selector, so a subject-scoped request is refused rather than deleting every recording in the project. Delete the individual recordings instead, or erase the person, which removes every recording made under their identifiers along with the person. memory_item, person_properties and group_properties are also unsupported here and return 501. An unknown or omitted class is 400; a reason longer than 400 bytes is 400.

Erase a person

DELETE /api/v1/projects/{projectId}/persons/{personId}

Requires persons:erase. personId is the person's id or any one of their identifiers; every identifier of that person is erased. It is matched exactly as sent, after URL decoding: case and a leading or trailing space are significant. The body requires a canonical operation UUID that you generate and a reason of at most 400 bytes:

{"operation_id":"649ebca1-0452-45b7-aad3-29411ad0f6cd","reason":"Request 4192"}
Body field Meaning
operation_id Required. A canonical UUID you generate; reuse it only to retry the same erasure.
reason Required, at most 400 bytes. Stored with the operation; do not put the person's identifiers in it.
allow_unlinked_identity Optional, default false. Set true to erase a personId that names nobody: it is then treated as an identifier, whatever it looks like, what was recorded under it is removed and it is blocked permanently.
{
  "erasure": {
    "operation_id": "649ebca1-0452-45b7-aad3-29411ad0f6cd",
    "project_id": "0c4f6f0e-52b1-4b54-9f5f-3d6c0d0a7e11",
    "person_id": "8a1d5c0e-7b1f-4f0a-9a3e-2f6d4c1b9e77",
    "distinct_id_count": 2,
    "reason": "Request 4192",
    "requested_by": "user:5d0f3b0a-2a61-4f55-8f1e-6f2d9b7c4a10",
    "requested_at": "2026-10-04T12:00:00Z",
    "state": "PERSON_ERASURE_STATE_PENDING",
    "completed_at": null,
    "stores": [
      {"store": "ingestion", "required": true, "state": "PERSON_ERASURE_STATE_PENDING", "acknowledged_at": null},
      {"store": "persons", "required": true, "state": "PERSON_ERASURE_STATE_PENDING", "acknowledged_at": null},
      {"store": "query", "required": true, "state": "PERSON_ERASURE_STATE_PENDING", "acknowledged_at": null}
    ]
  },
  "already_recorded": false
}

This removes the person's profile, identity, product events, errors, logs, traces, agent events, session recordings, diagnostic capture requests, problem reports, recorded agent session replays, account memberships, cohort memberships, experiment assignments and survey records, withdraws the results that counted them, and permanently blocks every one of their identifiers. A few kinds of record are not removed by it; Erase a person lists exactly what is and is not removed. It cannot be undone.

The profile and identity are gone when the response returns. The operation is PENDING at that point: stores lists each part that takes part, persons for the profile and identity, ingestion for product events and for blocking the identifiers, query for errors, logs, traces and agent events, replay for session recordings, errors for diagnostic capture requests and problem reports, a2a for recorded agent session replays, groups for account memberships and analytics for cohort memberships, experiment assignments, survey records and withdrawn results. Each finishes on its own, usually within a few minutes; query always finishes after ingestion, and analytics after persons and ingestion. Read the operation to see when it is complete.

The response never contains the person's identifiers, only how many there were. person_id is empty when personId was an identifier that was never linked to a person and you set allow_unlinked_identity; that identifier is blocked all the same.

Repeating the request with the same operation_id and the same project, personId and reason returns the recorded operation with already_recorded: true and erases nothing further. Reusing the operation_id with anything changed is 409. A missing body, a missing or over-long reason, or an operation_id that is not a canonical UUID is 400. A project key acting outside its own project is 403.

A personId that names nobody (not a person's id, and not an identifier of any person in the project) is 404 with details.reason: "PERSON_ERASURE_IDENTITY_UNLINKED". Nothing was erased and nothing was blocked. Check the spelling first. If you mean to erase what was recorded under an identifier that was never linked to a person, send the request again with "allow_unlinked_identity": true; that blocks the identifier permanently, so do not set it to get past a typo. An identifier that an earlier erasure already blocked is accepted without the field.

Read an erasure

GET /api/v1/projects/{projectId}/person-erasures/{operationId}
GET /api/v1/projects/{projectId}/person-erasures?limit=50&cursor=...

Both require persons:erase. The first returns {"erasure": {...}} in the shape above. The list returns {"erasures": [...], "next_cursor": "..."}, newest first; limit is 1 to 200 (default 50), and next_cursor is empty on the last page.

state is PERSON_ERASURE_STATE_PENDING while required parts are working, PERSON_ERASURE_STATE_STALLED when required work is overdue or receipt delivery needs recovery, and PERSON_ERASURE_STATE_COMPLETE once every entry in stores with required: true is complete. completed_at is null until then, and each entry's acknowledged_at is null until that part has finished. Overdue work is persisted after the expected time (30 minutes by default), even when the profile and identity removal has finished. Its operation has stalled_at, machine stalled_reason: "required_work_overdue", and status_message:

Erasure has not finished everywhere after the expected time. The person's profile and identity are already removed. Keep the operation id and contact support to finish the erasure.

Missing required entries in stores have state: "PERSON_ERASURE_STATE_STALLED", stalled_at and detail_code: "required_work_overdue". Completed entries keep their acknowledgement times. Required parts are saved on the operation and later additions also require confirmation of older work; those operations become Stalled if overdue. An exact request retry keeps the operation and its original time. A late acknowledgement can still complete it. The response's machine store ids stay stable; customer views name the data removed by each part.

A receipt-delivery stall instead uses receipt_notification_retry_exhausted. Delivery stops automatic retries after 20 attempts or 24 hours; its notice remains available for a platform operator to recover.

All four boundaries have enforcement points. Sixteen of the twenty-four cells bind today, and eight cells have nothing to gate. The product marks each cell with which of those states it is in. Decide what recorded content may be used for carries the whole grid and is the page to read before relying on a cell. In short:

  • store and read enforce model_transcript, tool_arguments, replay_dom, person_properties and group_properties. A denial at store is irreversible — the bytes are never written. A denial at read is not: the content stays recorded and a later change serves it again. A replay_dom denial refuses the chunk upload or the playback outright rather than returning an empty recording; a person_properties denial drops the property values from identify() while leaving the identity itself intact, and leaves the property bag off a person read, per person, according to the project that person belongs to.
  • group_properties is independent from person_properties. It defaults to allow at store, read and judge_transfer, preserving the existing account experience, and to deny at export. Its store and read cells enforce the group property bag. Nothing exports that bag and nothing sends it to a model today, so its export and judge_transfer cells have nothing to gate.
  • export applies to model_transcript, tool_arguments, replay_dom and person_properties. Bulk telemetry exports carry model transcripts and tool arguments; content the policy refuses is withheld from the file and the job says which classes and why. See Save and export data.
  • judge_transfer enforces model_transcript and tool_arguments. replay_dom, person_properties, group_properties and memory_item have nothing to gate here, because no model is sent that content. An automatic quality check on a run whose content is not permitted is recorded as withheld by policy rather than scored or silently skipped (Automatic quality checks). A typed evaluation is the place recorded content is sent to a model, and this is the boundary that decides whether it may be.

memory_item has no producer at all, so editing its cells records your intent without changing behavior. Documents pulled in by a retrieval step are recorded as part of the model transcript and are governed by model_transcript.

Two knock-on effects of person_properties: deny at read are worth knowing: people search stops matching on property values for that project (searching by an identifier you sent with identify() still works), and audiences defined by a property value stop refreshing. A people search that names no project takes the narrower behavior whatever your policy says — pass project_id to keep property matching.

read, export, and automatic judge_transfer decisions are independent. An export asks the export cell even when read is denied, and an automatic quality check asks judge_transfer before anything is sent to a model.

Export a person's data

POST /api/v1/projects/{projectId}/person-exports
GET  /api/v1/projects/{projectId}/person-exports
GET  /api/v1/projects/{projectId}/person-exports/{exportId}
GET  /api/v1/projects/{projectId}/person-exports/{exportId}/download
GET  /api/v1/projects/{projectId}/person-exports/{exportId}/content

All five require persons:export and every permission that reads the person's data; see Export a person's data for the list, the archive's layout, what it does not contain and the full contract.

The request body is {"person": "...", "export_id": "..."}. person is the person's id or any one of their identifiers, matched exactly; it is sent in the body, never in the URL. export_id is a UUID you generate, and repeating it returns the export already recorded. The response is {"export": {...}, "already_recorded": false}. The export is built in the background: read it until state is PERSON_EXPORT_STATE_COMPLETE. On the way it is PERSON_EXPORT_STATE_WAITING_FOR_RECENT_DATA for about a minute, with waiting_until, so that the archive holds what was sent just before the request. A complete export carries complete_as_of on every kind that was read (nothing_to_read on one that was not), identifiers_read_at, and a freshness object that says how long it waited and, for the period between capture_accepted_from and capture_accepted_through, what was still being stored.

The list is newest first and accepts person_id, limit (1 to 200, default 50) and cursor; it returns {"exports": [...], "next_cursor": "..."}.

/download returns {"url": "...", "expires_at": "..."}, where url is the /content route as an absolute address. /content streams the archive (one zip) to an authenticated request and supports Range. No response carries one of the person's identifiers.

Conversations

What happened in a conversation?

GET /api/v1/agent-sessions
GET /api/v1/agent-sessions/{sessionKey}
GET /api/v1/agent-turns?session_key=...

A session is one conversation, a turn is one exchange within it, and a run is one execution the agent performed to answer a turn — a turn can span several runs, and run_ids lists them in start order. Sessions are newest-activity-first and cursor-paged; turns require a session_key, because a turn list with no conversation is a scan of everything you own.

A session's turn_count counts only turns that carry an identifier. Telemetry that reports no turn id contributes none, so a session showing 0 turns and non-zero activity means the instrumentation is not reporting turns — not that nothing was said.

GET /agent-sessions/{sessionKey} answers 404 for a session with no activity in scope, rather than an empty body, so "no such conversation" is never confused with "a conversation that did nothing".

Telemetry completeness

How complete is my agent telemetry?

GET /api/v1/telemetry-completeness

Two answers about one set of spans. buckets gives exact row counts per normalizer version and source schema, newest first — deliberately buckets rather than one percentage, because "94% complete" cannot distinguish a fleet that is uniformly current from one where a tenth of your data came from a version behind and means something subtly different. defects gives the telemetry-quality verdicts: seven detectors over the same runs, each an exact count.

kind Counts What it means
PARTIAL_TRACE runs Nothing in the run is a root: the span that started it never arrived, so what you can see is a fragment
ORPHANED_WORK spans A step points at a parent that is not in its run — the work was recorded, the context it belongs to was not
INVALID_TIMING spans A step ends before it starts, or one of its timestamps is the Unix epoch, which is what an exporter that never set the field sends
MISSING_TERMINAL_OUTCOME runs The run never declared how it ended, so its status is inferred. This is the same population as status_explicit: false
ABSENT_SESSION_ATTRIBUTION runs No step carries a session key, so the run cannot be placed in its conversation
ABSENT_PERSON_ATTRIBUTION runs No step carries a distinct id, so the run cannot be attributed to a customer
ABSENT_VERSION_ATTRIBUTION runs No step carries an agent version, so a regression cannot be attributed to the change that caused it

On the wire each value carries its enum prefix — TELEMETRY_DEFECT_KIND_PARTIAL_TRACE, and likewise TELEMETRY_DEFECT_STATUS_* and TELEMETRY_DEFECT_UNIT_* — as in the example below. affected and evaluated are 64-bit counts and are encoded as JSON strings.

All seven are always present, whether they found anything or not, so you can read the list as a checklist without having to guess whether a missing entry means "clean" or "not looked for".

affected: 0 is not automatically good news, and status is what tells you which it is:

  • DETECTED — some of what was examined carries the defect.
  • CLEAN — something was examined and none of it carries the defect.
  • NOT_MEASURED — nothing was examined. The window, the filters, or the agent selector matched no runs, so the zero says nothing about your fleet. Treat this as "ask a different question", never as a pass.

evaluated is the population each count was taken over, and it is why status can say that: affected: 2 out of evaluated: 14 and out of evaluated: 14000 are not the same report. Note the two units — run-level detectors and span-level detectors have different denominators, so compare a count only against the evaluated beside it.

{
  "defects": [
    {
      "kind": "TELEMETRY_DEFECT_KIND_PARTIAL_TRACE",
      "status": "TELEMETRY_DEFECT_STATUS_DETECTED",
      "unit": "TELEMETRY_DEFECT_UNIT_RUN",
      "affected": "1",
      "evaluated": "8"
    }
  ]
}

The window selects runs, not spans: a run that started inside it is examined whole, including steps that began earlier. That is deliberate — narrowing the window must never make a run look like it is missing its first steps.

Each bucket also says what its rows can still be read to mean. Knowing that a tenth of your data came from an older normalizer is only useful next to the answer "and here is which fields of it no longer mean what you would mean". Every bucket carries a resolution:

{
  "normalizer_version": 5,
  "source_schema": "openinference",
  "row_count": "1204",
  "resolution": {
    "reader_version": 6,
    "recognized": true,
    "unresolved": true,
    "unresolved_fields": ["agent_observation_key"],
    "absent_fields": []
  }
}
Field What it tells you
unresolved the verdict. false means these rows can be read exactly as the newest rows are
unresolved_fields a later normalizer changed what these fields mean. The stored values are not comparable with the current ones, and cannot be recomputed — treat them as a different measurement, not a stale one
absent_fields a later normalizer started reading these fields. They are empty in these rows because nothing looked for them, which is not the same as your producer having sent nothing
recognized: false these rows predate the normalizer history this API knows, or were written by a newer one. No claim is made about them at all
ahead the rows are newer than the reader — you are reading during a rollout; ask again shortly
mixed only on aggregates: the rows behind one number were not all written by the same normalizer

The same resolution appears inside provenance on runs, steps and events, so any single result carries its own verdict. A run's is a range: a run whose steps straddled a normalizer change comes back with mixed: true rather than quietly reporting the newer version for all of it. If resolution is absent from a response, that endpoint did not measure it — it never means the row is current.

Two defects have a fix on your side rather than ours. PARTIAL_TRACE and ORPHANED_WORK are almost always a dropped or never-flushed export: check that your exporter flushes on shutdown and that its queue is not silently discarding batches. INVALID_TIMING at the epoch means a span was exported with its timestamps never set.

The agent run graph

What did an agent actually do?

GET /api/v1/agent-runs?agent_observation_key=...&person_id=...&group_type=...&group_key=...
GET /api/v1/agent-runs/{runId}/graph
GET /api/v1/agent-events?run_id=...
GET /api/v1/agent-events?run_id=...&category=context&context_source_id=...
GET /api/v1/agent-events?memory_item_key=...
GET /api/v1/action-receipts?chain_id=...
GET /api/v1/action-receipts/{actionId}

The run list accepts repeated or comma-separated agent_observation_key values and matches any of them. It also accepts a canonical person_id, or an account selector supplied as the pair group_type and group_key; sending only half of that pair is invalid. These are whole-run selectors: when any step carries the selected identity, the response retains every step in that run. Display names are labels and should not be used to distinguish two fleet records.

Page the run list with limit and the opaque cursor returned as next_cursor. Send cursor at most once: repeated values return 400 invalid_request before a query runs, even if identical or empty. An absent or single empty cursor starts the first page; preserve a non-empty cursor unchanged when requesting the next page.

Comparing releases. The run list also accepts release=<version>, which keeps runs whose earliest declared gen_ai.agent.version matches exactly, and without_release=true, which keeps the runs that declared no version at all. The two cannot be combined; sending both is invalid.

Every response — including one with no runs in it — carries release_coverage for the population your other filters select, with the release filter itself removed. It reports run_count, released_run_count and the sorted releases observed, and a state of AGENT_RELEASE_COVERAGE_STATE_NO_RUNS (nothing to measure), AGENT_RELEASE_COVERAGE_STATE_NONE (runs exist, none declared a version), AGENT_RELEASE_COVERAGE_STATE_PARTIAL or AGENT_RELEASE_COVERAGE_STATE_COMPLETE. Read it before you conclude anything from an empty page: a release filter over a project that never sets the attribute returns no runs, and without the coverage beside it that is indistinguishable from a release with nothing wrong. Each run also carries release_declaration, so an empty release is reported as AGENT_RELEASE_DECLARATION_UNDECLARED rather than left ambiguous.

/agent-runs/{runId}/graph returns the run, every step in it, the non-span evidence attached to it, and the action receipts it produced, in one response. Steps are in start order and each carries parent_span_id — that field alone defines the shape of the run; there is no separate edge list to reconcile it against.

The graph's run and steps, and the session and turn reads, report recorded customer identity in person. A nonempty distinct_id whose linkage has not been resolved has resolution: PERSON_RESOLUTION_UNRESOLVED, with person_id repeating that recorded id. PERSON_RESOLUTION_UNSPECIFIED means no identity was recorded and both ids are empty. A child that recorded no identity remains anonymous even when its run has a recorded customer identity.

Every model step also carries a context summary. instrumented: true means at least one valid assembly, compaction, or cache event was accepted for that step; assembled_tokens, overflow, compactions, and cache_outcome are its fold. A model step with no accepted context event still returns the object with instrumented: false, including older rows and rejected-only telemetry. Non-model steps omit it. Never infer instrumentation from zero-valued counters or the normalizer version: an instrumented assembly may measure zero tokens.

Each step also carries outcome and, when it failed, a bounded error_type code — so the graph names which step failed rather than only how many did. outcome is what the step itself declared about its response; it is never derived from a span's status, so OUTCOME_UNSPECIFIED means the step declared no outcome and never that it succeeded. The same two fields are on the agent-facing get_agent_run_graph capability, spelled there as the bare lowercase member (error, ok, unspecified).

error_type is the error.type attribute your instrumentation set on that step — the error class, such as RuntimeError or java.lang.IllegalStateException. It is a code, not a message: values longer than 64 bytes, or carrying anything but letters, digits, ., _, -, : and /, are dropped rather than shortened, because a truncated class name would name an error you never raised. A step's free-text status message is never served here for the same reason.

When a value is dropped, the step says so: error_type_rejection names the reason — too_long for a value over the 64-byte bound, not_a_code for one carrying characters no error class or code does. It is unspecified (and omitted from the MCP capability's JSON) when nothing was dropped, which includes the ordinary case of a step that declared nothing at all.

So an empty error_type has two readings and the sibling field tells you which: with no rejection beside it, the step declared no error class — set error.type on the failing span if you want the graph to name it. With a rejection beside it, your instrumentation is setting the attribute and this platform will not serve the value it sent; send the class rather than the message, and the graph will name it. The two need different fixes, which is why they stopped sharing one answer.

The run and each model step also carry cost_nanos beside a cost_source that says how the number was arrived at — a cost is not readable without it:

  • COST_SOURCE_CLIENT — you reported the charge (anectico.gen_ai.cost_usd, or a per-token price override). A zero here is a measured zero: a free tier, a cached completion, a call that genuinely cost nothing.
  • COST_SOURCE_TABLE — this platform priced the call from token counts and the model's rate.
  • COST_SOURCE_UNPRICED — a priced model call for which no rate is known here. Set a pricing override for the model, or report the cost yourself.
  • COST_SOURCE_CLIENT_INVALID — you reported something that is not a cost: a negative charge, a NaN, an infinity, or a per-token price that is one of those. Each stated per-token price is judged on its own, so an honest output price does not rescue a negative input price, and a negative price applied to zero tokens is still refused. The cost reads 0 because nothing is known about it, and the call is excluded from every spend total — fix the instrumentation that sends the value.
  • COST_SOURCE_MIXED — run only: at least one call has a valid measured CLIENT or TABLE cost and at least one other call has CLIENT_INVALID. The run's cost_nanos is the subtotal of only the valid calls; the refused claim contributes nothing and remains visible regardless of which call happened first. Inspect the model steps for each call's exact source.
  • COST_SOURCE_NONE — the step reported no cost and none was derived.

The distinction between COST_SOURCE_UNPRICED, COST_SOURCE_CLIENT_INVALID, and COST_SOURCE_NONE matters: COST_SOURCE_UNPRICED is a gap here that you close with a pricing override, COST_SOURCE_CLIENT_INVALID is a gap in what your instrumentation sent, and COST_SOURCE_NONE is neither. A refused claim is never served as COST_SOURCE_CLIENT with a zero, because that would be indistinguishable from a call that really was free. At run level provenance is an order-independent fold: COST_SOURCE_MIXED takes priority for valid-plus-invalid calls; otherwise the precedence is COST_SOURCE_CLIENT_INVALID, COST_SOURCE_UNPRICED, COST_SOURCE_CLIENT, COST_SOURCE_TABLE, then COST_SOURCE_NONE. This makes an incomplete subtotal visible while each model step continues to state the exact source of its own cost.

action_receipts is the run's ledger of consequential actions, in the same folded form /action-receipts returns: requester_principal for the principal that asked for it, approver_principal and approved_at for an action that was approved, delegator_chain for the authority it was taken under (outermost first), policy_id and policy_version for the policy it was authorized against, and final_state, where COMPENSATED means the effect was deliberately undone rather than merely completed. None of this is a step — an approval and a compensation are transitions in an append-only chain, not spans — so the graph is where you read what a run was permitted to do, and the step tree is where you read what it did.

Each receipt carries its entries, and each entry carries its own authority. The receipt's authority is the folded value; when you need to know whether Anectico performed an action or merely received a report that it happened, read the entry. An empty action_receipts means the run took no receipted action; a ledger that cannot be read is an error, never an empty list. A run whose ledger is past the size ceiling answers 429, exactly as an oversized step tree does.

The graph is whole or it is refused. There is no page size. A page of a graph is a graph with missing parents or evidence, which is indistinguishable from a genuinely small run. A graph with more than 2,000 evidence events therefore answers 429 instead of returning the first 2,000 as if they were complete; the step tree and the other whole-graph collections have their own ceilings for the same reason. The CLI exits with the request error and the get_agent_run_graph capability returns a tool error, so neither surface emits a plausible partial result. If you hit the ceiling, the run is almost certainly a loop, and /agent-events?run_id=... will page through its evidence.

A run's status is a producer declaration or undeclared; the platform does not turn child errors into a run verdict. status_explicit is true only when the run supplied a trustworthy terminal outcome. error_count remains the number of errored spans and can be non-zero on a successfully recovered run, so use it as step-level evidence rather than as the run result. An undeclared run has an empty terminal_reason.

outcome_vocabulary records how the run can declare its outcome. AGENT_RUN_OUTCOME_VOCABULARY_ANECTICO means it carries the Anectico agent-observability vocabulary, AGENT_RUN_OUTCOME_VOCABULARY_DECLARING_VENDOR means its root agent span came from a recognized framework integration that explicitly sets OpenTelemetry status, and AGENT_RUN_OUTCOME_VOCABULARY_NON_DECLARING means no declaring integration could be identified confidently. The terminal-status and terminal-reason fields are excluded from the Anectico vocabulary test, so a run cannot qualify for an instrumentation-coverage denominator merely by already carrying the outcome being measured. An UNSET OpenTelemetry status remains undeclared in every vocabulary.

A run's release comes from the SDK-declared gen_ai.agent.version. Read release_declaration with it: AGENT_RELEASE_DECLARATION_DECLARED means the value was present, while AGENT_RELEASE_DECLARATION_UNDECLARED means the run was measured and sent none. Agent-run list responses additionally carry release_coverage with the run count, released-run count, sorted declared releases, and one of AGENT_RELEASE_COVERAGE_STATE_NO_RUNS, AGENT_RELEASE_COVERAGE_STATE_NONE, AGENT_RELEASE_COVERAGE_STATE_PARTIAL, or AGENT_RELEASE_COVERAGE_STATE_COMPLETE. In particular, AGENT_RELEASE_COVERAGE_STATE_NONE means runs exist but this population declares no releases; it is not an empty-result guess.

GET /api/v1/evaluation/results returns stored evaluation results. Every result carries a provenance.execution object recording how it came to exist: sampling (SAMPLING_DECISION_SAMPLED, SAMPLING_DECISION_NOT_SAMPLED or SAMPLING_DECISION_WHOLE_DATASET) with sampling_rule_id and sampling_rate present only for SAMPLING_DECISION_SAMPLED; cost_state (COST_STATE_CHARGED, COST_STATE_FREE, COST_STATE_UNPRICED, COST_STATE_UNREPORTED) with charged_cost_nano_usd, input_tokens and output_tokens; judge_model_state (JUDGE_MODEL_STATE_RECORDED, JUDGE_MODEL_STATE_NOT_APPLICABLE, JUDGE_MODEL_STATE_UNREPORTED) with judge_model; and job_status (JOB_STATUS_COMPLETED, JOB_STATUS_PARTIAL, JOB_STATUS_FAILED, JOB_STATUS_CANCELED) with job_failure_class. Read the state before the value in each pair: a charge of zero means a free evaluator under COST_STATE_FREE and an unknown price under COST_STATE_UNPRICED, and judge_model is the model version that answered, recorded on each result because an evaluator version names no model. JOB_STATUS_FAILED and JOB_STATUS_CANCELED appear only on the immediate reply to POST /api/v1/evaluation/evaluate, where the score exists and its storage is still owed; no stored result carries them, and a release gate refuses both. A result written before this record existed returns empty values here and is refused by release gates for the same reason a result with no evaluator version is.

GET /api/v1/evaluation/release-series attributes one exact evaluator version and metric to those observed releases. It requires evaluator_id, positive evaluator_version, and metric_name; accepts optional inclusive RFC3339 start and end subject-time bounds; and returns declared releases oldest first with counts, means, and the delta from the preceding release. If every identity is semantic versioning the order uses semantic precedence; otherwise the entire series uses first-observed time, and ordering_strategy states which rule won. Explicitly unreleased results appear in a separate aggregate instead of disappearing or acquiring an invented release identity.

A retired dataset version. Every dataset version in a response carries state: DATASET_VERSION_STATE_ACTIVE, or DATASET_VERSION_STATE_RETIRED with retirement_cause (DATASET_VERSION_RETIREMENT_CAUSE_PERSON_ERASED) and retired_at when one of its cases was the run of a person you have since erased. Every experiment carries state: EXPERIMENT_STATE_ACTIVE, or EXPERIMENT_STATE_INVALIDATED with invalidation_cause and invalidated_at when its dataset version was retired. A call that would use a retired version or an invalidated experiment is 400 with details.reason: "EVALUATION_DATASET_VERSION_RETIRED": GET /api/v1/evaluation/datasets/{datasetId}/versions/{version}/items, a replay of one of its cases, POST /api/v1/evaluation/experiments over it, and /trials, /run and /comparison on an experiment pinned to it, and a release-gate evaluation that reads such an experiment. The state is permanent, so do not retry: cut a new version (a promotion, or a removal; POST /api/v1/evaluation/datasets/{datasetId}/removals with an empty item_ids is accepted only for a dataset whose current version is retired and seals the cases that are left) and create a new experiment over it. See evaluation datasets.

counts.handoffs is measured. A step is counted as a handoff when your instrumentation declared anectico.agent.handoff.target on it, so a 0 here means no handoff was recorded in that run — it is an answer, not a gap. The count reads the declared target and not the step's operation: recording a handoff on your agent-run span annotates that span and does not rename it, so the step — and the run — keep CANONICAL_OPERATION_INVOKE_AGENT and are still counted here.

There are TWO guardrail counts and they are different numbers. counts.guardrail_checks counts every step on which a guardrail ran — one your instrumentation named, or one reported by a framework that says a guardrail ran and nothing more — counted once however it qualified. Recording a guardrail on your agent-run span leaves that step categorized as agent work and still counts it here. counts.guardrail_decisions counts only the steps whose instrumentation declared anectico.guardrail.name. The gap between them is how much of your guardrail surface reports what it decided.

A step carries a guardrail object when it declared that name. It reports name, the guardrail's own result, a finding_class, the policy_id and policy_version in force, and the decision the policy reached — one of allow, deny, modify, escalate. A step with no guardrail object means the guardrail half was not instrumented; it never means the guardrail passed. A decision of GUARDRAIL_DECISION_UNSPECIFIED means the same thing about the decision alone, and is never a synonym for allow. Every field is bounded and a value outside its bound is refused rather than truncated — see Agent span attributes for the exact shapes and for the reason result and decision are two fields.

counts.a2a_calls and a step's a2a object report Agent-to-Agent protocol work: the method, and the task_id and context_id when the call carried them. They are populated only when your own agents emit the a2a.* attributes; Anectico does not emit them, so a step without those attributes has no a2a object and the count stays 0.

counts.approvals is still always 0. Nothing reported on a step expresses an approval, so that zero means "not reported", never "none happened". Every other count is measured.

A step carries a handoff object when it declared a handoff to another agent. It reports the target — the agent the work was handed to, exactly as you declared it — and an optional reason. Both are bounded: the target must be spelled as an identifier (letters, digits and . _ - : /, up to 128 bytes) and the reason must be a short lowercase code such as specialist_required, never a sentence. A value outside either bound is refused rather than truncated, and the field simply does not appear. A reason declared with no target records nothing at all: there is no handoff for it to annotate.

A handoff does not change the step's category. The span is still the agent invocation it was, so it keeps OPERATION_CATEGORY_AGENT and continues to count as one; what changes is canonical_operation, which becomes CANONICAL_OPERATION_HANDOFF.

Downstream application steps are classified. A step that is not agent work but carries stable OpenTelemetry evidence of an HTTP request, a database query, an RPC or a messaging operation — one of http.request.method, db.system.name (or the older db.system), rpc.system or messaging.system — comes back with category: OPERATION_CATEGORY_APPLICATION. This is what lets one run's graph span the boundary between the agent and the services it called, with parentage intact.

OPERATION_CATEGORY_UNSPECIFIED is a distinct answer and not a weaker version of that one: it means the step carried none of those markers and nothing else classified it either. Read it as "not classified", never as "ordinary application work" — connection details such as server.address or url.full are deliberately not enough, because they appear on model calls too.

Model steps carry requested_model and served_model separately, so a provider that serves a different build than you asked for is visible. Both are empty on runs recorded before this was captured; that means unknown, not that they matched.

Model steps also report streamed and stream_abandoned. stream_abandoned is true when your own code stopped consuming a streaming response before it ended — a break, a close, a cancelled request. That is deliberately not an error and is never counted as one, because a caller stopping early is ordinary control flow and booking it as a failure would raise alerts on code that is working. What it does tell you is that the token counts on that call are the provider's last reported values and are partial by construction. Both fields are false when nothing was reported about streaming at all, so read a false as "not stated" unless you know your instrumentation sends it.

Tool steps carry mcp_session_id and mcp_protocol_version when the step was an MCP call, so a misbehaving tool can be traced back to the session and the protocol version it ran under.

A tool step's arguments and result describe recorded tool content without disclosing it. Each is present only when something was recorded about that channel, and each carries a sha256, a byte_size, a disposition and a truncated flag — never the value itself. disposition: FULL means the content is held; disposition: ABSENT with a non-zero byte_size means it was captured by your application and not kept, which is what your project's tool_arguments content policy decides. The absence of the field entirely means the tool call recorded nothing. byte_size is the size before any truncation your SDK applied, so truncated: true tells you the held value is a prefix of a larger one rather than the whole answer.

See Memory provenance and quarantine for the concept behind the fields below — item identity, the two trust labels, and what a quarantine opens — before the exact shapes here.

Memory steps carry a memory object when your instrumentation declared which item a memory operation touched. It reports the store the operation targeted, a bounded memory_operation, an item_key, an item_provenance and a declared_trust_label.

item_key is derived by the platform from your declared item id together with your organization, project and store — the declared id itself is never stored or returned. That derivation is what makes the same item recognizable across two conversations, which is the question the field exists to answer: pass it as GET /api/v1/agent-events?memory_item_key=... to get every recorded read and write of that one item. The filter is a complete selector on its own — you do not need a run or a session beside it — and an item nobody has recorded returns an empty page rather than an error.

No memory item content is stored or returned, by this or any other endpoint. item_provenance is a bounded reference — a trace id, a trace/span pair, or a sha256: digest — and a value in any other shape is refused rather than kept, so nothing free-form reaches the field. declared_trust_label is what your own instrumentation asserted about the item, and is one of trusted, suspect or quarantined. It is reported separately from trust_label, which is the label established through review — the one you set with the endpoints below. The two are never folded together: your own assertion cannot become the reviewed judgement, or a poisoned item could declare itself clean.

trust_label is the reviewed label that was in force at the moment that row happened, not the item's label now. An item read while it was still trusted and quarantined an hour later reports trusted on that read and quarantined on everything after the decision — which is what lets you say which conversations carried an item before anybody knew it was bad. trust_label_revision names the exact judgement that applied, so you can read its reason and its author out of the label history below, and trust_label_in_force_since is when that judgement was recorded.

An empty trust_label means nobody had judged the item when that row happened, and it means nothing else: trust_label_revision is 0 in exactly that case (revisions start at 1), and if the reviewed label cannot be read at all, the whole request fails rather than returning memory rows without their labels. A memory row is never disclosed bare.

Agent memory

Reviewing a memory item

PUT  /api/v1/memory-items/{itemKey}/label
GET  /api/v1/memory-items/{itemKey}/label
GET  /api/v1/memory-item-labels?memory_item_key=...&label=quarantined

{itemKey} is the item_key above. The PUT body carries label — MEMORY_TRUST_LABEL_TRUSTED, MEMORY_TRUST_LABEL_SUSPECT or MEMORY_TRUST_LABEL_QUARANTINED — and a reason. Both are required: an omitted label is refused rather than treated as "no opinion", and a decision with no reason is not reviewable. The PUT takes agents:write; both reads take agents:read.

You do not say who is labelling. The reviewer identity and the timestamp are taken from the credential that made the request and returned as labeled_by and labeled_at; a value you send for them is not accepted. A request made with a machine credential that carries no user or key identity is refused, and so is one made on the platform's own behalf: labelling a memory item is a decision a person makes.

The history is append-only. Relabelling adds a revision rather than replacing one, so GET /api/v1/memory-item-labels?memory_item_key=... returns every judgement ever recorded about the item, newest first, each with its own revision, reason, labeled_by and labeled_at — which is what lets you ask who trusted an item before an incident. GET /api/v1/memory-items/{itemKey}/label returns the current one, the highest revision. An item nobody has labelled is a 404, never a 200 carrying an empty label: "nobody has looked at this" and "somebody looked and said nothing" are different answers.

Labelling an item quarantined opens exactly one review signal of kind poisoned_memory_item, whose subject_ref is the item key; labelling it trusted again resolves that same signal. A second quarantined refreshes the open signal rather than opening another, and suspect leaves an open signal open, because a downgrade is an unfinished review rather than an all-clear. The response says which happened, in review_signal_opened and review_signal_resolved. You may label an item before it has been recorded anywhere: containment often runs ahead of the next session.

A label records a judgement; it contains nothing by itself. Anectico does not scan memory for poisoning and does not stop your agent reading its own store — nothing here revokes a credential or blocks a call.

Where an item went, and under what label

GET /api/v1/memory-items/{itemKey}/propagation?limit=200

This is the read a poisoning question actually needs: every recorded place one item was written or read, oldest first, each carrying the label that was in force at that moment. It takes agents:read.

Each entry reports the session_key, turn_id, run_id, span_id and occurred_at of one place the item was, the canonical_operation that touched it, the store, the item_provenance and your own declared_trust_label — plus label_state, label_in_force, label_revision, label_id and label_in_force_since. distinct_session_count is how many separate conversations the item reached: one means it never left its own, and two or more is the cross-session spread the endpoint exists to make visible.

label_in_force is historical, not current. It is the judgement that had been recorded when that entry happened — so an item read while it was still trusted and quarantined an hour later reports trusted on that entry, and quarantined on anything after the decision. That is the point: it lets you say which conversations carried an item before anybody knew it was bad. The item's label now rides once on the response, as current_label and current_label_state.

label_state is MEMORY_LABEL_STATE_LABELED or MEMORY_LABEL_STATE_UNLABELED, and the second one is a real answer rather than a blank: nobody had judged the item at that point. An entry never carries an empty label word instead.

An item that was never recorded anywhere and was never labelled is a 404. An item you labelled before it was ever observed returns the label with no entries — containment often runs ahead of the next session. If more entries exist than limit returned, truncated is true; a partly-read flow must not be mistaken for the whole of it. limit defaults to 200 and caps at 500.

If the label cannot be read, the whole request fails rather than returning entries without their labels: an item disclosed without its quarantine label is exactly the answer this endpoint must never give.

The memory object is absent whenever your instrumentation declared no item, which is the common case: the OpenTelemetry conventions standardise the memory operations and define nothing about item identity, so most frameworks emit the operation alone. Read that absence as the item is not instrumented — the step still reports the memory operation and it still counts in counts.memory_ops. It never means no memory was read. Declaring anectico.memory.item.id, gen_ai.data_source.id, anectico.memory.item.provenance and anectico.memory.trust_label on your memory spans is what fills it.

A step's outcome is filled only when the telemetry stated one for that response. completed, failed and cancelled become OUTCOME_OK, OUTCOME_ERROR and OUTCOME_CANCELLED. A response reported as incomplete, queued or in_progress leaves outcome unset and carries the stated value in that step's attributes, under gen_ai.response.status: none of the three means success or failure, and labelling them either way would be a guess. A step whose telemetry stated no status leaves outcome unset too, and a step's span status is never used to fill it — "something in this span errored" is not the same claim as "this response failed". Step outcomes belong to the run graph; /agent-events does not report an event-level outcome.

/agent-events returns step-level evidence — tool calls, retrievals, safety checks — anchored on a run, a session, an agent identity plus a bounded window, or a window no wider than 24 hours. A request with no anchor is rejected rather than silently scanning. Ordering is newest first and paging is cursor-based. Its category and canonical_operation filters, and /action-receipts' authority and final_state, follow the same rule as /assets: repeatable, OR-within and AND-across, and an unrecognized value is rejected rather than producing a page that is quietly the wrong one.

Context events use category=context and carry exactly one typed context detail:

  • assembly: budgets, assembled tokens, overflow and assembly digest, plus the ordered bounded source metadata;
  • compaction: trigger and strategy, before and after counts and digests, removed source IDs and the server-derived ratio;
  • cache: provider, key digest, outcome and optional reuse, latency and cost savings.

context_source_id is a lineage narrowing filter over an assembly's ordered source IDs. It does not count as an anchor by itself: supply run_id (or another ordinary bounded event anchor) too. The raw span-event attribute map is never returned. Optional numeric members are omitted when the producer omitted them, while a measured zero is returned as zero. That rule applies independently to assembly counts, each source's tokens and position, compaction counts and ratio, cache reuse, latency and savings, and a model step's assembled-token summary. The step summary's instrumented bit remains the authoritative distinction between an uninstrumented step and one carrying an accepted context event.

/action-receipts also accepts repeated normalizer_versions, which narrows the page to receipts a given normalizer version produced — the filter to reach for when you are reproducing an earlier answer and need the population pinned. Unlike authority and final_state it is an open number rather than a closed vocabulary, so a version this deployment has never produced is a legitimate question with an empty answer rather than a 400. Omit it for every version.

The singular normalizer_version spelling is also accepted on /action-receipts. Repeated and comma-separated values work with either spelling; when both are supplied, their values are combined. Zero is a valid version. Empty or whitespace-only lists behave like omission. Malformed values are dropped when at least one valid unsigned integer remains; an entirely malformed nonempty list is rejected with 400.

outcome is not an agent-event field. The evidence an event is derived from does not carry an outcome of its own, and this API will not infer one — a safety check that denied inside an otherwise successful step must not be labelled as having succeeded. The deprecated ?outcome= selector is therefore rejected instead of returning an empty page that could be mistaken for "none happened". Filter on category or canonical_operation instead, and use the containing run's status (/agent-runs/{runId}) when you need success or failure.

category accepts only values telemetry can report. Thirteen categories are derived today: model, agent, workflow, planning, tool, mcp, retrieval, memory, safety, evaluation, context, application and a2a. Any other value is refused with 400 rather than answered with an empty page, so an empty page for a category always means "none happened". Approvals, authority changes and compensations are not categories at all: they are action receipts, returned by GET /api/v1/agent-runs/{runId}/graph under action_receipts, and served in full by /authority-edges and /action-receipts — both of which carry more than a span category could.

GET /api/v1/action-receipts requires exactly one non-empty selector: action_id for one action's receipts or chain_id for a chain's receipts. Supplying both returns 400 invalid_request with exactly one of chain_id and action_id is required; neither selector is silently discarded. Each selector may appear at most once, including empty or identical repeats; repeats return 400 invalid_request with action_id and chain_id must each be provided at most once. A single empty companion parameter counts as absent. Omitting both selectors returns 400 InvalidArgument with exactly one of chain_id and action_id is required.

An action receipt is the fold of an action's lifecycle, not one row: its entries array is the complete sequence of transitions, and final_state is the state of the last one. authority bounds what the receipt claims — RECEIPT_AUTHORITY_MEDIATED means the action ran through this platform, RECEIPT_AUTHORITY_REPORTED means an agent told us it acted and the receipt attests only to what was received and when. Each entry carries its own authority as well, so a reader never has to infer which lines are whose claim; one chain holds both kinds, and an action's authority cannot change halfway through its life.

requester_principal is the principal that asked for the action — a bounded identifier such as user:..., apikey:... or a platform service name. It is taken from the first entry that carries one, because the requester is part of what the action is and is declared when the action opens; later transitions carry the field and may leave it empty, and taking the last would blank the name of the principal the action was authorized for. Each entry also carries its own requester_principal, so you can see which transition each name was declared on rather than inferring it from the fold.

If the entries name more than one requester, the receipt reports the first and lists every distinct name in conflicting_requester_principals. That list is empty in the ordinary case. It exists because nothing prevents a chain from carrying two, and showing one principal's name over another principal's transition in silence would be the more comfortable answer and the wrong one.

effect_class folds to the WIDEST reach any transition declared, on the order EFFECT_CLASS_NONE < EFFECT_CLASS_INTERNAL < EFFECT_CLASS_EXTERNAL. A chain normally opens with an INTENDED step that makes no claim about reach — nothing has happened yet — and that omission is recorded as EFFECT_CLASS_NONE, so a receipt that took the first declaration would report NONE for every action however far it later reached. A later transition can therefore raise the receipt's declared reach and can never lower it, which is the same rule destructive follows: under-reporting what an action touched is the dangerous direction. Each entry still carries the class it was written with, so you can see which transition made the claim.

Ordering: the list is by your clock, a single receipt is by ours. occurred_at is a value you send on every entry, and GET /api/v1/action-receipts orders by it, newest first — that is the question a page of receipts answers, and the paging cursor is built on it. GET /api/v1/action-receipts?chain_id=... without an action_id asks a different question, "the chain's most recent receipt", and answers it in the order this platform sequenced the entries in. The two can disagree, and when they do the single-answer read is the one that cannot be moved by a future-dated occurred_at.

approver_principal and approved_at come from the AUTHORIZED transition, and only from that one. A receipt whose chain holds no AUTHORIZED entry reports both as empty, whatever else its entries carry — that is deliberate, and it is what stops an agent's own account of itself from supplying an approver for an action nobody approved. So an action that genuinely required a second person's sign-off records that sign-off as its own transition, and you read it from the fold.

A containment is the worked example. Approving one writes two entries in one chain: AUTHORIZED, carrying the approver and the moment they signed, then QUARANTINED, carrying the outcome the enforcement reported in its detail. Both entries name the requester, so the receipt answers "who asked" and "who approved" as two different principals — which is what the sign-off rule required. The lifecycle position is still the last transition, so final_state is QUARANTINED; the approval does not become the state, it becomes the authority. The same AUTHORIZED transition is what puts the action into /authority-edges, so a contained agent is answerable to "who authorized this, for what purpose" like every other mediated action.

policy_id and policy_version name the rule the action was decided under. On a RECEIPT_AUTHORITY_MEDIATED receipt — one this platform performed or gated — they are always present, and they are drawn from a fixed vocabulary of the form <domain>:<rule> with a rule version of the form v1:

policy_id The decision it records
mcp:write_gateway_authorization The scope and confirmation check applied to a consequential write made through the MCP endpoint — the rule behind both AUTHORIZED and DENIED on those actions
agents:containment_approval The second-principal approval a containment requires before an agent's credentials are frozen or revoked
content:retention_window The stored per-class retention window, enforced by the scheduled erasure pass
content:subject_erasure An on-demand erasure of one subject's content, decided by a person rather than by a schedule

The pair is folded from the first transition that carries it, and every transition of a platform-decided action carries the same pair — so a single entry read on its own still answers "under what rule". The version moves only when what the rule decides changes, which is what makes two receipts written months apart comparable.

A platform-decided action carries one policy record for its whole lifecycle. A transition naming a different rule — or a different version of the same rule — is refused with 400 when it is appended, because the chain is append-only and a second record, once written, could never be taken back: the receipt would assert one rule while the entries underneath it named two, and every entry digest would still verify.

If the entries do name more than one, the receipt reports the first and lists every distinct record in conflicting_policies, rendered <id>@<version> in sequence order. That list is empty in the ordinary case, and it is the same promise conflicting_requester_principals makes: a disagreement you can see beats a first-wins answer you cannot.

A policy record is one value. policy_id and policy_version are set together or not at all, on every receipt including a RECEIPT_AUTHORITY_REPORTED one — a transition carrying only one of them is refused with 400, naming which field was sent alone. Two entries each carrying half a record are not a record: joining one entry's id to another's version would report a rule neither of them declared. So the receipt's policy_id/policy_version come from a single entry that named both, they are empty when no entry did, conflicting_policies lists complete records only, and each entry carrying half is named in malformed_policy_records as seq <n>: <field> without <the other field>. That list is empty in the ordinary case and can only be populated by entries written before this rule.

On a RECEIPT_AUTHORITY_REPORTED receipt the two fields are yours: send whatever identifies the policy your own system applied, in whatever spelling it uses. They are free text there, bounded only by length, and this platform neither validates nor interprets them — including the one-record rule, which binds only the receipts this platform writes. Two of your own spellings on one action are reported in conflicting_policies rather than refused. The pair rule above does bind them: free text is still one value in two fields, and sending an id with no version, or a version with no id, is refused.

Receipts written before 2026-08-28 carry no policy on the platform's own actions. The chain is append-only and the entry digest covers these fields, so those rows cannot acquire one — an empty policy_id on an older RECEIPT_AUTHORITY_MEDIATED receipt means "written before the platform recorded this", never "decided by nothing".

compensated and rolled_back are not synonyms, and the fold keeps them apart. Compensation says a second action was performed to offset the first: the original effect happened and still stands. A rollback says the first action was reversed at its source: there is nothing left to offset. The receipt reports the newest of the two in remediation, separately from final_state — an action rolled back and then quarantined has final_state: RECEIPT_STATE_QUARANTINED, and "was the effect undone" is still yes. A rollback does not retract a verification either: the effect was verified, and what changed is that it no longer stands.

Both are refused (400) on an action whose entries hold no EXECUTED, EFFECT_VERIFIED or EFFECT_UNKNOWN step. An action that was only intended, or denied, or failed has no effect that either sentence could be true of.

verification is a three-state answer, not a boolean. attempted: false means nobody checked; attempted: true, verified: false means a check ran and could not confirm the effect, and method names the checker. An unconfirmable effect is a result, not a missing field — treat an EFFECT_UNKNOWN step as "nobody can say", never as failure.

Action receipts

Recording what your own agent did

POST /api/v1/action-receipts/entries

Requires agents:write. One call records one lifecycle step — intended, requested, executed, failed, effect-verified, effect-unknown, compensated, rolled-back, and the rest — not a whole receipt; steps sharing an action_id fold into one. On the wire each step is its full name, RECEIPT_STATE_ prefixed (RECEIPT_STATE_EFFECT_VERIFIED); see the spelling note below. Send the action_id, the state, the occurred_at, and whatever the step knows: the tool, the effect class, argument and intent digests, the purpose, the delegation chain, the session and turn, the resource URIs.

Two fields are derived and rejected on input. receipt_authority follows from your credential — your entries are recorded as RECEIPT_AUTHORITY_REPORTED, and you cannot ask for RECEIPT_AUTHORITY_MEDIATED. chain_id is ours to name: you have one action chain per project, and a caller who could start a fresh chain per action would have a chain that is tamper-evident about nothing.

Some action_id names are reserved. An id beginning quarantine:, erasure:, gateway: or redaction: names an action this platform performed on its own authority, and an entry of yours under one is rejected with 400. The reason is not tidiness: an action has exactly one authority for its whole life, so an entry of yours opening one of those ids first would make the platform's own record of the same action a conflict — and the containment, erasure or gateway call would have happened with nothing in the ledger saying who performed it. Every other action_id is yours to choose, including ids that merely contain those words; only the prefixes are reserved.

Those prefixes are also how you read such a receipt. A containment's receipt is GET /api/v1/action-receipts/quarantine:{quarantineId} — the quarantine id under its namespace, not the bare id, which names no action and answers 404. The colon is an ordinary character in a path segment: send it as-is or percent-encoded, both reach the same receipt.

Spell state and effect_class in full, in a request body. They are closed vocabularies and the body takes the full name — "state": "RECEIPT_STATE_EXECUTED", "effect_class": "EFFECT_CLASS_EXTERNAL" — which is also exactly how every response renders them, so a value you read back can be sent again unchanged. The query filters on GET /api/v1/action-receipts take the short lowercase form instead (?final_state=effect_verified), because a filter is a URL and a body is a document. That asymmetry is the one thing about these fields worth remembering: "effect_class": "external" in a body is not the external class, it is a value this API does not define.

A value outside either vocabulary is rejected with 400, and the error names the field and lists what it will accept. It is never quietly recorded as something else. That matters more here than on an ordinary endpoint: a receipt is an append-only record of what an action did, effect_class is where a reader establishes whether the action reached anything outside your systems, and a spelling we answered with a guess would put a confident, unfalsifiable "no external effect" on a row that might have been a destructive external write. Until 2026-08-21 an unrecognized effect_class did exactly that, and receipts written through this endpoint before then cannot be distinguished afterwards from ones that meant EFFECT_CLASS_NONE — see below.

Omitting effect_class is not the same as sending an unusable one. Leave the field out and you are making no claim about the effect; the entry records EFFECT_CLASS_NONE, which is the documented default, and that is a deliberate answer to a deliberate silence. Send a spelling we cannot read and the call fails, because there is no honest default for a claim nobody can interpret. state has no default at all — it is required, and an omitted one is rejected.

Say EFFECT_UNKNOWN when you could not confirm the effect, and use EFFECT_VERIFIED only with an effect_verify_method naming how you checked — a verification that names no verifier is rejected, because nobody reading it later could judge it. effect_verify_method and detail_code are short lowercase identifiers (^[a-z][a-z0-9_]{0,63}$), not sentences: send provider_readback, never a provider's error text. Both are grouped over in the product, and a free string there is unbounded cardinality plus whatever the provider decided to echo back at you.

Send digests, never content: arguments_sha256 and intent_sha256 are hashes, and a receipt is audit evidence retained for 400 days. Never put prompt text, tool arguments or retrieved documents in one.

A receipt may name a person in three places, and they mean different things:

  • distinct_id — the person the action was done for: the end user your agent was acting on behalf of. Product analytics reads it: a FAILED action you report with a distinct_id becomes an $agent_tool_failed event for that person.
  • subject_refs — who the action was about, when that is not the person it was done for: for example the customer whose record an operator's tool deleted. Up to eight {"kind": "person" | "distinct_id" | "content_subject", "value": "..."} pairs. It is never read by product analytics and is not returned by a receipt read.
  • a subject query parameter on one of its resource_uris.

Put the identifier in one of those places and nowhere else. Erasing that person later removes what the entry said about them — distinct_id, subject_refs, purpose, resource_uris, session_key, turn_id, arguments_sha256, intent_sha256 and idempotency_key_sha256 — and finds the entry by exactly those three places. An identifier written into the wording of purpose, or into action_id, is not found and stays. The entry itself is never removed or altered, and the chain still verifies: it covers a fingerprint of each of those values that cannot be matched to the value once the erasure has run.

The response carries seq, prev_sha256, entry_sha256, recorded_at and redacted. redacted: true means the entry named an identifier that had already been erased: it was recorded, and those values were not kept.

A receipt read reports erased values as withheld. redacted_fields on a receipt lists which of person, purpose, resource_uris, arguments_sha256 and intent_sha256 are empty because they were erased rather than because nobody sent them, redacted_entry_count says how many of its entries were affected, and each such entry has redacted: true.

Events carry a content digest and size, never the content itself. Prompts, completions and tool payloads are a separate permission.

Deployments and configuration provenance

What did you deploy, and what configuration is it running?

POST /api/v1/deployments
POST /api/v1/config-snapshots
POST /api/v1/abom-manifests
GET  /api/v1/config-snapshots/{configHash}

The three POSTs require agents:write, which is owner or admin only to mint; the GET is an ordinary agents:read. This is the registration path a release pipeline uses, and it exists because the information is not in your telemetry: every span, run, turn and receipt carries an effective_config_hash, and nothing carries the configuration that hash stands for. Only the system that resolved it can say. Register both and drift detection has something to compare; register neither and it silently has nothing to say about any deployment.

POST /api/v1/deployments declares where one asset version is running. Send the asset, the environment, your own external_ref for the slot (a release id, a workload name) and who deployed it. The platform assigns the deployment_id and returns it — a supplied one is rejected, because a chosen id could point a configuration or a manifest at a record you do not own — and created tells you whether this was a new slot or a re-registration of an existing one. Re-registering the same (environment, asset, external_ref) updates that slot rather than creating a second. Set retired_at when the deployment goes away; a retired deployment stops being watched for drift.

POST /api/v1/config-snapshots declares one effective configuration: the effective_config_hash your telemetry already stamps, plus what it stands for — enabled tools, enabled MCP servers, flag variants, and a small resolved map of metadata — and the deployment and ABOM manifest it belongs to. A hash is immutable. Re-registering the same hash with a different composition is refused with 409, because that hash is stamped on rows already written and one hash naming two configurations would make all of them ambiguous. A changed configuration has a new hash. Re-registering the same hash with the same composition is a repeat sighting and succeeds. Fields are identifiers and are bounded: an oversized value, or one carrying a newline, is rejected rather than truncated — this is metadata storage, not a place to send prose or prompt text.

POST /api/v1/abom-manifests declares the composition of one asset version — its tools, models, prompts, MCP servers and skills, each as an identifier, a version and a content hash. The platform canonicalizes exactly what you sent, signs it, stores it immutably and returns the signed artifact: the canonical bytes, their SHA-256, the signature and the id of the key that made it. The manifest id is assigned here and is what a configuration references.

Send release too, and send the same string your agents put on their spans as gen_ai.agent.version. It is the label this bill of materials is for, it goes inside the signed bytes, and it is what lets anyone holding an exported manifest check that it is the composition for the release that actually ran — rather than for some other release of the same agent. Two releases of one unchanged composition are then two artifacts with two signatures, which is the point: without it, one signed document would serve every release and a label beside the signature could be retyped by anyone.

release is optional and explicitly so. Omit it and the signed bytes say "release": null — "this producer named no release" — which is a different statement from "release": "", and the two canonicalize differently on purpose. If you are not tagging releases yet, omit the field; do not send an empty string to mean the same thing.

Two things about this endpoint are worth stating plainly, because a signature invites a stronger reading than it earns. What you send is what is signed, so canonical_form, manifest_sha256, signature, signing_key_id and authority are all rejected on input rather than ignored. And a manifest you declare is recorded with authority: "declared" — inside the signed bytes, not beside them. The signature then proves what you declared, under your organization, at that instant, and that it has not changed since. It does not assert that the components are accurate; nobody but you can know that. authority: "mediated" is reserved for a composition the platform itself supplied, so a verifier reading only the exported artifact can always tell the two apart. Only components, versions and hashes belong here — a manifest is exportable, so never put prompt text, tool arguments or retrieved documents in one.

GET /api/v1/abom-manifests/{manifestId} returns one stored manifest and a verdict on it: valid checks the digest, current byte contract, indexed authority and release and signing key agreement, and the Ed25519 signature against the published key in the ABOM domain. defect names a failed check: canonical_form_superseded, canonical_form_unreadable, digest_mismatch, column_body_mismatch, signature_invalid, signing_key_unavailable or signature_algorithm_unsupported. Retained published keys still verify after rotation. The manifest is returned either way with HTTP 200, including when valid is false. MCP reports an invalid verdict with status: error; CLI prints the complete verdict and exits 1. The read selects a manifest by its project and id; the signed body supplies its environment. Verify independently when you need to check the server's assertion.

canonical_form_superseded means the manifest was signed under an earlier version of the byte contract: its signature is still a true statement about the bytes it covers, and those bytes are no longer a body this platform would produce for that manifest. Sign a fresh manifest for the asset version and reference that one going forward. This is what you will see for anything signed before release joined the signed body.

canonical_form_unreadable and digest_mismatch both mean the stored artifact is damaged rather than merely old: the signed bytes cannot be parsed, or the digest recorded beside them is not the digest of those bytes. Preserve the returned artifact and report the defect. Signing a new declaration does not repair the retained artifact.

column_body_mismatch is about the record beside the artifact rather than the artifact itself. So that manifests can be filtered and listed, authority, release and the signing key id are also held outside the signed bytes; this defect says an indexed value disagrees. The values reported to you are always the ones inside the signature — the signed bytes are the artifact, and nothing outside them can change what a manifest declares — so the manifest you read is still the one that was signed, and its signature still verifies. The defect is telling you that the searchable copy has drifted and that filters over it may have missed this manifest. Report it to us; nothing on your side produces it.

GET /api/v1/config-snapshots/{configHash} resolves a hash back to what it stands for, and returns every deployment that has ever run it. It is the one endpoint in this section that honors ?environment=, and it must: the same configuration registered in staging and in production is two records, so a lookup that ignored the environment would answer about one with the other. An unregistered hash answers 404.

Each entry in deployments[] carries the validity interval of that binding: first_seen_at is when the deployment started running this configuration, and superseded_at is when it moved to another one. An absent superseded_at means the binding is current. Resolving an action to the bill of materials that was actually in force when it ran means picking the entry whose interval contains the action's own timestamp — not the newest entry, and not the current one. A deployment that was upgraded and later moved back to this configuration appears once per period it ran it.

This is what makes the hash on an old trace, run, turn or receipt still answerable. Upgrading a deployment to a new configuration does not erase what the previous one referenced, so deployments[] for a configuration nothing runs any more is a list of closed intervals rather than an empty array.

Verification and tamper evidence

Has the record been tampered with?

GET  /api/v1/receipt-chains/{chainId}/verify
POST /api/v1/receipt-chains/{chainId}/checkpoints
GET  /api/v1/abom-manifests/{manifestId}

The verify response splits entry_count into mediated_entry_count and reported_entry_count, which sum to it. "The chain verifies" is a statement about integrity and says nothing about who vouches for what is in it. It also says where the check started (verified_from_seq, with anchor_entry_sha256 and the signed prune_anchor): entries before that point were removed for age and are not covered by the verdict. And it says how many entries had personal values erased (redacted_entry_count) and how many are the ledger's own statements of such erasures (redaction_statement_count). fault_count reports the total number of faults found, zero when the chain is valid. Neither erasure count is a fault; an entry whose values are missing with nothing in the chain accounting for it is one (redaction_unaccounted). See Verify attestations.

POST .../checkpoints is an ordinary agents:read and returns a signed assertion that at this instant the chain held exactly n entries ending in a given digest. Keep it. A hash chain is tamper-evident against modification and reordering and silent about deletion of its tail; a checkpoint you retained is what closes that, because it is not in our database and the key's private half never leaves the platform. A chain that does not currently verify is not checkpointed — signing a state already known to be faulty would make it look attested.

See Verify attestations for what these prove, what they do not, and how to check a signature yourself. The short version for a manifest: the signature proves that these exact bytes were declared, by whom, and when — and the authority inside those bytes is what tells a reader whether the composition came from you or from the platform. It is never a claim that the composition is correct.

Credentials and their grant history

Who holds a key, and who issued it?

GET  /api/v1/account/api-keys
POST /api/v1/account/api-keys
PATCH /api/v1/account/api-keys/{id}
DELETE /api/v1/account/api-keys/{id}

Creating a key without project_id binds it to the organization's default project (or the first available project when none is named default). If that project cannot be resolved, creation is refused; omission never creates an organization-wide key. Set org_wide: true explicitly to create an organization-wide key. A project-scoped key cannot set org_wide, select a sibling project, or omit its way around its signed project boundary.

Native creation supplies purpose (browser_capture, mobile_capture, server_capture or native_ingest), its single write scope and an optional source_id; see native intake keys. Optional idempotency_key (1–128 printable ASCII characters without spaces) makes creation durably unique to the authenticated actor and organization. Keep the same resolved project and normalized creation arguments on retries. A same-key and different-request retry returns 409. Only the initial response returns the plaintext secret; a retry returns current metadata, replayed: true and an empty plaintext_key, even after key expiry or revocation. No retry reactivates a key. An absent retry key preserves ordinary create-per-call behavior. New keys permit no expiry or a future expires_at at most 365 days away; an exact retry can carry the original timestamp after expiry. Fresh authorization and project and agent registry validation still apply.

Every key in the listing names the principal that minted it. user_id is the person who issued it; created_by_api_key_id is the key that issued it, when a key issued a key. created_by_state says which of those applies, so an empty user_id is never ambiguous:

created_by_state What it means
user user_id names the person who issued the key
api_key created_by_api_key_id names the key that issued it
system The platform issued it; there is no attributable principal
orphaned_user A person issued it and their record is no longer available, so the attribution was lost. The key is still valid
unknown The key predates this field

What was this key ever allowed to do?

GET /api/v1/account/audit-events?target_api_key_id={id}

target_api_key_id returns one key's whole grant history — issued, scopes changed, revoked — newest first, and never events belonging to any other key. It is matched against the key that was changed, never the key that did the changing, so a key that administers other keys does not collect their history.

Each event carries the scope set in metadata:

  • scopes — the set that stands after the act, as a comma-separated list. On an issue it is what was granted; on a revocation it is what the credential could do at the moment it was revoked, which is readable nowhere else afterwards. The whole set is returned however large it is, so a key holding everything a role grants reads back in full. An empty value means the act granted nothing, which is different from the field being absent.
  • previous_scopes — the set a change replaced. It is present on a scope change and on a freeze, where it is the authority the containment removed while scopes is what the key is left with; a rename replaces nothing and carries neither.

event_type filters the taxonomy — api_key_created, api_key_updated, api_key_revoked, membership_role_changed — and composes with target_api_key_id, so "every scope change to this key" is one request. resource=api_key selects credential events including their API mutations.

Revocations always name their actor. A revocation that follows a project or organization deletion records the platform as the actor and names the deletion that caused it under metadata.via.

What did each agent do?

GET /api/v1/agent-activity?since=&from=&to=&project_id=&credential_kind=&credential_id=&agent_asset_id=&owner_user_id=&outcome=&refusal=&class=&surface=&operation=&skill=&object_kind=&object_id=&page_size=&page_token=
GET /api/v1/agent-activity/summary?since=&from=&to=&project_id=

The agent activity record has one row for every call an agent made to Anectico through MCP, the CLI or this API, reads included, with its outcome. An agent is a credential: an API key that reads or manages, or an approved connection. See See what your agents did for what is and is not recorded.

Both routes require activity:read. With that scope alone a person sees their own keys and connections, and a key or a connection sees only its own calls. With audit:read as well the answer covers the workspace. visibility in the response is own or workspace. A key bound to one project reads that project's calls only. A filter never widens this: a credential_id you may not see returns the same empty list as one that does not exist.

A row lists an object only when the caller holds the read scope for that kind of object. The rest are counted in objects_withheld, and withheld_object_scopes names the scopes that would show them. Filtering by object_kind needs the read scope for that kind.

The list is newest first and cursor-paged: page_size is 1 to 200 (default 50), and next_page_token goes back unchanged as page_token with the same filters. A page is not a total.

  • since is a look-back such as 30m, 24h or 7d. from and to are RFC 3339 times; from is inclusive and to is exclusive. Give since or from, not both. With neither, the list reads your plan's whole audit history (30 days on Free and Solo, 90 on Pro, 180 on Scale) and the summary reads the last 24 hours. window in the response is the window that was read.
  • outcome is ok, previewed, partial, refused, error, canceled or unknown. refusal is permission, plan, validation, not_found, conflict, rate_limited, not_ready or budget. class is read, write, destructive or export. surface is mcp, cli or rest. credential_kind is api_key or oauth_connection. A value outside these lists is refused with 400; it is never read as "no filter".
  • operation is an exact tool or action name, or a method and route pattern such as GET /api/v1/issues/{id}.
  • object_kind and object_id together select the calls that named one object. Ids of persons and accounts are never stored, so such a search is refused with 400.
  • owner_user_id selects one person's agents. Naming another person without audit:read is refused with 403.
  • project_id selects one project. Add include_workspace_calls=true to also get the calls that addressed no project.

Each record carries id, occurred_at, project_id, agent (credential_kind, credential_id, label, and when known oauth_client_id, agent_asset_id and owner_user_id), surface, operation, class, outcome, refusal, duration_ms, objects (each a kind and an id), objects_omitted, missing_scopes, skills with skill_basis, and unrecorded_reads_before with unrecorded_since. objects_omitted counts the objects a call named whose ids are not stored. missing_scopes lists the permissions a call refused for a permission lacked (empty when the refusal named none). skills and skill_basis are set only on a skill report: the Anectico CLI said it installed, upgraded or removed those skills. A report is not skill use, and skill filters the list to the rows that name one skill. unrecorded_reads_before is the number of earlier read calls by the same agent that were answered and could not be recorded.

The summary returns one entry for each agent seen in the window, most recent first, with calls, refused, errors, writes, unknown, unrecorded_reads and last_seen_at. Every number counts stored rows. unrecorded_reads is not part of calls. At most 200 agents are returned; more_agents says when the list was cut. skills counts the skill reports of the window, one entry for each skill and action: reports, agents and last_reported_at.

Which proof pages did people open?

GET /api/v1/proof-opens?since=&from=&to=&project_id=&top=

The record of proof-page opens: a signed-in person opened a read-only proof page in the Console. The answer has opens and, for the window, by_kind, by_day (every UTC day of the window, with zero where nothing was recorded), top_objects and join. join compares the objects the agents you can see were given with the opens that followed; its rule states the matching rule, and the numbers count objects, not links followed. not_measured lists what the opens cannot see.

The route requires activity:read. The counts name nobody. With audit:read as well, openers_shown is true, openers lists the people who opened with their user ids, and join covers every agent in the workspace; visibility is own or workspace. An object id is listed only for a reader who holds the read scope for that kind of object; the rest are counted in top_objects_withheld. The default window is 7 days; the audit history is 30 days on Free and Solo, 90 on Pro, 180 on Scale. top is 1 to 25 (default 10). A key bound to one project reads that project only. See See what your agents did.

The Console reports an open with one request, which only a signed-in person's session can make. An API key or a connection is refused with 403:

POST /api/v1/projects/{projectId}/proof-opens
{ "kind": "issue", "object_id": "<id in the link>" }

kind is person, issue, trace, agent-run, incident, dashboard, replay or result. A result page also sends result_mode: result, comparison, definition or audience. The answer is 202 with recorded and a disposition of recorded, duplicate or rate_limited. The same person opening the same object in the same ten-minute period counts once.

The CLI reports the skill files it changed with one request, which only an API key or a connection can make. It is sent by anectico skill install, upgrade and remove:

POST /api/v1/agent-activity/skills
{ "action": "install", "skills": ["anectico-cli"] }

action is install, upgrade or remove. A skill name outside the Anectico skills is dropped and counted in ignored.

A request that can change something is refused with 503 and the error activity_record_unavailable when its activity row cannot be written. Nothing was changed. Retry after the Retry-After time.

Ingestion sources and pipelines

An ingestion source is the intake credential surface a native capture or OTLP key binds to (source_id on account API keys); a pipeline is an optional, immutable-revision transform program attached to a native_ingest source. Every route below is scoped to one project and requires ingestion:pipelines:read; the mutating ones also require ingestion:pipelines:write. See manage ingestion sources and pipelines for the concepts and permission scopes for who can hold each scope.

GET  /api/v1/projects/{projectId}/ingestion/sources?include_revoked=&limit=&page_token=
POST /api/v1/projects/{projectId}/ingestion/sources
GET  /api/v1/projects/{projectId}/ingestion/sources/{sourceId}
PUT  /api/v1/projects/{projectId}/ingestion/sources/{sourceId}
POST /api/v1/projects/{projectId}/ingestion/sources/{sourceId}/revoke
POST /api/v1/projects/{projectId}/ingestion/sources/{sourceId}/pipeline/validate
GET  /api/v1/projects/{projectId}/ingestion/sources/{sourceId}/pipeline/revisions?limit=&page_token=
POST /api/v1/projects/{projectId}/ingestion/sources/{sourceId}/pipeline/revisions
GET  /api/v1/projects/{projectId}/ingestion/sources/{sourceId}/pipeline/revisions/{revision}
POST /api/v1/projects/{projectId}/ingestion/sources/{sourceId}/pipeline/revisions/{revision}/preview
POST /api/v1/projects/{projectId}/ingestion/sources/{sourceId}/pipeline/preview
GET  /api/v1/projects/{projectId}/ingestion/sources/{sourceId}/pipeline/diagnostics?window_hours=
POST /api/v1/projects/{projectId}/ingestion/sources/{sourceId}/pipeline/activate
GET  /api/v1/projects/{projectId}/ingestion/sources/{sourceId}/operations?limit=&page_token=
GET  /api/v1/projects/{projectId}/ingestion/sources/{sourceId}/operations/{operationId}
GET  /api/v1/projects/{projectId}/ingestion/sources/{sourceId}/collection-health?window_minutes=

Every response is complete JSON with every field present (an unset nested object is null, never omitted); snake_case names; 64-bit counters (revision, catalog_epoch, active_pipeline_revision and every operation's from/to_epoch and from/target_pipeline_revision) are decimal strings; enums are their full name, such as INGRESS_CREDENTIAL_PURPOSE_NATIVE_INGEST, SOURCE_OPERATION_STATE_DRAINING or PIPELINE_REVISION_STATUS_ACTIVE.

  • POST .../sources creates a source for one purpose (browser_capture, mobile_capture, server_capture or native_ingest), name and optional description. Purpose and project are fixed for the source's life. A required idempotency_key makes retries safe: the identical key returns 200 with replayed: true and the original source instead of creating a second one; the same key with a different body is 409.
  • PUT .../sources/{sourceId} replaces name/description and requires the exact current expected_revision; a stale value is 409.
  • POST .../sources/{sourceId}/revoke is immediate and permanent. It returns the updated source, a DRAINING operation, and already_revoked (true on a repeat call, which is otherwise a no-op). Every key bound to the source stops authenticating at once. The response for that refusal is the same across every intake transport: capture returns 403 with header X-Anectico-Refusal-Reason: INGRESS_SOURCE_REVOKED; OTLP over HTTP returns 403 with the message intake source revoked; OTLP over gRPC returns PermissionDenied with the same message. A project's default source (used implicitly by a key that omits source_id) is never recreated once revoked.
  • pipeline/validate is a dry run that saves no program: it returns valid, structural diagnostics (step, code, message), the program's program_digest and schema_version. Pipelines exist only for native_ingest sources; every other purpose returns 400 failed_precondition (pipelines-unsupported).
  • POST .../pipeline/revisions stores an immutable revision from the same validated program document. An identical program returns 200 with existing: true and the prior revision instead of creating a duplicate; a structurally invalid one is still stored as a non-activatable draft (PIPELINE_REVISION_STATUS_DRAFT) carrying its diagnostics.
  • pipeline/revisions/{revision}/preview and pipeline/preview dry-run 1–10 structured sample records — {"body", "severity_text", "attributes", "resource"}, at most 16 KiB encoded each (not a total across samples) — through, respectively, a stored revision or an unsaved program document, with the same compiler, runtime, redaction and limits intake uses. Samples and outputs are never stored or logged. A response is {"state":"PIPELINE_PREVIEW_STATE_COMPLETE","results":[...]} with one result per sample in order — outcome (PIPELINE_RECORD_OUTCOME_UNCHANGED, _TRANSFORMED, _DROPPED or _REFUSED), reason_code (empty, TRANSFORM_DROPPED, or a TRANSFORM_* refusal code), step (the zero-based step that dropped or refused it, or -1) and output (the record intake would store; null for a dropped or refused sample) — or {"state":"PIPELINE_PREVIEW_STATE_INVALID_PROGRAM","diagnostics":[...]} when the program itself does not validate, in which case nothing ran. 400 for a bad sample body, an unknown severity_text (TRANSFORM_SAMPLE_INVALID), 0 or more than 10 samples or any sample over 16 KiB (TRANSFORM_SAMPLE_LIMIT), providing both or neither of a stored revision and an unsaved program, or an unsupported source purpose (failed_precondition).
  • pipeline/diagnostics?window_hours= reports one project's native-intake pipeline accounting over a recent window (1–168 hours, default 24, hourly buckets): per active revision, accepted, transformed, dropped and refused counts and their reasons (TRANSFORM_DROPPED, a TRANSFORM_* refusal code, or ALLOCATION_SAMPLED), newest revision first, plus freshness — the epoch and revision the most recently counted admission pinned, whether that epoch is the source's current one, and the roughly-15-second accounting_delay before a count becomes readable. freshness is null when nothing was admitted in the window. Counts only, never record content; 400 for an out-of-range window_hours, failed_precondition for an unsupported source purpose.
  • pipeline/activate moves the source's live pipeline to revision (an earlier revision is a rollback; 0 removes the active pipeline) and requires the exact current expected_catalog_epoch — a stale value is 409. It returns the updated source, a DRAINING operation and already_active. The operation reports DRAINING for roughly fifteen seconds and reads back COMPLETE on any later read once that drain elapses; nothing else needs to be polled to advance it.
  • operations lists and reads the activation, rollback, and revocation history for one source, newest first, with kind (ACTIVATE, ROLLBACK, REVOKE), state (DRAINING, COMPLETE, FAILED) and, on failure, failure_code.
  • collection-health?window_minutes= classifies whether a native_ingest source's collector is delivering, over a recent window (1–1440 minutes, default 15): state is one of COLLECTION_HEALTH_STATE_NO_SIGNALS, _AUTHENTICATION_REJECTED, _QUEUED, _PARSER_REFUSAL or _VERIFIED, with every condition that holds in conditions (most urgent first) and a plain-language explanation. It reports stored signals (signal, record_count, last_received_at) per telemetry family; the collector's own last-reported collector queue state (null when it sent no report in the window). Signals and collector reports count retained records by arrival in [window_start, window_end), regardless of their event timestamps. Collector counter increases use reports received in the two minutes before window_start as their baseline, or zero without one. It also reports pipeline_evidence, pipeline_refused_count and pipeline_refusal_reasons from the source's transform pipeline; and credentials (evidence, bound_key_count, usable_key_count) — how many keys bound to the source can still authenticate. evidence fields are COLLECTION_EVIDENCE_AVAILABLE, _WITHHELD (the caller lacks api_key:read), _UNAVAILABLE (the owning read did not answer) or _TRUNCATED (more keys are bound than one read examines) — metadata and counts only, never telemetry content. 400 for a malformed window_minutes, failed_precondition for an unsupported source purpose.

Every route returns 400 for a malformed request (including an invalid enum, a pipeline op on an unsupported purpose, or a not-yet-validated program), 403 for a missing scope or a project mismatch, 404 for an unknown source, revision or operation, 409 for a stale compare-and-swap value or a reused idempotency and retry key against a different body, 429 for rate limits, and 503 when the source catalog is not yet configured.

First-application setup draft

The saved setup draft of a project's first application: how the setup is driven, what is being instrumented, the step reached and the test identifiers the setup sends. It lets a setup resume on another device. It is not proof that instrumentation works. Reading requires settings:read; saving requires settings:write.

GET /api/v1/projects/{projectId}/onboarding-setup
PUT /api/v1/projects/{projectId}/onboarding-setup

GET returns {"setup": {...}}, or 404 when the project has no draft. PUT takes {"setup": {...}} and replaces the saved draft entirely:

Field Value
host claude, codex, cli or manual
stack node, browser, python, go, otlp or mobile
step 0 to 3
person, error_message, event_name Required test identifiers, at most 255 characters each
trace_id The test trace found so far; may be empty
started_at Required, RFC 3339
updated_at Returned only: when the draft was last saved

An unknown field or an invalid value returns 400, and nothing else can be stored through these routes. A project API key reads and saves its own project's draft and receives 404 for another.

Browser capture settings

What a project's browsers may collect automatically, including the switch that turns autocapture off without redeploying the site. Reading requires ingestion:pipelines:read; updating also requires ingestion:pipelines:write. See browser capture settings for the fields, defaults and how long a change takes to reach browsers.

GET /api/v1/projects/{projectId}/capture-settings
PUT /api/v1/projects/{projectId}/capture-settings

PUT replaces the whole autocapture section and takes the expected_revision you last read; a stale revision is refused with 409. A project that has never been configured reads as revision 0 with autocapture disabled.

The SDK reads the same settings from the capture host with its own capture key:

GET /api/v1/capture/config

It accepts a browser or mobile capture key, answers 403 to a server capture key and 401 to any other credential, and supports If-None-Match with ETag.

Log-derived metric rules

A rule counts matching logs, or sums one finite numeric log field, into fixed event-time buckets; see Derive a metric from your logs for the concepts. Every route is scoped to one project and requires metrics:read; the mutating ones also require ingestion:pipelines:write.

GET    /api/v1/projects/{projectId}/log-metrics
POST   /api/v1/projects/{projectId}/log-metrics
POST   /api/v1/projects/{projectId}/log-metrics/validate
GET    /api/v1/projects/{projectId}/log-metrics/{ruleId}
DELETE /api/v1/projects/{projectId}/log-metrics/{ruleId}
GET    /api/v1/projects/{projectId}/log-metrics/{ruleId}/revisions
POST   /api/v1/projects/{projectId}/log-metrics/{ruleId}/revisions
POST   /api/v1/projects/{projectId}/log-metrics/{ruleId}/query

Every response is complete JSON with every field present; snake_case names; enums are their full name, such as LOG_METRIC_AGGREGATION_COUNT, LOG_METRIC_AGGREGATION_SUM or LOG_METRIC_FRESHNESS_STATE_CURRENT. An unknown field in a request body is refused with 400 rather than silently ignored.

  • POST .../log-metrics takes spec (a LogMetricRuleSpec) and idempotency_key. Use preview_only: true to review the rule without creating it. After approval, submit the same specification and retry key with preview_only: false; creation requires that retry key. Identical retries return the committed rule. replay_only: true reads that committed creation or returns not found. The route supplies the project. The specification contains metric_name, description, aggregation, unit, bucket_seconds (60, 300, 600, 900, 1800, 3600 or 86400), and a definition (match, value_field, labels, max_series, late_cutoff_seconds, source_id, enabled). metric_name, aggregation, unit and bucket_seconds are fixed for the rule's life. For example:

    {
      "idempotency_key": "checkout-error-count-v1",
      "preview_only": true,
      "spec": {
        "metric_name": "checkout.errors",
        "aggregation": "LOG_METRIC_AGGREGATION_COUNT",
        "unit": "1",
        "bucket_seconds": 60,
        "definition": {
          "match": [
            {
              "field": "severity",
              "equals": "error"
            }
          ],
          "labels": [
            "attributes.http.route"
          ],
          "max_series": 100,
          "enabled": true
        }
      }
    }
  • .../log-metrics/validate takes only the LogMetricRuleSpec itself and saves no metric rule; it returns {"valid": bool, "diagnostics": [{"code", "field", "message"}]} — the exact diagnostics a create or revise would refuse with. Stable code values: INVALID_RULE, INVALID_FIELD, INVALID_REGEX, DERIVED_LABEL_LIMIT, DERIVED_LABEL_FORBIDDEN, DERIVED_CARDINALITY_LIMIT, INVALID_BUCKET_WIDTH, DERIVED_LATE_CUTOFF_LIMIT, INVALID_UNIT, INVALID_METRIC_NAME.

  • .../log-metrics/{ruleId}/revisions (POST) appends an immutable revision: exactly one of a new definition or a rollback_to_revision, plus the rule's exact current expected_revision and an optional new description. It applies to logs received after it takes effect (10 seconds after the save); logs already received keep the revision they were counted under. A stale expected_revision is 409; a rollback_to_revision that does not exist is 400.

  • .../log-metrics/{ruleId} (DELETE) stops deriving anything new from the rule; its series stop being readable. A repeat delete of an already-deleted rule is 404.

  • .../log-metrics/{ruleId}/query (POST) reads the published series: {"time_range": {"start", "end"}, "step", "group_by": [...], "filters": [{"key", "values"}]}. time_range defaults to the last 24 hours (at most 400 bucket windows); step must be a whole multiple of the rule's bucket width (empty uses the bucket width itself), and the range must cover at most 2000 steps. group_by keeps only the listed label keys, summing the rest away; filters keeps only series whose label value is one of the listed ones (at most 4 filters). The response carries series (labels, points: [{"timestamp", "value"}]) and a freshness object: state (_CURRENT, _REBUILD_PENDING or _INVALIDATED), watermark, complete_through, windows_in_range, windows_served, windows_rebuild_pending, windows_withheld, late_count and series_overflowed. A value is always a per-bucket count or sum, never a cumulative counter.

Every route returns 400 for a malformed request or an unsupported step, 403 for a missing scope or a project mismatch, 404 for an unknown rule, 409 for a stale expected_revision or a metric name already used live in the project, and 429 once a project holds 50 live rules or a rule 200 revisions.

Approval for product definition changes

Every management write to experiments, flags, cohorts, account segments, surveys, dashboards, widgets, report subscriptions, saved insights and derived audiences first returns HTTP 200 with the server's mutation evidence envelope. No definition is changed on that call. structuredContent.outcome is preview; structuredContent.preview carries the confirmation token and complete details.before, details.proposed and details.required_scopes. Stored customer text is marked as untrusted data. Server-assigned creation IDs and commit timestamps are listed separately because they are assigned at commit.

Review both states and every named scope. Repeat identical path, query, body, retry key and version arguments, adding X-Anectico-Confirm-Token: TOKEN (or confirm_token in the body). Approval expires after minutes and is bound to the exact arguments and previewed state; concurrent edits or changed source membership require a new preview. The confirmed response uses the same envelope with outcome applied. A creation retry with the same actor, action and idempotency key returns replayed and the original creation result without another mutation. Ordinary resource deletion preserves that key; workspace or project deletion revokes access and removes its receipts. Changed arguments under a key conflict with 409.

Creates and report runs require idempotency_key, an opaque string of 1–128 bytes, supplied in body or Idempotency-Key. Flags and segments require exact expected_updated_at for changes; cohort definition changes also require expected_definition_version and expected_generation. Refresh uses both cohort versions; static member replacement uses expected_generation and shows complete canonical person sets. Experiments, surveys, saved insights and reports use the exact current revision. Dashboard and widget changes and dashboard cloning use the dashboard's aggregate expected_version. Every checked source scope appears in the preview. REST requires these domain and source scopes; the MCP gateway additionally requires its read or write transport scopes.

anectico commands print this server preview and expose --confirm-token; a local confirmation flag does not replace server approval. Product mutation bodies are strict single JSON objects bounded to 4 MiB. Definition-specific bounds still apply. Authenticated delivery and measurement endpoints and the public email unsubscribe confirmation are separate from management writes.

Product experiments

Two fixed 50/50 arms, application-server assigned and measured, one fixed-horizon result. See Product experiments for the full lifecycle, launch admission codes and how a result is read; this section is the wire contract. Unrelated to AI Evaluation experiments (/evaluation/experiments above) — never mix the two.

Management routes are project-path-scoped; the organization always comes from the caller's principal:

GET  /api/v1/projects/{projectId}/experiments
POST /api/v1/projects/{projectId}/experiments
GET  /api/v1/projects/{projectId}/experiments/{key}
PUT  /api/v1/projects/{projectId}/experiments/{key}/draft
POST /api/v1/projects/{projectId}/experiments/{key}/launch-preview
POST /api/v1/projects/{projectId}/experiments/{key}/launch
POST /api/v1/projects/{projectId}/experiments/{key}/stop
GET  /api/v1/projects/{projectId}/experiments/{key}/counts
GET  /api/v1/projects/{projectId}/experiments/{key}/result

Delivery routes carry no project or organization at all — both come from the APPLICATION key presented as X-API-Key (see Authentication):

POST /api/v1/experiments/{key}/assignments   {"distinct_id"}
POST /api/v1/experiments/{key}/exposures     {"assignment_id", "idempotency_key"}
POST /api/v1/experiments/{key}/outcomes      {"exposure_id", "source_id", "event_id"}

Every response is complete JSON with every field present; snake_case names; enums are their full name (EXPERIMENT_STATE_RUNNING, EXPERIMENT_ARM_TREATMENT, …); int64/uint64 fields (revision, launched_revision, every count) are decimal strings, never numbers.

  • POST .../experiments previews a draft from {"key", "definition", "idempotency_key"} — an ExperimentDefinition: name, hypothesis, control_payload/treatment_payload (any JSON), eligibility ({"kind": "EXPERIMENT_ELIGIBILITY_KIND_ALL_RESOLVED_PEOPLE"} or {"kind": "EXPERIMENT_ELIGIBILITY_KIND_AUDIENCE_GENERATION", "cohort_id", "generation"}), primary_outcome ({"event", "source_id"}, a server-capture source), conversion_horizon_seconds (1 to 2,592,000), enrollment_start/enrollment_end (half-open, 7 to 30 days apart), minimum_exposed_per_arm (at least 100), direction (EXPERIMENT_DIRECTION_INCREASE/_DECREASE), and an optional descriptive guardrail selector. key is a slug unique in the project forever; a repeat key is 409.
  • PUT .../draft replaces a draft's definition at its exact expected_revision; a stale value, or a non-draft state, is 409.
  • POST .../launch-preview evaluates launch admission without launching: {"experiment", "admission": {"admitted", "refusals": [{"code", "detail"}], "finalization_deadline", "retention": [{"family", "retention_days", "required_from", "retained_from_at_deadline", "covered"}], "audience_expires_at", "required_scopes"}, "proposed"}. An admitted preview includes the complete proposed running definition. Refusal codes: EXPERIMENT_HORIZON_LIMIT, UNSUPPORTED_EXPERIMENT_MODE, EXPERIMENT_ENROLLMENT_IN_PAST, EXPERIMENT_RETENTION_INSUFFICIENT, EXPERIMENT_SOURCE_UNTRUSTED, EXPERIMENT_AUDIENCE_UNAVAILABLE, EXPERIMENT_AUDIENCE_EXPIRES_BEFORE_FINALIZATION.
  • POST .../launch freezes the draft at expected_revision (the previewed revision) and starts the run; a refused admission is 400 naming the same refusal codes, and a moved or non-draft revision is 409.
  • POST .../stop previews cancellation of a draft or run ({"expected_revision", "reason"?}, reason at most 1024 characters); confirmation applies it and assignment, exposure and outcome recording stop immediately. Stopping an already-canceled experiment returns it unchanged; stopping an invalidated or final run is 400 (already terminal).
  • GET .../counts returns per-arm ledger counts only — assigned, exposed, outcomes_provisional, outcomes_reconciled, outcomes_refused, outcomes_missing — plus identity_changed_before_exposure. No per-person rows, no inference.
  • GET .../result returns {"experiment", "result"}. While running/observing, and for a canceled/invalidated run, result carries descriptive per-arm counts only (descriptive_only: true, inference/sample_ratio/snapshot all null, decision unspecified). After the finalization deadline it is computed once, to EXPERIMENT_RESULT_STATE_FINAL (rate, Miettinen–Nurminen difference, z, p value and 95% CI, sample-ratio check and a direction-aware decision — never an automatic rollout) or EXPERIMENT_RESULT_STATE_INSUFFICIENT_EVIDENCE (reasons names why: FLOOR_NOT_MET, MINIMUM_SAMPLE_NOT_MET, MISSING_OUTCOME_EVIDENCE, DEGENERATE_TABLE, NUMERICAL_NONCONVERGENCE, or SAMPLE_RATIO_MISMATCH — the last one still reports sample_ratio but sets no decision). An optional guardrail is descriptive only and never affects the decision. A draft has no result (400); an unknown key is 404.
  • Delivery: .../assignments is stable per canonical person (aliases, retries and concurrent calls agree); an unidentified distinct_id is 400 with details.reason EXPERIMENT_SUBJECT_UNRESOLVED — never retryable. .../exposures and .../outcomes are idempotent on idempotency_key / the (exposure_id, source_id, event_id) triple; .../outcomes is 400 with details.reason EXPERIMENT_OUTCOME_CONFLICT when the captured event is already bound to a different exposure — also never retryable.

Every route requires experiments:read; writes additionally need experiments:write (drafts) or experiments:launch (launch, stop); delivery requires the application key's own experiments:deliver, which no other credential can hold. 403 for a missing scope or a project mismatch, 404 for an unknown experiment or delivery target, 409 for every revision and launch-state conflict above.

Read one subject's experiment delivery receipts

POST /api/v1/projects/{projectId}/experiments/{key}/delivery-evidence is a semantic read with experiments:read; no confirmation is needed. Its typed body has revision (positive decimal string), exactly one assignment_id or distinct_id, optional exposure_id and optional ordered outcomes: [{source_id,event_id}] (at most 20; each event ID 1–1,024 printable UTF-8 bytes). Organization comes from the principal and project and key from the path; scope fields, unknown fields and query parameters are refused. There is no GET alias.

persons:read is required for declared-alias selection and canonical attribution; an assignment-ID metadata read otherwise reports attribution withheld. A canonical-only person UUID is not an alias. The response echoes selectors, numeric revisions as strings, current run state, owner read clocks, counts and limits. Original timestamps retain precision; absent timestamps are null. Each selected outcome has an observation state separate from provisional, reconciled, refused or missing settlement, reason and optional settlement time. No matched receipt is not_observed with no ledger state. Unavailable parent and identity evidence is not absence. capture_evidence is ledger_only and includes the frozen outcome declaration; links is empty and there is no statistical result. See subject delivery evidence for the complete request and independent-capture handoff.

The result read defaults to JSON. GET /api/v1/projects/{projectId}/experiments/{key}/result?format=png returns a supported descriptive interim or supplied final picture as image/png, 960 by 540 pixels and at most 256 KiB. The project stays in the path. The query accepts one format=png and, optionally, one project_id that names the same project as the path; a different, empty or repeated project_id, or any other parameter, is refused with HTTP 400. Successes and errors use Cache-Control: no-store. Terminal or unavailable PNG requests return HTTP 422 JSON with render_status, optional render_reason and independent image_outcome:{image_status,image_reason?} (with error: "render_unavailable" and message). An image-only layout, capacity, deadline or size refusal leaves browser validity intact. No second measurement or treatment action occurs. Read permission remains experiments:read; no experiment proof link exists. See Product experiments.

In-product surveys

Questions your own product shows to the visitors a survey targets, whose outcomes are events on the person. See Run in-product surveys for the lifecycle, targeting and how results are read; this section is the wire contract. Every route is authenticated and takes JSON. int64/uint64 fields (revision, expected_revision, generation, max_responses, response_count) are decimal strings in responses and accepted as strings or numbers in requests; enums are their full name (SURVEY_STATE_DRAFT, SURVEY_QUESTION_TYPE_RATING, …).

Management routes are project-path-scoped; the organization always comes from the principal:

GET    /api/v1/projects/{projectId}/surveys?state=&limit=&cursor=
POST   /api/v1/projects/{projectId}/surveys
GET    /api/v1/projects/{projectId}/surveys/{surveyId}
PUT    /api/v1/projects/{projectId}/surveys/{surveyId}
POST   /api/v1/projects/{projectId}/surveys/{surveyId}/launch
POST   /api/v1/projects/{projectId}/surveys/{surveyId}/stop
POST   /api/v1/projects/{projectId}/surveys/{surveyId}/duplicate
DELETE /api/v1/projects/{projectId}/surveys/{surveyId}
GET    /api/v1/surveys/limits

SDK delivery routes take no project in the path: the organization is the credential's, and a project-bound credential's own project always wins (the body's project_id is read only for an organization-wide credential), exactly as for POST /api/v1/decide:

POST /api/v1/surveys/eligible                {"distinct_id", "pathname", "groups"}
POST /api/v1/surveys/{surveyId}/shown        {"revision", "distinct_id", "shown_id", "environment"}
POST /api/v1/surveys/{surveyId}/responses    {"revision", "distinct_id", "response_id", "answers", "environment"}
POST /api/v1/surveys/{surveyId}/dismissals   {"revision", "distinct_id", "dismissal_id", "environment"}
  • GET .../surveys lists newest first; state is draft, active or stopped, limit is 1 to 200 (50 by default) and next_cursor continues the list. Each survey is {id, org_id, project_id, state, revision, definition, response_count, created_at, updated_at, launched_at, stopped_at, stop_reason}.
  • POST .../surveys creates a draft from {"definition": {name, description, questions, targeting}, "idempotency_key"} and previews revision "1"; confirmation returns a mutation receipt. A question is {id, type, prompt, required} with options: [{id, label}] for a choice type, scale: {min, max, low_label, high_label} for a rating and nothing more for open text. targeting is {routes, audience: {cohort_id, generation}, feature_flag: {key, variant}, sample_percent, max_responses, start_at, end_at, wait_days_after_any_survey}. Unknown fields are refused.
  • PUT .../surveys/{surveyId} replaces a draft's whole definition at {"expected_revision", "definition"}; a stale revision or a survey that is not a draft is 409 with SURVEY_REVISION_CONFLICT.
  • POST .../launch previews the draft at {"expected_revision"} with proposed SURVEY_STATE_ACTIVE; confirmation freezes it. A refused launch names its code: SURVEY_INCOMPLETE, SURVEY_SCHEDULE_ENDED, SURVEY_AUDIENCE_UNAVAILABLE, SURVEY_FLAG_NOT_FOUND or SURVEY_LIMIT; a moved or non-draft revision is 409.
  • POST .../stop takes {"expected_revision", "reason"?} (reason at most 1,024 characters). POST .../duplicate takes {"expected_revision", "idempotency_key", "name"?} and previews a new draft. DELETE ... requires expected_revision and previews removal of a draft or stopped survey; an active survey is refused with SURVEY_ACTIVE. Confirmation applies each change.
  • GET /api/v1/surveys/limits returns {limits, refusal_codes}: the bounds a definition is held to and eligibility_staleness_seconds, the most time a launch, stop or reached response cap takes to change what is offered.
  • eligible answers {"surveys": [{id, revision, questions}]}, at most three, oldest launch first, with the questions and nothing about targeting, and is never cached. shown, responses and dismissals answer {shown_at|responded_at|dismissed_at, replayed}; a repeat of the same idempotency id, and a second display for the same person, is replayed: true. A response's answers hold one {question_id, option_ids | rating | text} per answered question. Delivery refusals begin with SURVEY_NOT_ACTIVE, SURVEY_REVISION_MISMATCH, SURVEY_ANSWER_INVALID, SURVEY_ALREADY_ANSWERED, SURVEY_RESPONSE_CAP_REACHED or SURVEY_NOT_OFFERED (the visitor was not returned this survey by POST /api/v1/surveys/eligible; call it first).

A survey's results are not read here: they are the survey kind of POST /api/v1/analytics/query (above), with the open-text answers paged by GET /api/v1/analytics/results/{result_id}/survey-answers.

Every management route requires surveys:read; writes additionally need surveys:write; the four delivery routes require the credential's own surveys:respond, which implies neither of the others and which the key embedded in a page must hold alone. 403 for a missing scope or a project mismatch, 404 for an unknown survey, 409 for a revision conflict.

Dashboards

Creation takes name, optional project_id, description, layout, tags, variables, initial widgets and required idempotency_key. A project-bound credential may omit its project or repeat the exact binding; organization credentials must name a project. The server verifies the project's current ownership and active state without requiring projects:read. The owner is the authenticated actor, including an API key. Clone keeps the source project and requires its exact expected_version plus a creation key; API-key cloning requires a key bound to that source project.

Up to 50 initial widgets are committed atomically with creation, including saved-insight pins whose current source access is admitted in the preview. Every widget carries its complete query, visualization, title and refresh interval. Metadata updates also accept layout, tags and variables. Widget add, update, and delete and metadata update fence the dashboard aggregate version; adding a widget requires a creation key. Cloning previews the entire source and copy. Mutations use the approval envelope described above; keys remain reserved after ordinary deletion.

GET /api/v1/dashboards/{id} returns complete owner and project metadata, layout, tags, variable definitions, full widget queries, version, timestamps and an accurate widget_count. The MCP get_dashboard result returns widgets in pages of 30 and carries pagination.next_cursor; continue with widget_cursor or CLI dashboards get ID --widget-cursor CURSOR. A changed version invalidates that cursor. The default CLI detail read returns the complete REST aggregate. Dashboard lists use limit 1–500 (default 50) and a scope-bound cursor; a page is not a total.

Invalid definitions and too many initial widgets return 400; changed key arguments or stale aggregate versions return 409; missing scopes or project mismatch return 403. An unknown, inactive, deleted or foreign project returns 404. Dependency outages are 503, deadlines 504; missing service composition is a permanent configuration error. See Creating project-bound saved resources and collector presets for the same approved creation flow.

Scheduled reports

A scheduled report delivers a saved insight or a dashboard on a daily, weekly or monthly schedule to notification channels or organization members. Every route is authenticated and non-cacheable. The organization comes from the principal; the report id is the path's. Subscription routes take project_id as a query parameter (a project-scoped key may omit it or repeat its own); /api/v1/reports/limits is organization-wide and refuses project_id. Request bodies are strict: an unknown field, such as a free-form recipient address, is 400 invalid_request. The approval request is bounded to 4 MiB; report definitions remain bounded to 64 KiB. int64 values such as revision are decimal strings and enums use their full names.

Method and path Scope Notes
GET /api/v1/reports/limits reports:read {"limits": {...}}: counts, name and note length, min_interval_seconds, stale_slot_seconds, max_day_of_month, frequencies, channel_types and the owner permissions per target kind
GET /api/v1/reports/subscriptions reports:read {"subscriptions": [...]}, newest first, not paged
POST /api/v1/reports/subscriptions reports:write + owner permissions body {"definition": {...}, "idempotency_key": "KEY"}; server preview, then confirmed receipt
GET /api/v1/reports/subscriptions/{id} reports:read {"subscription": {...}}
PUT /api/v1/reports/subscriptions/{id} reports:write + owner permissions body {"expected_revision": "N", "definition": {...}}; replaces the whole definition
DELETE /api/v1/reports/subscriptions/{id}?expected_revision=N reports:write removes the report with its runs and deliveries
POST /api/v1/reports/subscriptions/{id}/pause reports:write body {"expected_revision": "N"}
POST /api/v1/reports/subscriptions/{id}/resume reports:write + owner permissions body {"expected_revision": "N"}; returns a paused or needs-attention report to active
POST /api/v1/reports/subscriptions/{id}/run reports:write body {"preview": false, "expected_revision": "N", "idempotency_key": "KEY"}; server approval before queueing
GET /api/v1/reports/subscriptions/{id}/runs?limit=&cursor= reports:read {"runs": [...], "next_cursor": ""} newest first, each with its deliveries
POST /api/v1/reports/subscriptions/{id}/unsubscribe reports:read body {"expected_revision": "N"}; previews removal of the calling member from recipients

The definition is {"name", "note"?, "target", "schedule", "recipients"}. target is {"saved_insight": {"insight_id", "pinned_revision"}} ("0" follows the latest revision) or {"dashboard": {"dashboard_id"}}. schedule is {"frequency", "weekday"?, "day_of_month"?, "last_day_of_month"?, "local_time", "timezone"} with frequency one of REPORT_FREQUENCY_DAILY, REPORT_FREQUENCY_WEEKLY and REPORT_FREQUENCY_MONTHLY, weekday required for weekly only, and monthly taking day_of_month 1 to 28 or last_day_of_month: true. recipients are {"channel_id"} or {"member_user_id"}. A returned subscription adds id, state, attention_reason, attention_detail, owner, revision, next_run_at, created_at and updated_at, and may emit unused schedule fields as 0 and false.

Confirmed POST retried with the same idempotency_key and definition returns the first creation result, including after ordinary deletion. PUT, pause, resume, DELETE, run and unsubscribe require expected_revision, a positive decimal string; a missing or malformed value is 400 invalid_request, and a stale one is 409 with code Aborted. Creating, replacing or resuming makes the caller the owner and is refused with 403 when the caller lacks a permission a run needs. A target that is not in the project is 404. limit on the runs route is 0 to 100 (default 20); follow next_cursor unchanged until it is empty. preview: true still queues a real delivery to the calling member after server approval and is refused for an API key. Report writes require reports:read and reports:write; self-unsubscribe requires read only, and all admitted target and recipient source scopes are named before confirmation.

A public, credential-free route behind each report email's unsubscribe link, GET|POST /api/v1/public/report-subscriptions/unsubscribe?token=, removes one member from one report; GET only shows a confirmation page and POST performs the unsubscribe. See Send a scheduled report.

Warehouse export

Warehouse export writes product events and cohort memberships on a schedule to an S3-compatible bucket you own. Every route is authenticated and non-cacheable. The organization comes from the principal; the object id is the path's. Destination, export and run routes take project_id as a query parameter (a project-scoped key may omit it or repeat its own); /api/v1/warehouse/limits is organization-wide and refuses project_id. Request bodies are strict: an unknown field or an undeclared enum value is 400 invalid_request, and a body above 128 KiB is 413. int64 values such as revision are decimal strings and enums use their full names.

Method and path Scope Notes
GET /api/v1/warehouse/limits warehouse:read {"limits": {...}}: counts, name length, backfill lookback, row and byte bounds, safety_lag_seconds, the owner permissions, the file schema versions and notices
GET /api/v1/warehouse/destinations warehouse:read {"destinations": [...]}, newest first, not paged; never a secret
POST /api/v1/warehouse/destinations warehouse:write body {"definition": {...}}; 201 with {"destination": {...}}
GET /api/v1/warehouse/destinations/{id} warehouse:read {"destination": {...}}
PUT /api/v1/warehouse/destinations/{id} warehouse:write body {"expected_revision": "N", "definition": {...}}; replaces the destination, and an omitted secret_access_key keeps the stored one
DELETE /api/v1/warehouse/destinations/{id}?expected_revision=N warehouse:write refused while an export writes to it; removes the destination and its secret
POST /api/v1/warehouse/destinations/{id}/test warehouse:write 200 with {"outcome", "detail", "destination"}; a refusal by the destination is an outcome, not an error
GET /api/v1/warehouse/exports warehouse:read {"exports": [...]}, newest first, not paged
POST /api/v1/warehouse/exports warehouse:write + owner permissions body {"definition": {...}}; 201 with {"export": {...}}
GET /api/v1/warehouse/exports/{id} warehouse:read {"export": {...}}
PUT /api/v1/warehouse/exports/{id} warehouse:write + owner permissions body {"expected_revision": "N", "definition": {...}}; replaces the whole definition
DELETE /api/v1/warehouse/exports/{id}?expected_revision=N warehouse:write removes the export with its runs; files already written stay in the bucket
POST /api/v1/warehouse/exports/{id}/pause warehouse:write body {"expected_revision": "N"}
POST /api/v1/warehouse/exports/{id}/resume warehouse:write + owner permissions body {"expected_revision": "N"}; returns a paused or needs-attention export to active
POST /api/v1/warehouse/exports/{id}/run warehouse:write 202 with the queued {"run": {...}}
GET /api/v1/warehouse/exports/{id}/runs?limit=&cursor= warehouse:read {"runs": [...], "next_cursor": ""} newest first

A destination definition is {"name", "type", "s3", "secret_access_key"?}. type is WAREHOUSE_DESTINATION_TYPE_S3_COMPATIBLE and s3 is {"endpoint", "region", "bucket", "prefix"?, "path_style"?, "access_key_id"}. The endpoint must be https and a public address; one that is not, or whose host does not resolve, is 400. secret_access_key is required on create, write-only and returned by no route; a destination carries credential_set and credential_fingerprint instead, plus last_test_outcome (one of the WAREHOUSE_DESTINATION_TEST_OUTCOME_* values, or ..._UNSPECIFIED before any test), last_tested_at and export_count.

An export definition is {"name", "destination_id", "dataset", "events"?, "audience"?, "format", "schedule"}. dataset is WAREHOUSE_DATASET_EVENTS with events {"event_names", "environment", "include_properties", "backfill_start"}, or WAREHOUSE_DATASET_AUDIENCE with audience {"cohort_id", "pinned_generation"} (a decimal string; "0" follows the current generation). format is WAREHOUSE_FILE_FORMAT_PARQUET or WAREHOUSE_FILE_FORMAT_NDJSON_GZIP; schedule is WAREHOUSE_SCHEDULE_HOURLY (events only) or WAREHOUSE_SCHEDULE_DAILY. A returned export adds id, state, attention_reason, attention_detail, owner_kind (api_key or org_member), owner_id, revision, high_water, next_run_at, schema_version, created_at and updated_at, and may emit unused messages as null and unused arrays as []. A returned run carries kind, status, reason, detail, window_start, window_end, attempts, rows, bytes, parts, properties_included, generation, manifest_key, created_at and completed_at.

PUT, pause, resume and DELETE require expected_revision, a positive decimal string; a missing or malformed value is 400 invalid_request, and a stale one is 409 with code Aborted. A missing scope is 403 with code PermissionDenied. Creating, replacing or resuming an export makes the caller the owner and is refused with 403 when the caller lacks a permission a run needs. The dataset cannot be changed and backfill_start is honoured only on create. limit on the runs route is 1 to 100 (default 20); follow next_cursor unchanged until it is empty. See Export events and audiences to your warehouse.

External monitoring

A monitor probes a public target on a schedule and pages through an escalation policy the same way any other alert does. Four kinds share the same lifecycle, routing and response gate: http (request and response check), tls (certificate expiry), domain (registration expiry) and heartbeat (expects pings from a scheduled job). See External monitoring for the concepts, limits and reason codes. Every route requires monitoring:read; the mutating ones also require monitoring:write (DELETE requires monitoring:delete). Because a monitor's routing lives on an alert rule, creating, resuming or re-routing a monitor also requires alerts:rules:write, and deleting one also requires alerts:rules:delete. Naming an owning service (service_key) also requires incidents:read, and moving a monitor that has its own rule to a service also requires alerts:rules:delete.

GET    /api/v1/monitors?project_id=&include_deleted=&page_size=&page_token=
POST   /api/v1/monitors
GET    /api/v1/monitors/{monitorId}
PATCH  /api/v1/monitors/{monitorId}
DELETE /api/v1/monitors/{monitorId}?expected_revision=
POST   /api/v1/monitors/{monitorId}/pause
POST   /api/v1/monitors/{monitorId}/resume
POST   /api/v1/monitors/{monitorId}/test
GET    /api/v1/monitors/{monitorId}/history?kind=&page_size=&page_token=
GET    /api/v1/monitors/{monitorId}/audit?page_size=&page_token=
GET    /api/v1/monitors/{monitorId}/rounds?kind=&page_size=&page_token=
POST   /api/v1/monitors/{monitorId}/rearm
GET    /api/v1/monitoring/regions
POST   /api/v1/monitors/{monitorId}/heartbeat-token
GET    /api/v1/monitors/{monitorId}/pings?page_size=&page_token=
GET    /api/v1/maintenance-windows?project_id=&state=&page_size=&page_token=
POST   /api/v1/maintenance-windows
GET    /api/v1/maintenance-windows/{windowId}
PATCH  /api/v1/maintenance-windows/{windowId}
POST   /api/v1/maintenance-windows/{windowId}/end

Every response is complete JSON with every field present; snake_case names; revision and every job's config_revision are decimal strings; state, binding_state, kind and assertion kind are plain lowercase strings (for example active, paused, bound, scheduled, body_contains); reason_codes and a job's reason_code are closed, upper-snake-case strings (for example MONITOR_PAUSED, EXECUTOR_UNAVAILABLE). A monitor's service_key and service_id name its owning service (both empty when it has its own routing); its alert then describes the service's rule, and alert.binding_state is none — the monitor has no rule of its own.

  • POST /monitors creates a monitor from name, config and exactly one route: routing (escalation_policy_id, severity) creates its dedicated alert rule, or service_key names the live catalog service that owns it — the monitor then has no rule of its own, pages through the service's rule, and its incidents carry the service's ownership. Both together are 400 MONITOR_ROUTING_CONFLICT; an unknown or deleted key is 400 MONITOR_SERVICE_INVALID, a service whose rule is not bound yet 400 MONITOR_SERVICE_NOT_ROUTABLE. config carries exactly one of http, tls, domain or heartbeat, plus the fields the kind uses:
    • http: http.method, http.url, http.expected_status[]{min,max}, http.assertions[]{kind,json_path,value}, http.headers[]{name,connection_id}, http.follow_redirects, http.timeout_seconds, interval_seconds (60-86400, default 60), regions[], failure_rounds, recovery_rounds (1-10 each, default 2).
    • tls: tls.host, tls.port (443 default, or 465, 636, 853, 993, 995 or 1024–65535), tls.warning_days (1-180, default 21), interval_seconds (3600-86400, default 21600), regions[].
    • domain: domain.domain (stored as its registrable domain), domain.warning_days (1-365, default 30), interval_seconds (21600-86400, default 86400); no regions.
    • heartbeat: exactly one of heartbeat.interval_seconds (60-604800) or heartbeat.cron (five-field, heartbeat.timezone an IANA zone, default UTC), heartbeat.grace_seconds (60-86400, default 300); no interval_seconds or regions. The kind cannot change after creation (400 MONITOR_KIND_IMMUTABLE). paused creates it without scheduling. An optional idempotency_key (1-128 characters) makes a retry safe: the identical key and body returns 200 with replayed: true and the original monitor instead of creating a second one; the same key with a different body is 409. 201 on creation, 200 on a replay, 400 for a rejected configuration (a closed MONITOR_* code named in message), and 503 naming MONITOR_BINDING_PENDING when the monitor was stored but its alert rule could not be confirmed — repeat the exact request (same idempotency_key) to retry, or resume it. Creating a heartbeat monitor returns heartbeat_token (anhb_ + 43 characters) in this response only — it is never returned again; a lost token is replaced with POST /monitors/{id}/heartbeat-token.
  • PATCH /monitors/{id} replaces whichever of name, config (the whole configuration), routing and service_key are present in the body, comparing and swapping on the required expected_revision; a name or configuration change writes a new revision and fences every job issued for an older one. A present service_key moves the monitor to that service (its own alert rule is deleted); "service_key": "" together with routing moves it back to its own rule. A failing monitor's open alert moves with it: the old route's alert resolves with resolution: withdrawn and the new route is paged at once. routing on a service-owned monitor is 400 MONITOR_SERVICE_OWNED; an empty service_key without routing is 400 MONITOR_ROUTING_REQUIRED; a move whose old rule could not be removed yet is 503 MONITOR_RELEASE_PENDING (the monitor already pages through the service — repeat the request). An empty body, or one naming none of those four fields, is 400. A stale expected_revision is 409; a config that changes the monitor's kind is 400 MONITOR_KIND_IMMUTABLE.
  • DELETE /monitors/{id}?expected_revision= tombstones the monitor (history and audit are kept), fences its open jobs, and deletes its alert rule — or, for a service-owned monitor that is failing, resolves its open alert with resolution: withdrawn; expected_revision is required. Repeating the delete on an already-deleted monitor skips the revision check and retries the alert-rule release — 200 either way, or 503 naming MONITOR_RELEASE_PENDING if the release itself is still pending.
  • POST /monitors/{id}/pause and /resume optionally compare and swap on expected_revision. Resuming re-validates routing coverage and is refused (400, ROUTING_NOT_COVERED) unless the escalation policy can page.
  • POST /monitors/{id}/test admits one bounded, extra round (one job per configured region) outside the schedule. page (default false) controls whether a failing result of this round may page; a paging test against uncovered or not-yet-bound routing is 400. At most one test round per monitor is open at a time; a second one is 400 naming MONITOR_TEST_IN_PROGRESS. 202 — the round is admitted, run by the regional probe executors, and reduced the same way a scheduled round is; read its verdict with GET /monitors/{id}/rounds?kind=test. If too few of the monitor's regions currently have a serving executor to reach a verdict, the response's executor_status is unavailable (or degraded when some but not all do) and reason_code names why.
  • GET /monitors/{id}/history?kind= lists a monitor's scheduled or test jobs, newest first, including a deleted monitor's. GET /monitors/{id}/audit lists its audited actions (created, updated, paused, resumed, test_requested, deleted, alert_bound, alert_released, route_changed, response_ended, rearmed, heartbeat_token_rotated, paged_after_maintenance), newest first, also for a deleted monitor.
  • GET /monitors/{id}/rounds?kind= lists a monitor's rounds, newest first: the majority verdict (failed, healthy, unknown or pending), quorum, coverage (full, degraded or unknown, plus which regions reported, were missing or were invalid), what the round did to the response (effect: empty, opened, updated, recovered, gated, stale_revision or recorded), and each region's observations[] (outcome, status_code, a closed error_class, a redacted detail, per-phase timing in milliseconds, redirects). Works for a deleted monitor.
  • POST /monitors/{id}/rearm {"expected_gate_version": N} re-arms paging after a responder resolved the monitor's alert while it was still failing (health.response_gate.state is response_ended_while_unhealthy); expected_gate_version must equal the current health.response_gate.version, shown on every monitor read. The next failed round then opens a new episode and pages once. A stale version is 409; re-arming a monitor whose gate was never ended, or one that is deleted, is 400 naming MONITOR_NOT_GATED or MONITOR_DELETED.
  • GET /monitoring/regions lists the organization's probe region catalog and each region's executor diagnostics: status (serving, stale or absent), a worker count, last_seen_at, and the distinct executor builds seen recently. Never worker ids or addresses.
  • POST /monitors/{id}/heartbeat-token replaces a heartbeat monitor's ping token; the previous token stops working immediately. The new token is in this response only (never cached, Cache-Control: no-store); 400 MONITOR_NOT_HEARTBEAT for any other kind.
  • GET /monitors/{id}/pings?page_size=&page_token= lists a heartbeat monitor's received pings, newest first: event (start, success, fail), run_id, exit_code (when the ping carried one), disposition (accepted or duplicate — a repeated start or completion of the same run_id, which changed nothing), and effect — how it was counted once its round settled: counted, late (completed a run whose expected run was already judged), ignored (received before its round's window opened, for example a manual run between scheduled times), or empty before the round settles.
  • GET /maintenance-windows?project_id=&state=&page_size=&page_token= lists maintenance windows, latest start first; state filters to upcoming, active, completed or canceled.
  • POST /maintenance-windows creates a window from project_id, name, scope (project — every monitor in the project, including ones created later — or monitors with monitor_ids[], 1-200), timezone (default UTC, display only), starts_at (may be in the past: the window is then active at once) and ends_at (must be in the future, at most 14 days after starts_at, and starts_at at most 365 days ahead). 400 MAINTENANCE_INVALID for a rejected window (a monitor outside the project, bad bounds), 429 MAINTENANCE_LIMIT past 50 open windows in one project.
  • PATCH /maintenance-windows/{id} replaces whichever of name, scope (with monitor_ids), timezone, starts_at, ends_at are present, comparing and swapping on expected_revision. An active window accepts only name and ends_at (400 MAINTENANCE_ACTIVE for anything else); a completed or canceled one accepts nothing (400 MAINTENANCE_ENDED).
  • POST /maintenance-windows/{id}/end {"expected_revision": N} cancels an upcoming window or ends an active one now. Ending an active window pages every scoped monitor that is still failing and has no open occurrence (once, when the window ends — not while it is active, and not only at that monitor's next failed round).
  • GET|POST /api/v1/heartbeats/ping?event=start|success|fail&run_id=&exit_code= is public: its only credential is the heartbeat monitor's ping token, sent as X-Anectico-Heartbeat-Token (never as a query parameter or in the body). Send an empty request body and put event, run_id and exit_code in the query string. A token query parameter or any non-empty request body is 400, even with a valid token header; the refused request records no ping. Answers {"disposition": "accepted"|"duplicate"|"ignored", "received_at"}, never cached. A wrong or revoked token is 401; beyond 30 accepted pings per minute for one monitor is 429 HEARTBEAT_RATE_LIMITED. See Heartbeats for schedules, grace and pairing.

Every route returns 400 for a rejected configuration or a malformed request, 403 for a missing scope or a project mismatch, 404 for an unknown monitor or maintenance window, 409 for a stale expected_revision, response-gate version, or a reused idempotency key against a different body, 429 for rate limits, and 503 while a monitor's alert-rule binding or release is unconfirmed.

Response catalog and runbooks

A response service declares who responds when it pages: an escalation policy, an optional on-call schedule and an optional default runbook. See Set up the response catalog for the concepts. Every route requires incidents:read; service and runbook create, update and revise also require incidents:write (services also alerts:rules:write); deletes also require incidents:delete (services also alerts:rules:delete); the checklist write requires incidents:respond.

GET    /api/v1/service-catalog?project_id=&include_deleted=
POST   /api/v1/service-catalog
GET    /api/v1/service-catalog/{serviceId}
PATCH  /api/v1/service-catalog/{serviceId}
DELETE /api/v1/service-catalog/{serviceId}?expected_revision=
GET    /api/v1/service-catalog/routing-preview?service_key=&fallback_service_key=&project_id=
GET    /api/v1/runbooks?project_id=&include_deleted=
POST   /api/v1/runbooks
GET    /api/v1/runbooks/{runbookId}?revision=
DELETE /api/v1/runbooks/{runbookId}?expected_revision=
GET    /api/v1/runbooks/{runbookId}/revisions
POST   /api/v1/runbooks/{runbookId}/revisions
GET    /api/v1/incidents/{incidentId}/ownership
PUT    /api/v1/incidents/{incidentId}/checklist/{stepId}

Every response is complete JSON, snake_case, with revision and runbook_revision as decimal strings.

  • POST /service-catalog creates a service from key (1-64 of a-z, 0-9, -, _, unique among the project's live services), name, escalation_policy_id (required — a service pages through a policy) and optional description, on_call_schedule_id, default_runbook_id, severity (info, warning or critical, default critical). 201 on creation; 200 with "replayed": true when this identical create completed an earlier one whose alert rule had not been confirmed yet. 409 SERVICE_KEY_TAKEN for a duplicate key; 400 for an invalid key, missing or invalid escalation_policy_id; 503 SERVICE_BINDING_PENDING when the service was saved but its alert rule could not be confirmed yet — repeat the identical create, or update the service, to retry.
  • GET /service-catalog/{id} and GET /service-catalog?project_id=&include_deleted= read one service or list a project's live (or, with include_deleted=true, also tombstoned) services. Each carries routing: {status, detail} — covered only when its policy exists, is active and has at least one level.
  • PATCH /service-catalog/{id} replaces whichever of name, description, on_call_schedule_id, default_runbook_id, escalation_policy_id, severity are present in the body (an empty on_call_schedule_id or default_runbook_id clears it), comparing and swapping on the required expected_revision. Changing escalation_policy_id or severity re-routes the bound alert rule. 409 for a stale revision.
  • DELETE /service-catalog/{id}?expected_revision= tombstones the service (history and incident ownership snapshots are kept; its key becomes reusable) and releases its alert rule. expected_revision is required for a live service. A delete supplying a revision for an already-tombstoned service is refused with 400 CATALOG_ENTRY_DELETED; omit the revision to retry releasing its rule. This recovery form cannot delete a live service. 503 SERVICE_RELEASE_PENDING means the service was tombstoned but its rule could not be released yet — repeat the REST delete without expected_revision to retry routing cleanup.
  • GET /service-catalog/routing-preview?service_key=&fallback_service_key= answers "where would an alert naming this key go right now": routed, the resolved service, used_fallback, on_call_user_id, the runbook_id/runbook_revision/runbook_title an incident would snapshot, and diagnostics[]{code, detail} (UNKNOWN_SERVICE, FALLBACK_USED, SERVICE_NOT_ROUTABLE, NO_ROUTING_COVERAGE, NO_SCHEDULE, NO_RESPONDER, RESPONDER_UNVERIFIED, NO_RUNBOOK).
  • POST /runbooks creates a runbook from key, title and steps[]{id, title, detail, links[]} (1-50 steps, each at most 4 KiB, each links[] at most 5 absolute http(s) URLs of at most 2048 bytes). 400 RUNBOOK_STEPS_INVALID for a rejected step list. GET /runbooks/{id}?revision= reads the current revision, or an older, immutable one. DELETE /runbooks/{id}?expected_revision= tombstones it; 400 RUNBOOK_IN_USE while a live service names it as its default, and 400 CATALOG_ENTRY_DELETED if it is already tombstoned. Deleting keeps the content revision and historical steps; it does not publish a new revision. Change those services' defaults first; deleting a runbook does not clear service defaults. Existing incident snapshots and checklists are preserved. POST /runbooks/{id}/revisions {"expected_revision", "title"?, "steps"} writes a new immutable revision (201); GET .../revisions lists every revision, newest first, without their steps.
  • POST /incidents (manually declaring an incident) accepts an optional service_key naming a response-catalog service; Incidents snapshots that service's owner, escalation policy and default runbook revision into the incident in the same transaction that creates it — the same snapshot an incident opened automatically from that service's alert would carry. 404 if the key names no live service in the credential's project; 400 for a malformed key.
  • GET /incidents/{id}/ownership returns {"ownership": {...}|null, "checklist": [...]} — the incident's immutable snapshot (service, owner schedule, policy, runbook revision, its steps, source alert|manual, captured_at) taken once when it opened, and each step's current {step_id, done, actor, at}. ownership is null for an incident with no cataloged service. PUT /incidents/{id}/checklist/{stepId} {"done": true|false} marks one step; the response's changed is false when the step already had that state (nothing was recorded). 400 NO_OWNERSHIP_SNAPSHOT on an incident with no snapshot; 404 for a step id outside the snapshot, or an incident outside the credential's project.
  • POST /incidents/{id}/slack-channel {"connection_id", "existing_channel_id"?} opens (or links) the incident's Slack channel; requires incidents:respond. incident_id is the idempotency key — a duplicate request for the same incident returns the already-linked channel (same state) rather than creating a second one or erroring. Response: {"channel": {"incident_id", "connection_id", "team_id", "channel_id", "channel_name", "summary_message_ts", "state", "requested_by", "requested_at", "last_sync_at", "last_error_code"}}, where state starts requested and settles to active, missing (the channel itself was rejected), or permission_lost (the connection needs reinstalling) once the platform finishes syncing with Slack, usually within a few seconds. GET /incidents/{id}/slack-channel reads the same shape and requires incidents:read; 404 means no channel has been requested for this incident yet, not an error.

Response reports and retrospectives

The response and availability reports, and an incident's retrospective. See Response and availability reports for the formulas. The response report and retrospective reads require incidents:read; retrospective and action-item writes require incidents:write; the availability report requires monitoring:read.

GET    /api/v1/reports/response?project_id=&from=&to=&severity=&service_key=&source=&format=&part=
GET    /api/v1/reports/availability?project_id=&from=&to=&monitor_id=&format=
GET    /api/v1/incidents/{incidentId}/retrospective
PUT    /api/v1/incidents/{incidentId}/retrospective
POST   /api/v1/incidents/{incidentId}/retrospective/actions
PATCH  /api/v1/incidents/{incidentId}/retrospective/actions/{actionId}
DELETE /api/v1/incidents/{incidentId}/retrospective/actions/{actionId}
  • GET /reports/response requires from and to (RFC 3339, [from, to), at most 366 days); severity, service_key and source repeat. mtta and mttr each carry available, mean_seconds, median_seconds and sample_count; with no samples, available is false and the mean and median are absent. The counts and stats cover every matching episode; episodes lists the newest 1,000 and rows_truncated is true when more matched. format=csv returns the episode table; add part=summary for the one-row summary.
  • GET /reports/availability requires from and to (at most 90 days) and accepts up to 50 monitor_id values; with none, it reports the project's first 50 live monitors and sets truncated when there are more. availability is absent when availability_known is false. format=csv returns the per-monitor table.
  • PUT /incidents/{id}/retrospective replaces the whole document (summary, impact, contributing_factors, went_well, went_wrong, timeline_refs, status) under expected_revision (0 creates the first revision). timeline_refs are at most 100 ids of this incident's own timeline events. 409 for a stale revision.
  • POST .../retrospective/actions takes title, optional owner_user_id (a current member of your organization), due_date, ticket_url (http/https only) and idempotency_key; a retry with the same key and body returns the first item, and the same key with a different body is 409. PATCH changes only the fields present in the body.
  • An action item's owner_user_id is returned only to callers that also hold members:read.

Status pages

A status page is a public page: components with a status each, public incidents with their updates, and scheduled maintenance. Everything edited here is a draft — nothing reaches the public page until POST /publish (or an opted-in automatic monitor-driven status change) creates a publication. See Status pages for the concepts, states and limits. Every route requires status:read; draft edits also require status:write; publish, unpublish and DELETE also require status:publish. Associating a component with a monitor also requires monitoring:read; associating it with a service, or linking a public incident to an internal one, also requires incidents:read.

GET    /api/v1/status-pages?project_id=&include_deleted=&page_size=&page_token=
POST   /api/v1/status-pages
GET    /api/v1/status-pages/{pageId}
PATCH  /api/v1/status-pages/{pageId}
DELETE /api/v1/status-pages/{pageId}?expected_revision=
PUT    /api/v1/status-pages/{pageId}/components/{key}
DELETE /api/v1/status-pages/{pageId}/components/{key}?expected_revision=
POST   /api/v1/status-pages/{pageId}/incidents
PATCH  /api/v1/status-pages/{pageId}/incidents/{incidentId}
DELETE /api/v1/status-pages/{pageId}/incidents/{incidentId}?expected_revision=
POST   /api/v1/status-pages/{pageId}/maintenance
PUT    /api/v1/status-pages/{pageId}/maintenance/{maintenanceId}
DELETE /api/v1/status-pages/{pageId}/maintenance/{maintenanceId}?expected_revision=
GET    /api/v1/status-pages/{pageId}/preview
POST   /api/v1/status-pages/{pageId}/publish
POST   /api/v1/status-pages/{pageId}/unpublish
GET    /api/v1/status-pages/{pageId}/publications?page_size=&page_token=
GET    /api/v1/status-pages/{pageId}/domain
POST   /api/v1/status-pages/{pageId}/domain
POST   /api/v1/status-pages/{pageId}/domain/verify
DELETE /api/v1/status-pages/{pageId}/domain
GET    /api/v1/status-pages/{pageId}/subscribers?state=&page_size=&page_token=
DELETE /api/v1/status-pages/{pageId}/subscribers/{subscriptionId}

Every response is complete JSON with every field present; snake_case names; revision, live_revision and a publication's revision/page_revision/size_bytes are decimal strings; state, association_kind, manual_status/auto_status/status, incident impact/status and a publication's kind/origin/state are plain lowercase strings.

  • POST /status-pages creates a draft page from slug (3-48 of a-z, 0-9 and -, starting and ending with a letter or digit — globally unique and permanent, even for a deleted page), title and an optional description. 201, or 409 STATUS_SLUG_TAKEN for a slug already in use.
  • GET /status-pages/{id} returns the full draft — components, public incidents with their updates, maintenance — plus its publication state (public, live_revision, has_unpublished_changes, latest_publication). GET /status-pages list rows carry the same page-level fields but no components, incidents or maintenance.
  • PATCH /status-pages/{id} {"expected_revision", "title"?, "description"?} edits the draft header, comparing and swapping on expected_revision. Every draft edit below shares this same compare-and-swap: a stale expected_revision is 409 STATUS_REVISION_CONFLICT naming the revision observed at the comparison in the message and details.current_revision (a decimal string). Read the latest draft and reconcile your edit before retrying; the named revision can change again before your next attempt.
  • DELETE /status-pages/{id}?expected_revision= tombstones the page (its slug stays reserved); if the page was public, its removal from the public renderer is queued the same way an explicit unpublish is.
  • PUT /status-pages/{id}/components/{key} {"expected_revision", "component": {...}} creates or replaces the component at key (1-64 of a-z, 0-9, - and _): name, an optional group heading, position (sort order), association_kind (none, service or monitor, with association_id required for the latter two — verified to exist, live and in the page's project before the write), auto_update (monitor association only), and manual_status (operational, degraded, partial_outage, major_outage or maintenance). 400 STATUS_AUTO_UPDATE_NEEDS_MONITOR for auto_update without a monitor association; 400 STATUS_ASSOCIATION_NOT_FOUND for a missing, deleted or cross-project target. A component's association and auto_update are never published — only its status is.
  • DELETE /status-pages/{id}/components/{key}?expected_revision= removes a component; 409 STATUS_COMPONENT_IN_USE while a public incident or maintenance still names it, 409 STATUS_COMPONENT_LIMIT is instead returned by the PUT that would have exceeded 100 components on the page.
  • POST /status-pages/{id}/incidents {"expected_revision", "title", "impact", "status", "body", "affected_components"?, "internal_incident_id"?} opens a public incident with its first public update; impact is none, minor, major or critical, status is investigating, identified, monitoring or resolved. internal_incident_id optionally links an internal incident — verified to exist in the page's project — and is never published. 201 with incident_id.
  • PATCH /status-pages/{id}/incidents/{incidentId} {"expected_revision", "title"?, "impact"?, "affected_components"?, "set_affected_components"?, "update_status"?, "update_body"?} edits the incident's header or posts a new public update; set_affected_components: true replaces the affected-component list (including with an empty one) — omit it to leave that list unchanged. Setting update_status to resolved resolves the incident and records resolved_at; moving it off resolved reopens it and clears that field.
  • DELETE /status-pages/{id}/incidents/{incidentId}?expected_revision= removes a public incident and its updates from the draft.
  • POST /status-pages/{id}/maintenance {"expected_revision", "maintenance": {...}} creates a maintenance (201 with maintenance_id); PUT /status-pages/{id}/maintenance/{maintenanceId} {"expected_revision", "maintenance": {...}} replaces one. maintenance carries title, an optional body, affected components[], and starts_at/ends_at (RFC 3339; ends_at must be strictly after starts_at, else 400 STATUS_INVALID).
  • DELETE /status-pages/{id}/maintenance/{maintenanceId}?expected_revision= removes a maintenance from the draft.
  • GET /status-pages/{id}/preview returns {"document", "size_bytes", "limit_bytes":262144, "page_revision"} — exactly the public document a publish would write for the current draft, with nothing published. document carries only the allowlisted public fields (page slug and title/ description, each component's key, name, group, and status, unresolved and recently-resolved incidents with their updates, scheduled or in-progress maintenance, overall_status, and — only when a component is automatically updated — stale_after_seconds and checked_at).
  • POST /status-pages/{id}/publish {"expected_revision"} snapshots the draft into a new publication and queues it for the public renderer. 202 with {"publication", "replayed": false} when newly queued; 200 with "replayed": true when that exact draft revision already has a pending or published publication — nothing new is written. 400 STATUS_PAYLOAD_LIMIT if the resulting document exceeds 256 KiB.
  • POST /status-pages/{id}/unpublish {"expected_revision"} queues the page's removal from the public renderer. 202 with {"page", "publication"} when a removal was queued; 200 with no publication when the page was already not public. A later publish makes the current draft public again under a new revision.
  • GET /status-pages/{id}/publications?page_size=&page_token= lists a page's publications, newest first: kind (publish/unpublish), origin (manual/automatic), state (pending, published, failed or superseded), digest, size_bytes, attempts and any renderer error. A pending publication is never the page's live one — watch for state to become published (or read the page's live_revision) before treating a change as visible to your users.

Every route returns 400 for a rejected field or a malformed request, 403 for a missing scope or a project mismatch, 404 for an unknown page, component, incident or maintenance, and 409 for a stale expected_revision, a slug already taken (including by a deleted page), or a component still referenced elsewhere. Revision and component conflicts keep error: "FailedPrecondition" and carry details.reason as STATUS_REVISION_CONFLICT, STATUS_COMPONENT_LIMIT, or STATUS_COMPONENT_IN_USE; only a revision conflict also carries details.current_revision. A taken slug keeps error: "AlreadyExists". Refused writes do not change the draft or its revision.

Custom domain

Serve a status page on a subdomain you control instead of its default address. GET/POST .../domain/verify require status:read; POST/DELETE .../domain require status:write. See Status pages for the concepts and limits.

  • GET /status-pages/{id}/domain returns {"domain": {"hostname", "state", "cname_target", "ownership_txt_name", "ownership_txt_value", "ssl_status", "dns_target_ok", "last_checked_at", "last_error", "created_at", "public_url"}}. 404 when the page has no custom domain.
  • POST /status-pages/{id}/domain {"hostname"} claims a subdomain you control, e.g. status.example.com. 201 with the domain in state pending_ownership and its DNS instructions. 400 STATUS_INVALID for a malformed hostname; 400 STATUS_DOMAIN_UNSUPPORTED for an apex, wildcard, IP address, or a hostname equal to or under a platform domain; 409 STATUS_DOMAIN_TAKEN when another page already holds it; 409 STATUS_DOMAIN_EXISTS when this page already has one; 400 STATUS_DOMAINS_UNAVAILABLE when custom domains are not configured on this deployment.
  • POST /status-pages/{id}/domain/verify asks for an immediate re-check rather than waiting for the next scheduled one. Pass the custom domain’s updated_at as the expected_version query argument; a stale clock is refused. 400 STATUS_DOMAIN_REMOVING while the domain is being removed.
  • DELETE /status-pages/{id}/domain?expected_version=<domain-updated-at> starts releasing the previewed hostname; a stale clock is refused. 202 with the domain in state removing — the page's own address keeps working, and removal finishes once the renderer confirms the hostname serves nothing.

Email subscribers

Manage a page's email subscriber list. Every route requires status:read; DELETE .../subscribers/{id} also requires status:write. The full address is never returned by any API. See Status pages for the concepts and limits.

  • GET /status-pages/{id}/subscribers?state=&page_size=&page_token= returns {"subscribers": [{"id", "masked_email", "state", "components", "created_at", "confirmed_at"}], "counts": {"pending", "confirmed", "unsubscribed"}, "uncertain_sends", "failed_sends", "next_page_token"}. state narrows to pending, confirmed or unsubscribed. masked_email keeps only the first character of the local part and the full domain (e.g. p***@example.com). uncertain_sends counts emails whose delivery could not be confirmed one way or the other (never retried); failed_sends counts emails the provider refused or that ran out of retries.
  • DELETE /status-pages/{id}/subscribers/{subscriptionId} erases one subscriber — its address, consent and any queued sends. 204.

Public status-subscription routes (no credential)

These carry the public subscriber flow behind a live status page's own address; none of them takes an organization credential, and each answer is deliberately the same whether or not the address was already subscribed. Every route is rate limited and fails closed (503) if the limiter is unavailable, or 501 on a deployment with no subscriber configuration at all.

POST     /api/v1/public/status-subscriptions
GET|POST /api/v1/public/status-subscriptions/confirm?token=
GET|POST /api/v1/public/status-subscriptions/unsubscribe?token=
  • POST /public/status-subscriptions {"slug", "email", "components"?} subscribes email to a live public page's updates, optionally scoped to specific component keys (omitted or empty means all). 202 {"status":"accepted","message"} for a new, pending or already-confirmed address alike — the response never reveals which. 404 when the slug is not a live public page; 429 with Retry-After over the rate limit (5 per minute per address, 60 per minute per page); 503 when the subscription system is temporarily unavailable; 501 when it is not configured on this deployment. A confirmation email is queued with a 24-hour link unless one was already queued in the last 10 minutes.
  • GET|POST /public/status-subscriptions/confirm?token= confirms a pending subscription once. GET renders an HTML confirmation page (a button that POSTs — nothing is confirmed by fetching the link); POST acts. With Accept: application/json, a successful POST returns {"status":"ok","page_title"}; an unknown, already-used or expired token is 404 (STATUS_TOKEN_INVALID).
  • GET|POST /public/status-subscriptions/unsubscribe?token= ends a subscription; repeating it succeeds both times. Every subscriber email carries this link, and the POST form is also the RFC 8058 one-click target of the email's List-Unsubscribe header. Same response shapes as confirm.

Incoming alerts

An incoming-alert source lets Prometheus Alertmanager or a signed generic JSON sender page a service in the response catalog. See Incoming alerts for the signing scheme, payload shapes and outcomes. The management routes require connections:read (create, update, and rotate also connections:write, revoke also connections:delete); the delivery route is public and authenticates itself.

GET    /api/v1/incoming-alert-sources?project_id=
POST   /api/v1/incoming-alert-sources
GET    /api/v1/incoming-alert-sources/{sourceId}
PATCH  /api/v1/incoming-alert-sources/{sourceId}
POST   /api/v1/incoming-alert-sources/{sourceId}/rotate-secret
POST   /api/v1/incoming-alert-sources/{sourceId}/revoke
GET    /api/v1/incoming-alert-sources/{sourceId}/deliveries
POST   /api/v1/incoming-alerts?source={sourceId}
  • POST /incoming-alert-sources creates a source from provider (generic_json or alertmanager), display_name and optional fallback_service_key, service_label (Alertmanager only, default service). 201 with {"source", "secret"} — the signing secret (aninc_ + 43 characters), returned here and only here (and again from rotate-secret); it is never shown again. The response also sets Cache-Control: no-store. PATCH /incoming-alert-sources/{id} replaces whichever of display_name, fallback_service_key, service_label are present. POST .../{id}/rotate-secret?expected_version=<source-updated-at> issues a new secret under the source’s current clock (old one stops verifying immediately), returned the same one-time way. POST .../{id}/revoke stops the source; every later delivery is 401.
  • GET .../{id}/deliveries returns the source's last 100 outcomes, newest first — never a payload: {received_at, outcome, service_key, dedup_key_digest, state, alert_id, detail}. outcome is one of accepted, updated, resolved, duplicate, stale, ignored, unknown_service, service_not_routable, invalid_payload, bad_signature, revoked, rate_limited.
  • POST /incoming-alerts?source={id} is the public delivery route (not cookie-authenticated). The source query parameter names the source; the credential is in the request itself — generic JSON: X-Anectico-Signature: sha256=<hex> over "<X-Anectico-Timestamp>.<raw body>" (timestamp within 5 minutes); Alertmanager: Authorization: Bearer <secret>. 401 for an unknown source, a bad signature, a revoked source, or a duplicated verification header — all identical, so a caller cannot use the response to enumerate sources. 413 past 256 KiB. 429 INCOMING_ALERT_RATE_LIMITED past 120 requests per minute for one source. 400 INCOMING_ALERT_INVALID for a payload that does not match the provider's documented shape (an unrecognized field, a bad timestamp, and so on). 503 for a transient failure — safe to retry the identical request. On success, returns {"results": [{outcome, service_key, dedup_key_digest, alert_id, detail}, ...]}, one entry per normalized event in the request.

Request and response conventions

  • Send JSON writes with Content-Type: application/json unless an upload endpoint requires a raw symbol artifact.
  • JSON field names are snake_case.
  • Times are RFC 3339; common windows use start_time and end_time. Omitting an optional timestamp selects the endpoint's documented default, while a non-empty malformed timestamp returns 400 invalid_request instead of silently selecting that default.
  • GET /persons/{person_id}/timeline accepts start_time and end_time to bound the merged customer story. Keep those bounds unchanged while following its opaque pagination cursor.
  • Repeated filters may accept repeated parameters or comma-separated values, depending on the resource.
  • Paginated reads commonly accept limit and an opaque cursor. Return the next cursor unchanged; do not decode or construct it.
  • Log, trace, Errors, and alert-rule paginated reads—including affected-customer results—reject a malformed non-empty pagination cursor with 400 InvalidArgument; they never treat it as a request for the first page.
  • Server limits clamp page sizes. Do not assume one maximum across resources.
  • A paginated read reports has_more together with a next_cursor, and sets neither once the page is the last one. Treat a full page with has_more false as the complete result, and keep following next_cursor while has_more is true rather than assuming one page is everything.
  • total_count is a hint, and has_more/next_cursor are the contract. Page on those. A count is measured a moment apart from the page it accompanies, so on data still being written it can lag; it will never be smaller than the results in the same response, but treating it as the loop condition can end a read early.
  • pagination.total_count is present only when the endpoint counted the matches. An endpoint that does not count leaves the field out of pagination. An absent total_count means "not counted"; it never means zero. A present "0" means the endpoint counted and nothing matched. The project list and the person timeline are two reads that do not count.
  • Those two properties hold on every paginated read, not just the busiest ones. total_count is never below the number of results in the same response. For offset-based reads, it also covers the matching rows skipped by that offset. has_more is decided by the result set itself rather than by the count, so a lagging count can no longer end your loop early. If you have been treating total_count as the loop condition, switch to has_more: it is the only one of the two that is exact.

GET /account/members returns members and a pagination object containing next_cursor, has_more, and total_count. Its default page size is 50 and its maximum is 1000; larger limits and malformed non-empty cursors return 400. Members are ordered by when they joined, with a stable tie-breaker. Follow the returned cursor unchanged, keeping any role filter, until has_more is false to read the complete matching roster. The total is a hint for that roster; it does not decide whether another page exists.

The following collection routes all accept limit and opaque cursor. Their default page size is 100 and their maximum is 500, except flags, dashboards and account segments, whose default is 50. Preserve next_cursor exactly and continue until has_more is false. Native page responses expose next_cursor, has_more and returned_count inside pagination; bounded complete collections expose them at the top level alongside the named item array. Follow the response's shape. A count is the returned page, never an inferred inventory total. A disclosed source cap with has_more: true and no cursor is incomplete; narrow the supported selections instead of repeating an empty tail.

Route Item array
GET /services services
GET /alerts/silences silences
GET /releases releases
GET /errors/groups/{group_id}/releases breakdown
GET /errors/groups/{group_id}/merged-sources sources
GET /incidents incidents
GET /evaluation/release-series releases
GET /evaluation/datasets/{datasetId}/versions/{version}/items items
GET /account/api-keys api_keys
GET /oncall/schedules schedules
GET /oncall/schedules/{id}/overrides overrides
GET /users/me/contact-methods contact_methods
GET /escalation-policies policies
GET /flags flags
GET /group-types group_types
GET /llm/pricing overrides
GET /dashboards dashboards
GET /account-segments segments
GET /connections connections
GET /connections/{id}/identity-mappings mappings
GET /issues/{group_id}/tickets tickets
GET /notifications/channels channels
GET /notifications/templates templates
GET /evaluation/evaluators evaluators
GET /evaluation/sampling-rules rules
GET /evaluation/datasets datasets
GET /evaluation/experiments experiments
GET /evaluation/gates gates

PATCH bodies are partial. On these control-plane routes, an omitted mutable field keeps its stored value; it is not replaced by that field's empty or false default:

PATCH /api/v1/alerts/rules/{id}
PATCH /api/v1/alerts/silences/{id}
PATCH /api/v1/account-segments/{id}
PATCH /api/v1/notifications/channels/{id}
PATCH /api/v1/notifications/templates/{id}

This distinction applies to scalar, object, map, and list fields. For example, a channel body with only config keeps its name and is_enabled state; "is_enabled": false explicitly disables it. A template rename keeps its subject and template. A rule severity edit keeps its pause state, labels, notification channels, template, and escalation policy. A segment rename with exact expected_updated_at first previews the full state and keeps its group_type and filters, and extending a silence window keeps its comment. An explicitly empty value is still an edit when that value is valid: for example, "notification_channels": [] detaches every channel, while "filters": [] deliberately changes an account segment to match every account of its group type. Omit a field when you intend to preserve it.

Rule PATCHes must include the current version; a stale value is rejected rather than overwriting a newer edit. These PATCH routes reject unknown JSON fields with 400 invalid_request, so a misspelled safety-sensitive field cannot look as if it was applied.

GET /api/v1/invitations?status= accepts the exact invitation enum names returned by the API: INVITATION_STATUS_PENDING, INVITATION_STATUS_ACCEPTED, INVITATION_STATUS_REJECTED, or INVITATION_STATUS_EXPIRED. An unknown non-empty spelling returns 400 invalid_request; it never widens into the unfiltered invitation list.

Alerting and notification behavior

A forced evaluation can report that it measured nothing

POST /api/v1/alerts/rules/{id}/evaluate

An aggregate over a window that matched no rows is not a value. max, min, avg and the percentiles have no answer over an empty window, and the number that comes back for them is a placeholder rather than a measurement — so the result carries "unmeasured": true, an unmeasured_reason, "rows_scanned": 0, and "observed": null on each condition. Nothing fires on such a tick, and nothing resolves either: an alert the rule already had stays open and picks up an unmeasured_since timestamp naming when its evidence went away, because "no data" is not "the problem cleared".

A third case exists and is distinct from both: if the window held rows but the aggregated column was NULL in every one of them, you get "unmeasured": false with "observed": null — the platform looked, and the reducer had no answer. Read the two fields as a pair.

Read observed, not value. value is 0 both when nothing was measured and when the measurement really was zero; only observed: null tells the two apart.

count and sum are not affected — the count of an empty window is zero and the sum of an empty window is zero, and both are answers. Use a count-based comparison, or an absence condition, when you want a rule that pages because a window is empty.

An error budget is readable without being paged

GET /api/v1/slo/{rule_id}/burn

An SLO rule states a quality objective. This answers the two questions an alert on it cannot: how much of the error budget is left, and how fast is it going. The budget is (1 - target) times the evaluations in the compliance window, reported as a count of events — "18 of 30 failing runs remain" is actionable in a way that a percentage is not. The burn rate of a window is that window's own failure ratio divided by the ratio the objective allows — (failing / total) / (1 - target) — so a rate of 1 spends the budget exactly over the compliance period and 2 spends it in half. It does not depend on how many evaluations happened to land in the window, so a quiet hour and a busy one with the same failure ratio report the same burn.

Everything defaults from the rule's own burn policy. ?window=6h&window=30m, ?target=, ?compliance_window= and ?factor= override it when you want to ask a different question of the same objective.

A window holding no evaluations comes back with status: "SLO_BURN_STATUS_NOT_MEASURED" and an unmeasured_reason, never a burn_rate of 0. Those mean opposite things: zero says the objective is healthy, unmeasured says nothing was evaluated. would_fire is the same decision the rule itself makes — both the long and the short window over factor — and is false with a reason whenever that decision could not be made at all.

Alert acknowledgement is reversible

POST /api/v1/alerts/{id}/acknowledge
POST /api/v1/alerts/{id}/unacknowledge
GET  /api/v1/alerts/acknowledgments?alert_id=...
GET  /api/v1/alerts/history?rule_id=...&state=...&severity=...&start_time=...&end_time=...

Acknowledging an alert suppresses its escalation, so the ability to withdraw one matters: a mis-click no longer permanently silences a real alert. Acknowledgements are a sequence, not a flag — the same alert can be acknowledged and withdrawn more than once, and /alerts/acknowledgments returns each one with its actor, time and note. The current state is the single boolean on the alert; the history is what a post-incident review needs.

/alerts/history lists alerts in every state, resolved included, newest first. Every filter is optional: rule_id answers "how often has this fired", alert_id reads one alert, and state, severity, start_time and end_time (RFC 3339) narrow the rest. An alert_id or rule_id that is not a UUID returns 400; an alert that does not exist returns an empty alerts array. The response carries a pagination object: pass limit (default 100, maximum 1000) and send pagination.next_cursor back as cursor until has_more is false.

Silences can be read and edited, not only created and deleted

GET   /api/v1/alerts/silences/{id}
PATCH /api/v1/alerts/silences/{id}

Editing a silence keeps its identity and its audit trail. Deleting and recreating one to change its window does not, so prefer the PATCH. The silence's author is preserved across edits — editing does not transfer authorship. Send only the fields being changed; omitted matchers, window endpoints, name, and comment remain unchanged.

Notification channels can be edited without restating their alerting state

GET    /api/v1/notifications/channels
POST   /api/v1/notifications/channels
GET    /api/v1/notifications/channels/{id}
PATCH  /api/v1/notifications/channels/{id}
DELETE /api/v1/notifications/channels/{id}
POST   /api/v1/notifications/channels/{id}/test

The PATCH is partial. Updating only a webhook URL or other config value leaves the channel's name and enabled state unchanged. Send is_enabled: false only when the channel should stop delivering.

Notification templates decide what a channel says

GET    /api/v1/notifications/templates
GET    /api/v1/notifications/templates?channel_type=slack
GET    /api/v1/notifications/templates/effective?channel_type=slack
POST   /api/v1/notifications/templates
GET    /api/v1/notifications/templates/{id}
PATCH  /api/v1/notifications/templates/{id}
DELETE /api/v1/notifications/templates/{id}

/effective answers a different question from /{id}: it is the template that would be used for a channel type right now, which is what you want when checking what a Slack notification will actually look like. Asking for a template by id tells you about that template, which is not necessarily the one that would be sent.

An organization has at most one custom template per channel type; creating a second returns 409. When no custom template exists, /effective returns 404 and deliveries on that channel type use the built-in default template.

Template PATCHes are partial too. A body such as {"name":"Primary page"} changes only the name; omitted subject and template fields retain their stored content. Add ?expected_version={version} with the version you last read to make the change conditional: if the template was edited since, the PATCH returns 409 and changes nothing, so re-read it and decide again. Without the parameter the PATCH applies unconditionally.

  • Use IDs returned by the API for updates and deletes; do not derive them from display names.
  • A write normally answers 200: the change is committed and a read issued straight afterwards already reflects it. A write whose effect is served from a list that is rebuilt just behind it can answer 202 instead. 202 is a success and the change is committed and permanent — what it adds is that the corresponding read has not been confirmed to agree yet, so a read issued immediately afterwards may briefly still show the previous value. Re-read to see the settled state; do not repeat the write. DELETE /account/members/{user_id} is the endpoint that answers this way today, and repeating it returns 404 because the member is already gone.

Write outcome lookup

POST /api/v1/write-outcomes/lookup takes action, the exact original arguments, and exactly one of idempotency_key or request_id. Omit confirmation tokens. The response contains action, state, durability and a recorded result when available. The CLI is anectico writes lookup ACTION; MCP uses lookup_write_outcome through execute_read_action. See Write recovery for current-authority checks, retention, one-time-secret withholding and how to handle an unknown outcome.

Errors and retries

Errors use a JSON envelope:

{
  "error": "PermissionDenied",
  "message": "permission denied"
}

The error string comes from one of three families, and which one you get says where the refusal was decided: most codes are a canonical status name in PascalCase (PermissionDenied for ordinary insufficient-scope, NotFound, InvalidArgument, and so on); ordinary 5xx errors answer with a fixed, redacted lowercase code (internal_error or unavailable) regardless of the underlying fault; and a handful of refusals made before the request is processed — a malformed request body, a project-scope conflict (lowercase permission_denied), or an API-key project-floor refusal (forbidden) — use their own lowercase snake_case code. Match on the exact string; casing alone is not a reliable signal.

Treat 400 as invalid input, 401 as a missing or invalid credential, 403 as insufficient scope, 404 as missing or inaccessible, 409 as a state conflict, and 429 as a rate or plan limit. Retry 429 and temporary 5xx responses only with bounded exponential backoff, honoring Retry-After when present. Do not automatically retry validation or permission failures.

A canceled, timed-out or unavailable write can already have taken effect. Product writes through REST, MCP and the CLI report an unknown outcome using the mutation receipt below. Read the object back before trying again. If the action supports idempotent retries, use only the identical arguments and the same idempotency key; a new key means new work. The temporary retry cache alone is not proof that an unacknowledged write can be safely repeated.

Failure Reads A write dispatched without a confirmed outcome
Canceled (REST 499) No answer was returned; call again if you still need it. isError: true, structuredContent.outcome: "indeterminate", structuredContent.errors[].code: "write_outcome_unknown".
Timed out (REST 504) Narrow the window or filter and retry. The same indeterminate receipt; read back state or use an identical supported idempotent retry.
Temporarily unavailable (REST 503) Retry shortly, honoring Retry-After. The same indeterminate receipt; REST keeps Retry-After: 1.

Writes through the shared server approval boundary return that receipt. The direct analytics result cancellation endpoint instead keeps the JSON error envelope with error: "write_outcome_unknown" and the same reconciliation guidance. Read that result's status back before deciding to cancel it again; do not create a new execution to recover a lost reply.

An indeterminate receipt leaves changed, committed_at and mutation_id null and lists no confirmed targets. Its error has retryable: false: it requires reconciliation rather than an automatic retry. The CLI returns the receipt and exits unsuccessfully, describing the write outcome as unknown. Your agent decides the next step. A preview or a typed refusal before a write starts keeps its own refusal guidance; it does not mean an uncertain write occurred.

A 409 never becomes a success on its own. It covers two situations, and neither is fixed by sending the same request again: something you named is already taken — a connection for that provider, an escalation policy or on-call schedule with that name in that project, a contact method with that address for that user — or the version you supplied is stale. Change the request (a different name, or a re-read version) or change the state (retire what is holding the name), then send it once. An agent that retries a 409 unchanged will loop until it gives up.

Some 400 responses are not about your request. A capability whose backing store is not present in your deployment answers 400 with a message naming the capability — "error reads are not available in this deployment", "session replay is not available in this deployment". Sending a different request will not change the answer; the capability is absent for everyone on that deployment until an administrator enables it. Distinguish it from a validation failure by the message: a validation failure names a field of your request, this names a product capability.

No error message names the software behind Anectico. Message text describes your request or a product capability, never a database, queue, cache, identity provider or internal component. If you are matching on message text, match on the stable leading phrase, and prefer error and the HTTP status class over free text in every case.

Every 2xx is a success, 202 included — branch on the status class, not on equality with 200, or a committed write will read as a failure. See Request and response conventions for what 202 adds.

When a request body will not parse

A body that cannot be read into the endpoint's shape returns 400 invalid_request with a diagnostic in parentheses, so a client — or an agent acting on its own — can tell a malformed request from a server fault and correct it without a second round trip:

{
  "error": "invalid_request",
  "message": "request body does not match this endpoint (line 1, column 24; invalid_value; type google.protobuf.Timestamp)"
}

Read it as follows.

  • The text before the opening parenthesis is stable. Match on that, or on error, rather than on the parenthetical.
  • line and column locate the failure in the body you sent, counting from 1.
  • The middle term names the kind of failure: syntax (the bytes are not JSON), truncated (the body ended early), type_mismatch (a value of the wrong JSON type — a string where an object or a list belongs, for example), invalid_value (the right type, but not a value the field accepts — a timestamp that is not RFC 3339, for example), duplicate_field, unknown_field, or unclassified.
  • field and type, when present, name the field that rejected the value and the type it expects. A list field reads list of <type> and a map field map of <type>. Either may be absent when the failure happened before a field could be identified.

The diagnostic never contains the value you sent. It carries only a position in your request and names drawn from the published API schema, so a malformed body carrying customer data does not come back in the error — and does not reach a log. If a value is being rejected and the reason is not obvious from the field and type, compare against the field's documented format rather than expecting the value to be echoed.

The most common cause of invalid_value is user-supplied data reaching a typed field: an email, an identifier or a token placed where a timestamp or duration belongs. Validate those fields before sending rather than relying on the rejection.

Discover exact operations

The installed CLI mirrors the public surface and can print its current command contract:

anectico docs             # JSON manifest for tools and agents
anectico docs --markdown  # readable command and flag reference

Use the current CLI manifest (anectico docs) for exact early-access request bodies. Do not copy internal gRPC or service routes into an external integration.

Capture-attempt quality

GET /api/v1/events/capture-quality requires analytics:read. Supply project_id for an organization credential; a project credential uses its signed project and cannot select another. start_time and end_time are required RFC3339 timestamps, at millisecond precision or coarser, forming an increasing half-open event-time window from 1970 of at most 183 days. Optional environment selects one exact environment; empty selects all. Unknown or repeated parameters fail.

The response separates product and identity control positions into pending, queued, uncertain and not_attempted, with separate invalid and quota_rejected counts. These are submitted positions, including retries, not unique events or lost user actions. Valid input is attributed by coarse UTC-hour overlap, so a short window can include adjacent events in the same hour. Invalid input has no trustworthy time and environment and covers all retained attempts for this project. Attempts without a project are not assigned to it.

source_completeness is always unknown: queued does not mean consumed, zero does not prove absence of loss, and client sampling declarations do not establish actual upstream coverage, withholding, retention availability or processing progress. retained_project_requests is the number of retained requests examined, including those outside the selected window. digest is opaque current evidence that can change when any retained project attempt changes; it is not a saved result ID or authorization grant. The current whole-read ceilings are 10,000 requests, 8 MiB and 100 distinct sampling source and rate groups in the selected window; exceeding a ceiling returns HTTP 429 without a partial count. Storage failures, deleted scopes and invalid requests also fail instead of returning an apparently empty result. Deletion propagation and physical cleanup are asynchronous.

sampling_provenance is declared. Each entry in sampling_declarations contains source, rate, state (declared_sampled for rates below one, declared_unsampled for one), product publication counts and quota_rejected. sampling_unknown contains the remaining undeclared product publication and quota counts. Declarations plus unknown equal the corresponding product and quota totals; identity-control and invalid positions are excluded. Entries are ordered by source, then numeric rate. sampling_declaration_limit reports 100. These are submitted positions, not a count of unique stored events, sampled-away events or authorized source identities. The same environment and coarse hour-overlap selection applies. See declaring sampling for the reserved capture property and validation rules. The complete request evidence, including these declarations, participates in the digest; a changed retry cannot overwrite an earlier stored event.

The same operation is available through anectico analytics quality --start-time ... --end-time ... and the MCP get_capture_quality read action (also requiring mcp:read).

Windowed event catalog

GET /api/v1/events/catalog requires analytics:read and an active project. Select project_id for organization credentials; project credentials cannot select another project. Required start_time and end_time are increasing RFC3339 timestamps with millisecond precision or coarser, forming [start,end) within currently readable history and at most 90 days. A future end_time is allowed, but does not establish completeness for the future portion of the window. Optional environment is exact (empty selects all), prefix is literal and case-sensitive, limit defaults to 50 (maximum 200), and cursor continues a previous page. Unknown or repeated parameters are rejected.

The response includes the effective org_id, project_id, selection, and events ordered by name. Each event has name, converged occurrence count, first_seen and last_seen, all limited to the selected window. Properties, examples and person identifiers are not returned. next_cursor accompanies has_more; preserve all filters and page size when continuing. Cursors expire 15 minutes after the initial read without renewal. Start a fresh read after expiry.

ingested_before preserves the initial receipt cutoff; observed_from and observed_until describe this page's database read. These do not freeze the source or prove consumption. Late consumption and deletion may change later pages. platform_completeness and upstream_completeness are both unknown, even for an empty page. Results are returned with Cache-Control: no-store.

Invalid selections and cursors and expired or unavailable project history fail. A resource-budget refusal returns HTTP 429 without partial counts; narrow the window or prefix. Use the event catalog guide for MCP get_event_catalog and CLI analytics catalog usage.

Typed event property schema

GET /api/v1/events/schema requires analytics:read. Provide project_id, exact event, inclusive start_time, exclusive end_time, and optional exact environment. Project credentials cannot select a different project. Windows use millisecond RFC3339 timestamps and span at most 90 days within current project history.

The version-1 JSON response echoes the selection and returns observed_events, properties (key, present or missing, nonzero JSON type counts, finite_numbers, window-local first_seen or last_seen), visibility: "permitted_stored_properties", receipt and read clocks and both completeness fields set to unknown. There are no raw values and examples or hidden-key lists. Content-derived summaries require the requesting content scope and current project policy and class retention. Empty schemas and omitted fields do not prove absence. Results use Cache-Control: no-store.

There is no pagination or successful truncation. Current limits are 250 distinct keys of up to 512 UTF-8 bytes, 250,000 events, 8 MiB source strings and a ten-second service deadline, plus database limits. Exact environment strings allow up to 32,768 UTF-8 bytes and are not trimmed. A budget error returns 429; a narrower window can reduce rows, bytes, and property diversity, but cannot shrink one event's property bag. Temporary source unavailability returns 503 with Retry-After: 1. Unavailable current policy and history or a mid-read policy change returns 400 (FailedPrecondition); no evidence is substituted with zero. See event property schemas for field semantics and the equivalent get_event_schema MCP action and anectico analytics schema command.

Exact integration-event verification

POST /api/v1/instrumentation/events/verify requires analytics:read and is a read: no confirmation token, nothing changes, nothing is stored. The JSON body names project_id (optional for a project-bound key; a different project is refused), window_start and window_end (event time, half-open, millisecond precision, at most one hour), 1 to 20 samples (event, message_id, source_id, distinct_id, optional session_id, optional groups (the exact account, group type to group key, as sent in properties.$groups), optional property_checks of key, explicit required and JSON types; the key $groups is refused there) and an optional pair tracking_plan_id + tracking_plan_revision (decimal string). Unknown fields and a body org_id are refused with 400.

The answer keeps the request order and gives per-check state (observed_match, mismatch, not_observed, withheld, unavailable, ambiguous) with a reason, a per-sample outcome (verified, failed, undetermined), requested-sample counts, checked_at, read_cutoff, read clocks, the loaded plan's definition_hash, fixed limits, limitations, read_coverage and an empty links. tracking_plans:read, persons:read and agents:content:read add the plan, canonical identity and property checks; without them those checks are withheld. A reached limit, failed source or history cut is unavailable, never not_observed. A third concurrent verification for the project returns 429; a scope, policy or evidence change during the read returns 409 and no report. Results use Cache-Control: no-store. See integration event verification for every field, the equivalent verify_integration_events MCP action and anectico doctor events.

Feature-flag exposure verification

POST /api/v1/instrumentation/flags/verify requires flags:read and analytics:read and is a read: no confirmation token, nothing changes, nothing is stored. The JSON body names project_id (optional for a project-bound key; a different project is refused), window_start and window_end (event time, half-open, millisecond precision, at most one hour) and 1 to 20 exposures (message_id, source_id, flag_key, expected_value (true, false or a variant string), expected_flag_version (UTC, six fractional digits), evaluation_mode (remote or local), test_case_id, expected_evaluation_status, expected_is_fallback, expected_input_context (basis, optional required_inputs_present and input_binding), distinct_id, optional session_id). Unknown fields and a body org_id are refused with 400.

The answer keeps the request order with per-check state and reason for the event, identity, session, flag key, value, definition version, mode, test case, status, fallback and input context, a per-exposure outcome, the recorded fields (value presence and type only, never the value), the flag's current_definition version, and one comparisons entry per flag key and test case (established or not_comparable with bounded reasons, the exact pair indexes and, when established, agreement). persons:read and agents:content:read add canonical identity and the recorded flag fields; without them those checks are withheld. Limits, read_coverage, limitations and the empty links match event verification. POST /api/v1/decide also returns project_id, feature_flag_versions and feature_flag_results. See feature-flag exposure verification for every field, the equivalent verify_flag_integration MCP action and anectico doctor flags.

Integration job verification

POST /api/v1/instrumentation/verify requires query:read and is a read: no confirmation token, nothing changes, nothing is stored. The JSON body names job (product_events or flags), start_time and end_time (event time, half-open, millisecond precision, at most one hour), project_id (optional for a project-bound key; a different project is refused) and only that job's fields: samples with an optional tracking_plan_id and tracking_plan_revision for product_events (which also requires analytics:read), or exposures with an optional boolean compare_local_remote for flags (which also requires flags:read and analytics:read; every exposure expects evaluated and expected_is_fallback: false). A missing, unknown or not yet available job, a field of the other job, an unknown or repeated field and a body org_id are refused with 400 before anything is read.

The answer has verdict (verified_sample, failed or undetermined), the ordered required checks (check, required, state, reason, selector), population, the requested and effective window, checked_at, sources (read cutoff, clocks and limit state), upstream_completeness: "unknown", limitations, an always-empty links and evidence holding the answer of the event or flag verification read exactly as its own route returns it. A product event job needs no error, trace, log or replay. See integration job verification for every field, the equivalent verify_integration MCP action and anectico doctor integration.

Tracking-plan configuration reads

GET /api/v1/analytics/tracking-plans lists live metadata; GET /api/v1/analytics/tracking-plans/{plan_id} reads a current or retained definition. Both require tracking_plans:read and exact project scope. Lists accept limit (default 50, maximum 100) and after_plan_id; detail accepts revision (0=current). These are non-cacheable configuration reads, not telemetry queries. See Manage tracking plans.

GET /api/v1/analytics/insights lists and searches saved-insight metadata with project_id, optional search, limit (0 defaults to 50; maximum 100) and after_insight_id. GET /api/v1/analytics/insights/{id} returns current or exact retained configuration with project_id and optional nonnegative revision (0 selects current). Both require insights:read and recheck current credentials before disclosure. List returns insights, has_more and next_after_insight_id; get returns insight. Responses use protobuf JSON with integer revisions encoded as strings. See Manage saved insights.

POST /api/v1/analytics/insights/preview is a preview-only route for a current human session. POST /api/v1/analytics/insights/apply accepts the authenticated management actor and uses the same server preview and confirmation flow as MCP and CLI. Both require insights:read plus insights:write (create or update) or insights:delete (retire), and the definition's current source scopes. No query parameters are accepted. Bodies contain project_id, insight_id, UUID mutation_key, exact decimal-string expected_revision, title, description, definition, and delete. Create uses revision "0"; update or delete require the selected positive revision. Retirement omits metadata and definition. Organization comes from the verified principal. Responses are non-cacheable. The approved preview includes complete current and proposed revisions, the full recipe, metadata, definition hash, actor and checked scopes. Commit timestamps are assigned on apply. Keep the UUID mutation key and identical arguments when confirming or retrying. The confirmed apply returns a mutation receipt; its audit delivery remains a separate fact.

POST /api/v1/analytics/tracking-plans/preview and /api/v1/analytics/tracking-plans/apply require a current human session, tracking_plans:read and the operation's write and delete scope. Agents use confirmed MCP actions, also available through CLI. Strict protobuf JSON contains project_id and mutation (plan_id, mutation_key, decimal-string expected_revision, name, definition, delete). Preview changes nothing; apply returns plan, mutation_key and applied. Retire with an exact positive revision and no name and definition. Sensitive protections persist. Unknown or repeated fields and query parameters are refused. All tracking read or write responses use exact decimal-string revisions. See Manage tracking plans.

Numeric aggregates outside the finite numeric range return a terminal execution response with HTTP 200, status: "PRODUCT_RESULT_STATUS_FAILED" and failure_reason: "PRODUCT_RESULT_FAILURE_REASON_METRIC_OVERFLOW", without measurement data. Narrow the selection, then query with a new execution key. Retrying the frozen result cannot change its sum. This is a numeric limit, not a platform fault.

Analytics history refusals use HTTP 400 with error: "FailedPrecondition" and details: {"reason": "ANALYTICS_HISTORY_UNAVAILABLE"}. This applies to query, result, participant and contribution reads. If the background executor reaches the refusal after admission, the execution instead returns HTTP 200 with status: "PRODUCT_RESULT_STATUS_FAILED" and failure_reason: "PRODUCT_RESULT_FAILURE_REASON_HISTORY_UNAVAILABLE", without measurement data. That failed result is terminal. Choose a supported recent window and audience source selection and explicitly query with a new execution key; repeating an expired frozen execution cannot recover history. Other precondition failures do not imply this reason. See analytics history recovery.

A measurement kind this release cannot run uses the same HTTP 400 and error: "FailedPrecondition" with details: {"reason": "ANALYTICS_KIND_UNAVAILABLE"}. The definition is valid, so editing it and resubmitting cannot succeed, and no result_id is issued to poll. Trends, person funnels, exact-period retention and funnel correlation all run in this release.

Missing retained capture provenance or a contradiction with admission records prevents a certified measurement. Inline and saved definitions use the same accepted execution lifecycle: the query issues a result_id and returns an HTTP 200 lifecycle envelope. Once the source check fails, status is PRODUCT_RESULT_STATUS_FAILED and failure_reason is PRODUCT_RESULT_FAILURE_REASON_SOURCE_UNADMITTED, with no measurement payload. Both wait_millis: 0 and wait_millis: 25000 use this path; the wait changes only whether the response already contains the terminal state. Poll pending or running executions, not a failed one expecting numbers. A direct refusal outside this accepted lifecycle can carry HTTP 400, error: "FailedPrecondition" and details: {"reason": "ANALYTICS_SOURCE_UNADMITTED"} instead.

An absent provenance record is not proof the event historically bypassed capture. The check covers whole UTC hours around the relevant windows and the selected environment, including events not selected by the measurement. A clean disjoint scope may be measurable but answers a different question. Have an operator investigate and repair the source through supported capture procedures, then query with a new execution key. Do not fabricate admission records or blindly resend older events expecting their original provenance to be reconstructed. Polling or retrying the frozen execution cannot repair this condition.

Building a result's list of people resolves the same people its numbers were measured with. When identity resolution has changed since the result was published, that is impossible, and the participants, contribution and export routes refuse rather than return a different population: HTTP 400 with error: "FailedPrecondition" and details: {"reason": "ANALYTICS_DRILLDOWN_IDENTITY_CHANGED"}. Branch on the reason: the refusal is about the result, not the request, so retrying the page, shrinking limit or choosing another selection_id all fail identically. Run the query again with a new execution key and drill into the result it publishes. This is not ANALYTICS_RESULT_INVALIDATED: the original result is not withdrawn and GET /analytics/results/{result_id} keeps returning its numbers.

A deployment with no storage capacity left answers HTTP 429 with error: "ResourceExhausted" and details: {"reason": "ANALYTICS_STORAGE_EXHAUSTED"}. Branch on the reason, not the status: this 429 is not a rate limit, so Retry-After semantics and backoff do not apply and no amount of waiting clears it. The request is valid, so editing it, narrowing the window or retrying cannot succeed; contact whoever operates the deployment to make room. A saved definition that reaches the background executor reports the same cause as PRODUCT_RESULT_FAILURE_REASON_STORAGE_EXHAUSTED.

Frozen analytics audience preview

GET /api/v1/analytics/results/{result_id}/audience-preview?project_id=PROJECT_UUID&selection_id=SELECTION_UUID

Read-only, Cache-Control: no-store. Requires current analytics, person, and source read permissions. Only project_id and selection_id query parameters are accepted. Returns the complete selection's exact population counts, state (eligible, unresolved, unsupported, too_large), provisional member_limit, source scopes and original result, snapshot, computation, and expiry references. It returns no member IDs and creates no cohort. See audience preview.

Exact audience generations and dashboard writes

GET /api/v1/analytics/audiences/{id}?project_id=PROJECT_UUID&generation=1 requires an exact positive decimal generation and current analytics, person and source read permissions. It returns audience with its immutable source references, count, lifetime and generation; it does not return member IDs. Missing, expired, pruned or inaccessible generations are refused.

POST /api/v1/analytics/audiences/preview is a preview-only route for a current human session. POST /api/v1/analytics/audiences/apply accepts the authenticated management actor and uses the same server preview and confirmation as MCP and CLI. No query parameters are accepted. Bodies contain project_id, UUID mutation_key, result_id, selection_id, cohort_id, decimal-string expected_generation, name, description and lifetime_seconds (1–2,592,000). Create uses an empty cohort ID and generation "0"; replacement uses the exact current positive generation and empty metadata. Organization comes from the verified principal. Preview shows complete before and proposed generation metadata and canonical person sets, lifetime and current source scopes; it reserves nothing. Apply needs the identical arguments and confirmation token and returns the mutation receipt. Keep the UUID mutation key after an uncertain response. At most 10,000 resolved people may form an audience; unresolved or account populations are refused. Responses are non-cacheable. See save and reuse an audience.

Explicit analytics remeasurement

POST /api/v1/analytics/remeasurement/source prepares an ad-hoc query template from an exact original result, explicit population_mode (fresh_population or saved_audience), and absolute next_window (start, end). Saved mode requires audience with exact id and decimal-string generation. Optional next_comparison replaces the comparison; omission clears it. Execute definition_template with the ordinary analytics query endpoint and a new execution key. POST /api/v1/analytics/remeasurement/compare accepts the identical source request under source and the exact later_result_id. Both endpoints are read-only, accept no query parameters and require current analytics, person, and source permissions. Query execution separately requires analytics:query. Comparison retains separate original and later evidence and refuses a changed question. See remeasurement for retry, saved-generation, expiry and unavailable-original semantics.

Tracking-plan property drift

GET /api/v1/analytics/tracking-plans/{plan_id}/drift compares an explicitly pinned positive revision with permitted stored observations for exact event, start_time, end_time, optional environment and selected project_id. Requires tracking_plans:read and analytics:read; content policy still governs disclosed property rows. The response contains exact source coordinates, the permitted schema observation and per-property comparison rows. Unavailable evidence has null counts. Reads are non-cacheable, repeated and unknown parameters are rejected, and current authority and retained declaration are rechecked before disclosure.

The response also carries "did you mean" annotations. undeclared_events is an array of {name, observed_count, suggestion} for the event names observed in the same window that the pinned revision does not declare; observed_count is null when unavailable, and the array itself is null when the annotations could not be read at all, which is different from an empty array. undeclared_events_truncated is true when the read stopped at its bound and more undeclared names may exist. suggestions_unavailable_reason is present only when the whole annotation read failed. Each undeclared property row may carry the same suggestion object, and no other row does. A suggestion is {suggested_name, probability, unavailable_reason}, exactly one of suggested_name and unavailable_reason being non-null: suggested_name is a name declared at this revision and appears only above a calibrated confidence bar, and unavailable_reason is one of pending, none, below_bar, key_not_benign or a reason the judgment could not be made. Annotations never change status, a property row's outcome or which rows are returned.

See tracking-plan drift semantics and did you mean.

Account erasure

POST /api/v1/projects/{projectId}/group-erasures permanently erases one account. It requires groups:erase, a caller-generated operation_id UUID and a reason. The default GROUP_ERASURE_MODE_GROUP_ONLY keeps people and their events. GROUP_ERASURE_MODE_WITH_MEMBERS also requires persons:erase; shared members are skipped unless include_shared_members=true. More than 256 active membership edges refuses the whole request.

JSON group_type and group_key carry the exact account target. Dots (. and ..), slashes, percent signs, spaces, quotes, backslashes and Unicode need no URL escaping. Preserve exact spelling. A project-bound credential whose project differs from the path receives 403 on creation and reads, before any operation is forwarded. Read GET /api/v1/projects/{projectId}/group-erasures/{operationId} or list GET /api/v1/projects/{projectId}/group-erasures?limit=50&cursor=... (limit 1–200). The response is an erasure with operation_id, project_id, group_type, group_key_digest, mode, requester, reason, timestamps, state, stores and members, plus already_recorded on POST. Account keys and member identifiers are not returned. Member receipts include digests, child_operation_id, shared, skipped_shared, state and completion time, plus already_erased and satisfied_by_operation_id when a previous erasure satisfied the saved child. GROUP_ERASURE_STATE_STALLED records required work past 30 minutes with stalled_at and stalled_reason; missing parts also report STALLED. GROUP_ERASURE_STATE_COMPLETE means every required part and unskipped child has finished. Reuse the UUID only to retry the exact same request. The erased key cannot be reused. See Erase an account for what is removed and what stays.

Reproduction evidence for one Issue occurrence

GET /api/v1/errors/groups/{group_id}/reproduction reads recorded evidence on demand. It saves no reproduction artifact. Requires errors:read. Set project_id for an organization credential. A project credential is pinned to its own project.

Query parameter Meaning
project_id Project containing the Issue.
occurrence_id Exact recorded occurrence. Default: latest by timestamp, then id. An occurrence from another Issue is refused.
omit_request, omit_trace, omit_release, omit_replay, omit_flags Optional booleans, default false. An omitted part reports cut with omitted_by_caller.

The response names group_id, project_id, occurrence_group_id, selection_rule and the chosen occurrence. There are exactly five parts: request, trace, release, replay, flags. Each has status.state (present, withheld, not_collected, not_found, unavailable, cut; protobuf JSON uses the REPRODUCTION_STATE_ enum prefix), a fixed reason and any required scope. returned, available, unit and available_known describe a cut; an unknown available count is not zero. A part failure leaves the other parts readable.

The request fields method, url, route, response_status, headers, query_string, body each carry their own status and any stored value. Only method, URL and headers are stored on the occurrence. URL and header detail require agents:content:read and the content policy. A separate route, status or query string is never inferred. Release data contains recorded metadata only; release impact and adoption are not measured here.

Optional scopes complete the answer: traces:read, releases:read, replay:read, replay:content:read, analytics:read, agents:content:read. A missing scope reports withheld, including the revealing scope. A policy refusal may have no revealing scope. Recorded text is untrusted data.

The trace window is 24 hours before the failure through one second after it. Session steps are the first page of the whole recorded session, using the browser clock. Flag exposures use the inclusive 24-hour window ending at the occurrence, for both recorded person and session when both exist. Each exact key is separate from its cleaned display_label. A row gives the latest observed value, last_seen_at, observations and whether the value was cut. flags_available counts keys in the readable window before the output cap; history_cut states when retention shortened that window. A missing exposure is not an off value.

There is an eight-second overall deadline and two seconds per dependent part, with four concurrent reads and no retry loops. Output caps are 40 spans, 100 steps, 50 flags and 64 KiB of protobuf data per dependent part. Request fields are at most 4096 bytes. Exact flag keys over 4096 bytes are omitted; values are at most 1024 bytes. The recording's existing processing bounds are not raised. Larger recorded identifiers are cut and never used as follow-up keys.

Every answer states these limits: replay steps never return typed input values; request bodies come only from masked replay network capture when it was on; flag state is observed exposures, not everything (evaluations without an exposure event are invisible and server-side evaluations may have no person); a reproduction is not guaranteed. Captured network bodies have no request-to-trace identifier, so this bundle reports the failing body's association as unavailable. It never guesses from a URL match.

MCP: get_issue_reproduction through execute_read_action. CLI: anectico issues reproduction ISSUE_ID --occurrence OCCURRENCE_ID. The MCP conclusion counts the five parts with fixed words. Missing fields lower certainty. Issue, trace and recording proof links are supplied only for evidence returned by the read; there is no release proof page.

Coverage declarations and observations

The coverage guide documents the JSON importer and exact matching rules. Scope every request to one project_id and exact environment.

Method and path Request / result Scopes
POST /api/v1/coverage/declarations/validate project_id, environment, release, typed entries; returns the proposal, current revision and every refusal; saves no declaration projects:read, tracking_plans:read
POST /api/v1/coverage/declarations Same fields plus exact expected_revision and stable UUID mutation_key; immutable saved declaration projects:read, tracking_plans:read, tracking_plans:write; current human session only
GET /api/v1/coverage/declarations environment, release, optional decimal revision (latest by default) projects:read, tracking_plans:read
GET /api/v1/coverage/declarations/revisions Same pin, optional before_revision, limit up to 100; descending metadata and next cursor projects:read, tracking_plans:read
POST /api/v1/coverage/declarations/withdraw Same pin, positive expected_revision, UUID mutation_key; new withdrawal revision projects:read, tracking_plans:read, tracking_plans:delete; current human session only
GET /api/v1/coverage/compare Pin, optional revision, required absolute RFC3339 start_time and end_time; four lists, counts, matching rule and limits projects:read, tracking_plans:read; complete with query:read, traces:read, analytics:read
GET /api/v1/coverage/observed Environment, optional release, exact start_time / end_time; three observed kinds, volume and sampling state query:read; complete with traces:read, analytics:read, projects:read for the current sampling setting

REST entries use kind of COVERAGE_KIND_SERVICE, COVERAGE_KIND_ROUTE or COVERAGE_KIND_SCREEN. Services and screens carry name; routes carry service, method, route_template; note is optional. The CLI file uses the three arrays instead. Verified principal and commit time are server-derived.

API-key agents write through confirmed MCP declare_coverage / withdraw_coverage, including anectico coverage declare --confirm-token <token> and coverage withdraw --confirm-token <token>. First call the command without a confirmation token to inspect its preview; repeat the same arguments with the returned token to apply it. MCP also needs mcp:read/write. Dry-run retries can pass the original expected revision and mutation key to read the exact previous receipt without writing. All numbers are decimal strings on the protobuf REST wire. A withheld, failed or scan-limited kind has no observations or numeric count; measured empty counts are zero. The window is inclusive start, exclusive end, at most seven days and no later than now. Capture completeness is unknown; release-less data is window-only; declarations prove neither deployment nor traffic. There is no comparison proof link.

Saved findings

Every request names project_id; workspace identity and author attribution come from the verified credential. GET /findings/{id} and GET /findings need findings:read. POST /findings, POST /findings/{id}/revise and POST /findings/{id}/withdraw need findings:write. Create and revise also need projects:read and each referenced object's read scope. A create or revise body contains project_id and document:

{
  "project_id": "<project-id>",
  "document": {
    "title": "Checkout failure",
    "conclusion": "The author's conclusion.",
    "measurements": [{
      "name": "Failed checkouts", "value": 3, "unit": "attempts",
      "start": "2026-10-09T00:00:00Z", "end": "2026-10-10T00:00:00Z",
      "population": "This customer's checkout attempts", "population_size": "5",
      "certainty": "counted", "read": "get_person_timeline"
    }],
    "references": [{"kind": "issue", "id": "<issue-id>"}, {"kind": "person", "id": "<person-id>"}]
  }
}

Omit population_size for “not counted.” Reference kinds: issue, trace, person, session, release, incident, dashboard, agent_run, analytics_result, finding. A missing capture implementation returns an unverified reference; supported-owner permission and project failures refuse the save. All stated measurements remain author-stated. Title 256 bytes and conclusion 16384; at most 32 measurements and 32 references, and 128 KiB per write request. Every measurement needs all displayed fields and a finite value; certainty is counted, lower_bound, estimated or insufficient_evidence. A response contains finding: immutable identity and revision coordinates, server created_at, verified created_by (kind and id), state and content with trust: untrusted, document and captured references. Each capture has state, captured_at and facts (name and value). Withdrawn states omit content and original-author identity; they return withdrawal metadata and reason_class. There is no retained title or conclusion in the tombstone. List filters: author_kind, author_id, reference_kind, state, inclusive start and exclusive end creation clocks, limit (default 20, max 100), cursor (next_cursor from the prior page). Lists contain metadata only and next_cursor; total is absent. Cursors are bound to the same project and filters. Withdrawal body contains project_id and reason_class (user_withdrawn default, incorrect, privacy or replaced). It deletes all content in the lineage. See Save a finding for lifetime, teammate snapshot disclosure and person erasure.

Declare a release fix and read its outcome

All paths below are relative to /api/v1. Select the exact project_id in the query string. Read Did my fix work? for the five outcomes, original, readable and excluded denominator and release and time rules.

Method Path Purpose
POST /errors/fix-declarations Declare an immutable fix; does not close the issue
GET /errors/recovery-watches/{watch_id}/outcome Read current counts and permitted people; saves nothing
GET /errors/recovery-watches/{watch_id}/history Read past counts, evaluation times and windows
POST /errors/recovery-watches/{watch_id}/observations Explicitly append this caller's current answer to history
POST /errors/recovery-watches/{watch_id}/supersede Create a correction, preserving the previous declaration
POST /errors/recovery-watches/{watch_id}/withdraw Withdraw the assertion; keep it and its history readable

Every POST first returns a mutation preview with the current declaration, proposed operation, required scopes and structuredContent.preview.confirm_token; it writes nothing. After approval, repeat the identical request with X-Anectico-Confirm-Token or the body's confirm_token. Changed arguments or declaration state refuse the token and require a fresh preview. Confirmations are single use and return a mutation receipt; read the declaration through outcome or history. Recording measures live counts at confirmation time rather than freezing them at preview.

Declare and supersede accept the same JSON body:

{
  "id": "37c2d6a0-1f05-400f-8d76-8de0f7bb04bb",
  "group_id": "checkout-issue",
  "claimed_release": "2.4.1",
  "environment": "production",
  "success_event": "checkout_completed"
}

Use a new UUID for each assertion. Confirmed identical input with the same ID retains the original declaration; a different assertion with that ID is refused. The server supplies declared_at and declared_by. Optional fields: a note up to 500 bytes, either person_ids (at most eight canonical IDs) or operation_ids (at most 20 exact IDs), a shorter baseline_start within the readable seven-day baseline, and observation_end within the next 30 days. A partial subject is refused. Explicit subjects can include people without readable operations; they are not_readable, never recovered. Notes are untrusted evidence. Reads need errors:read and analytics:read. persons:read reveals people; without it the answer names that scope and returns counts only. Declare and supersede also need errors:write and persons:read. Record and withdraw need errors:write. Record and withdraw accept an empty JSON object. History keeps counts only, for 90 days and at most 256 observations. Person erasure may remove past answers. Tenant deletion removes the declarations and their history.

Agent budgets

Agent work limits: GET /api/v1/agent-budgets (policy and current UTC counts), PUT /api/v1/agent-budgets (owner policy replacement), and GET /api/v1/agent-budgets/usage (accepted history). Own reads need activity:read; another credential and defaults need audit:read. Select credential_kind=api_key|oauth_connection and credential_id, or credential_kind=workspace for defaults. History accepts period=hour|day, inclusive from, exclusive to (UTC bucket boundaries) and cursor. A refusal returns 429 with budget_refusal containing class, limit_kind, limit_value, resets_at, subject, owner_can_change and reason. An unavailable check returns 503 for writes and exports; unchecked queries carry X-Anectico-Budget-Checked: false. The CLI exits 1. See Manage agent budgets.

Responder configuration creates require idempotency_key in their request body (or the Idempotency-Key header on routes that offer it). The owner preserves the first result and withholds one-time credentials on replay. Timestamp-fenced configuration updates require the current updated_at as expected_version; numeric template, catalog, and monitor definitions use their stated version or revision. Monitor pause or resume require expected_revision. GET /incidents/{id}/ownership returns incident_version; checklist writes require that counter as expected_revision and return the resulting incident_version. Notification delivery detail pages its attempt history with page_size (default 20, maximum 50) and page_token.

Browser aggregate sharing

Owner control routes are GET/POST /api/v1/app-view-grants and DELETE /api/v1/app-view-grants/{grant_id}. GET uses exact project_id, optional parent_api_key_id, native limit (1–500, default 100) and one opaque cursor. Create and revoke use the shared server preview and confirmation executor, complete immutable arguments, the full admission digest and exact grant version. Token mint is the separate backend parent-key protocol; the token-only GET and OPTIONS projection never becomes an ordinary management principal. Responses, envelopes and refusal statuses are published by the serving customer contract.

See Share a saved aggregate with a browser page for the full credential, theft, lifetime, privacy and external-copy contract.

Recent customers by observed activity

POST /api/v1/persons/recent takes one project_id, an optional time_range with both RFC3339 start and end, and optional limit (default 25, 1–100). Omitted bounds select the last 24 hours; requested ranges span at most seven days. The organization comes from the credential, a project key is pinned, and an organization key must select a project. This summary requires persons:read and returns no profiles or aliases.

The current canonical top list is ordered across the whole measured errors, traces and product-event population before limiting. Counts are decimal strings; read exact eligible and omitted totals, separate source windows and floors, attribution remainders and unknown upstream completeness. Scan and identity limits refuse without ranked rows or totals. There is no cursor or enumerable audience. See Recent customers for the native MCP and CLI examples, erasure or retention semantics and typed refusal remedies.

Survey visitor delivery evidence

POST /api/v1/projects/{projectId}/surveys/{surveyId}/delivery-evidence is a read with a closed selection body: decimal-string revision, required distinct_id, window_start and window_end, and optional shown_id and outcome_id. The half-open receipt-time window is at most one hour. No query parameters, organization, project or survey fields are accepted in the body; no confirmation is required. Responses use Cache-Control: no-store.

It returns separate retained offer, explicit display and outcome stages, subject coverage and binding-qualified independent event checks. It returns no answer values and no link. surveys:read is required; persons:read, analytics:read and agents:content:read with current policy permit their respective attribution, event and protected-ID branches. Missing authority withholds those checks. See visitor delivery evidence for states, nullable fields, current history limits and complete examples.

Stored-result PNG

GET /api/v1/analytics/results/{result_id}?project_id=PROJECT_UUID&format=png requests one picture of the same authorized stored result. It runs no new measurement. Omitted format preserves JSON, including its READY-only authorized ETag and private revalidation. PNG ignores JSON validators: it never has an ETag or returns 304. Unknown, empty or repeated formats are 400.

PNG success is image/png, Cache-Control:no-store, with exact Content-Length. It is a complete 960 × 540 PNG of at most 262144 bytes. The supported trend has one current series, no breakdown, formula or comparison, at most 366 complete intervals and 131072 bytes of presentation metadata. All required qualifications must fit. Capacity and render time are bounded independently of measurement execution. No chart-specific scope, confirmation or second query charge is added.

Artifact refusals are JSON with error, message, render_status, optional render_reason, image_status, image_reason, available result_status and an authorized proof_link where available. A ready viewer can coexist with a refused PNG. All artifact errors use no-store.

HTTP status Artifact refusal
400 Invalid format or query
409 Pending, running, failed or canceled execution
410 Expired or withdrawn execution
422 Unsupported kind, shape or native layout, or invalid presentation
413 Presentation metadata or image exceeds its byte bound
429 Render capacity (render_capacity)
503 Render deadline (render_deadline)

Authentication, permission, not-found and source-policy errors retain their ordinary error status and precede rendering. Request cancellation keeps the ordinary transport handling. Clients must validate actual bytes, parsed MIME, PNG signature and exact header dimensions before full image decoding, then verify completion. The CLI does this with anectico analytics result get <result-id> --render png --out result.png, preserving any existing file on failure. Explicit --wait polls status before a fresh artifact read; no automatic query or refresh occurs. Downloads are delivered snapshots and cannot be recalled.

Observed customer request reliability

These six semantic POST operations are reads. They require a JSON object of at most 64 KiB, reject unknown or duplicate fields and query parameters, and never create a measurement record. Use the same native request object as the matching MCP action. selection carries the explicit project, service, optional exact environment and operation, absolute start_time and end_time, and the complete chosen definition when HTTP statuses are excluded. Organization is taken only from the credential; any supplied org_id is replaced with that verified organization. A project-bound credential may omit its project inside selection; a different project is refused.

POST path MCP action Additional authority
/api/v1/reliability/persons/latency rank_person_latency persons:read, traces:read
/api/v1/reliability/persons/read get_person_reliability persons:read, traces:read
/api/v1/reliability/accounts/latency rank_account_latency groups:read, traces:read, agents:content:read, current account content policy
/api/v1/reliability/accounts/read get_account_reliability groups:read, traces:read, agents:content:read, current account content policy
/api/v1/reliability/persons/preview preview_person_reliability Same authority as person measurements
/api/v1/reliability/accounts/preview preview_account_reliability Same authority as account measurements

MCP additionally needs mcp:read. Optional profiles require persons:profile:read and current profile policy. Same-definition account comparison needs no extra service permission.

For example, the account detail body preserves natural key bytes and explicit zero detail caps:

{
  "selection": {
    "project_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
    "service": "checkout-api",
    "start_time": "2026-10-16T12:00:00Z",
    "end_time": "2026-10-17T12:00:00Z"
  },
  "account_type": " company ",
  "account_key": " /客户? Acme ",
  "operations_limit": 0,
  "evidence_limit": 0
}

A rank request uses optional percentile, minimum_samples, limit and cursor; person requests also accept include_profiles. Person detail adds id_param. Account rank requires account_type; account detail requires both account_type and account_key. Preview requires operation: RELIABILITY_OPERATION_KIND_RANK and rank, or operation: RELIABILITY_OPERATION_KIND_DETAIL and detail, containing that measurement request. No preview scans observations, reserves an execution query or estimates a dollar price.

REST and MCP return the same evidence envelope, preserving the full typed response in data. Absent numeric quantities are explicit null, including zero-denominator rates and unknown size. Native enum names retain their complete spellings. History outcomes are complete, partially_covered and outside_retention; measurement outside_history is separate. A measured zero remains zero. Inspect definition, scope totals, population versus page counts, source coverage, live boundaries, independent operation and request evidence times, redactions and limits together. Read observed reliability before interpreting the result. These operations are experimental for applications; native adapter fixtures do not qualify hosted capacity, privacy completion or a real-agent workflow.

Account objective reads

GET /api/v1/slo/{rule_id}/account-burn?project_id=PROJECT_UUID&as_of=UTC_RFC3339 reads the actual policy at one instant. as_of is optional; future instants and hypothetical policy or source overrides are refused. GET /api/v1/alerts/{alert_id}/account-objective-evidence?project_id=PROJECT_UUID returns immutable original triggering evidence, subject to current source and lifetime gates. Both return the complete shared evidence document, including nulls, redactions and conclusion, and require current alerts:read, groups:read, traces:read, agents:content:read and project policy. They change no alert and send no notification. See account objectives.

Person timeline PNG

GET /api/v1/persons/{person_id}/timeline accepts one format=json (default) or format=png. Keep the ordinary project, time, signal, page-size, cursor and explicit count parameters. PNG projects exactly the authorized returned page; it does not follow the cursor or request counting. It retains ties, resolved or unresolved identity, count, partial, redaction and window limitations. persons:read and current source and content authority are unchanged. A returned canonical person proof link may open a different page or window; an unresolved key has no fabricated person link.

Success is image/png, 960 × 540, at most 262144 bytes. Both JSON and PNG are no-store. Invalid or repeated format returns 400. Owning errors retain their ordinary status before rendering. Presentation and image refusals return 422 JSON with error: "image_refused", message, render_status, optional render_reason and image_outcome:{image_status,image_reason?}. No blank image, measured rows or false success is substituted. PNG-only refusal preserves valid Apps data. Save through anectico persons timeline <id> --render png --out NEW_PATH; ordinary JSON remains the data fallback if the complete native layout cannot fit.

The four picture reads are the existing stored-result, service, person timeline and experiment reads only. Their refusal shapes differ as documented; do not apply the stored-result status map to timeline or experiment images, which use 422 for artifact refusal. Every fresh read checks its owning authorization. No render flag grants access, runs a new measurement or starts refresh.