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 is0orfalse, and an absent object isnull; 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}
POSTrequiresconnections:write. It accepts the same non-secretmetadataand optionalconnection_idas a start, and returns thehandoffplus ahandoff_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.GETrequiresconnections:read. It reportsstatus: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 newconnection_id.failed: carries afailure_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.
Trace and unified search
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=returnseventspluspagination.limitis 1–50 when present.GET /events/triggerers?project_id=&event=&since=&until=&property_key=&property_value=&limit=&cursor=requires exact RFC 3339sinceanduntiltimestamps and returns canonical person IDs plus the recognizable distinct ID, withpagination.limitis 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 stableidempotency_keyin the JSON body orIdempotency-Keyheader. 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-secondstart_time/end_timevalues.POST /api/v1/logs/export?project_id=is the logs convenience endpoint. Its optionalformatiscsv,json, orparquet, 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 carrycontent_policy_reason,content_policy_withheld_classesandcontent_policy_default_posture. The reason is empty until the job runs, thenallowed,policy,no_policy,resolver_errororunclassified. 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 aurlon 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 honoursRange(answering206 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:
setstores literal JSON values.nullis a present value; it does not delete.unsetis 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_onceis deterministic initialization. The earliest candidate wins until anyset/unsetexists; 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:
storeandreadenforcemodel_transcript,tool_arguments,replay_dom,person_propertiesandgroup_properties. A denial atstoreis irreversible — the bytes are never written. A denial atreadis not: the content stays recorded and a later change serves it again. Areplay_domdenial refuses the chunk upload or the playback outright rather than returning an empty recording; aperson_propertiesdenial drops the property values fromidentify()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_propertiesis independent fromperson_properties. It defaults toallowatstore,readandjudge_transfer, preserving the existing account experience, and todenyatexport. Itsstoreandreadcells enforce the group property bag. Nothing exports that bag and nothing sends it to a model today, so itsexportandjudge_transfercells have nothing to gate.exportapplies tomodel_transcript,tool_arguments,replay_domandperson_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_transferenforcesmodel_transcriptandtool_arguments.replay_dom,person_properties,group_propertiesandmemory_itemhave 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 measuredCLIENTorTABLEcost and at least one other call hasCLIENT_INVALID. The run'scost_nanosis 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.
outcomeis 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 oncategoryorcanonical_operationinstead, and use the containing run's status (/agent-runs/{runId}) when you need success or failure.
categoryaccepts only values telemetry can report. Thirteen categories are derived today:model,agent,workflow,planning,tool,mcp,retrieval,memory,safety,evaluation,context,applicationanda2a. Any other value is refused with400rather 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 byGET /api/v1/agent-runs/{runId}/graphunderaction_receipts, and served in full by/authority-edgesand/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: aFAILEDaction you report with adistinct_idbecomes an$agent_tool_failedevent 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
subjectquery parameter on one of itsresource_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 whilescopesis 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.
sinceis a look-back such as30m,24hor7d.fromandtoare RFC 3339 times;fromis inclusive andtois exclusive. Givesinceorfrom, 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.windowin the response is the window that was read.outcomeisok,previewed,partial,refused,error,canceledorunknown.refusalispermission,plan,validation,not_found,conflict,rate_limited,not_readyorbudget.classisread,write,destructiveorexport.surfaceismcp,cliorrest.credential_kindisapi_keyoroauth_connection. A value outside these lists is refused with400; it is never read as "no filter".operationis an exact tool or action name, or a method and route pattern such asGET /api/v1/issues/{id}.object_kindandobject_idtogether select the calls that named one object. Ids of persons and accounts are never stored, so such a search is refused with400.owner_user_idselects one person's agents. Naming another person withoutaudit:readis refused with403.project_idselects one project. Addinclude_workspace_calls=trueto 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 .../sourcescreates a source for onepurpose(browser_capture,mobile_capture,server_captureornative_ingest),nameand optionaldescription. Purpose and project are fixed for the source's life. A requiredidempotency_keymakes retries safe: the identical key returns200withreplayed: trueand the original source instead of creating a second one; the same key with a different body is409.PUT .../sources/{sourceId}replacesname/descriptionand requires the exact currentexpected_revision; a stale value is409.POST .../sources/{sourceId}/revokeis immediate and permanent. It returns the updated source, aDRAININGoperation, andalready_revoked(trueon 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 returns403with headerX-Anectico-Refusal-Reason: INGRESS_SOURCE_REVOKED; OTLP over HTTP returns403with the messageintake source revoked; OTLP over gRPC returnsPermissionDeniedwith the same message. A project's default source (used implicitly by a key that omitssource_id) is never recreated once revoked.pipeline/validateis a dry run that saves no program: it returnsvalid, structuraldiagnostics(step,code,message), the program'sprogram_digestandschema_version. Pipelines exist only fornative_ingestsources; every other purpose returns400 failed_precondition(pipelines-unsupported).POST .../pipeline/revisionsstores an immutable revision from the same validatedprogramdocument. An identical program returns200withexisting: trueand 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}/previewandpipeline/previewdry-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 unsavedprogramdocument, 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,_DROPPEDor_REFUSED),reason_code(empty,TRANSFORM_DROPPED, or aTRANSFORM_*refusal code),step(the zero-based step that dropped or refused it, or-1) andoutput(the record intake would store;nullfor 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.400for a bad sample body, an unknownseverity_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 unsavedprogram, or an unsupported source purpose (failed_precondition).pipeline/diagnostics?window_hours=reports one project's native-intake pipeline accounting over a recent window (1–168hours, default24, hourly buckets): per activerevision,accepted,transformed,droppedandrefusedcounts and theirreasons(TRANSFORM_DROPPED, aTRANSFORM_*refusal code, orALLOCATION_SAMPLED), newest revision first, plusfreshness— the epoch and revision the most recently counted admission pinned, whether that epoch is the source's current one, and the roughly-15-secondaccounting_delaybefore a count becomes readable.freshnessisnullwhen nothing was admitted in the window. Counts only, never record content;400for an out-of-rangewindow_hours,failed_preconditionfor an unsupported source purpose.pipeline/activatemoves the source's live pipeline torevision(an earlier revision is a rollback;0removes the active pipeline) and requires the exact currentexpected_catalog_epoch— a stale value is409. It returns the updated source, aDRAININGoperation andalready_active. The operation reportsDRAININGfor roughly fifteen seconds and reads backCOMPLETEon any later read once that drain elapses; nothing else needs to be polled to advance it.operationslists and reads the activation, rollback, and revocation history for one source, newest first, withkind(ACTIVATE,ROLLBACK,REVOKE),state(DRAINING,COMPLETE,FAILED) and, on failure,failure_code.collection-health?window_minutes=classifies whether anative_ingestsource's collector is delivering, over a recent window (1–1440minutes, default15):stateis one ofCOLLECTION_HEALTH_STATE_NO_SIGNALS,_AUTHENTICATION_REJECTED,_QUEUED,_PARSER_REFUSALor_VERIFIED, with every condition that holds inconditions(most urgent first) and a plain-languageexplanation. It reports storedsignals(signal,record_count,last_received_at) per telemetry family; the collector's own last-reportedcollectorqueue state (nullwhen 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 beforewindow_startas their baseline, or zero without one. It also reportspipeline_evidence,pipeline_refused_countandpipeline_refusal_reasonsfrom the source's transform pipeline; andcredentials(evidence,bound_key_count,usable_key_count) — how many keys bound to the source can still authenticate.evidencefields areCOLLECTION_EVIDENCE_AVAILABLE,_WITHHELD(the caller lacksapi_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.400for a malformedwindow_minutes,failed_preconditionfor 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-metricstakesspec(aLogMetricRuleSpec) andidempotency_key. Usepreview_only: trueto review the rule without creating it. After approval, submit the same specification and retry key withpreview_only: false; creation requires that retry key. Identical retries return the committed rule.replay_only: truereads that committed creation or returns not found. The route supplies the project. The specification containsmetric_name,description,aggregation,unit,bucket_seconds(60,300,600,900,1800,3600or86400), and adefinition(match,value_field,labels,max_series,late_cutoff_seconds,source_id,enabled).metric_name,aggregation,unitandbucket_secondsare 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/validatetakes only theLogMetricRuleSpecitself and saves no metric rule; it returns{"valid": bool, "diagnostics": [{"code", "field", "message"}]}— the exact diagnostics a create or revise would refuse with. Stablecodevalues: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 newdefinitionor arollback_to_revision, plus the rule's exact currentexpected_revisionand an optional newdescription. 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 staleexpected_revisionis409; arollback_to_revisionthat does not exist is400. -
.../log-metrics/{ruleId}(DELETE) stops deriving anything new from the rule; its series stop being readable. A repeat delete of an already-deleted rule is404. -
.../log-metrics/{ruleId}/query(POST) reads the published series:{"time_range": {"start", "end"}, "step", "group_by": [...], "filters": [{"key", "values"}]}.time_rangedefaults to the last 24 hours (at most 400 bucket windows);stepmust 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_bykeeps only the listed label keys, summing the rest away;filterskeeps only series whose label value is one of the listed ones (at most 4 filters). The response carriesseries(labels,points: [{"timestamp", "value"}]) and afreshnessobject:state(_CURRENT,_REBUILD_PENDINGor_INVALIDATED),watermark,complete_through,windows_in_range,windows_served,windows_rebuild_pending,windows_withheld,late_countandseries_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 .../experimentspreviews a draft from{"key", "definition", "idempotency_key"}— anExperimentDefinition: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 descriptiveguardrailselector.keyis a slug unique in the project forever; a repeatkeyis409.PUT .../draftreplaces a draft's definition at its exactexpected_revision; a stale value, or a non-draft state, is409.POST .../launch-previewevaluates 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 .../launchfreezes the draft atexpected_revision(the previewed revision) and starts the run; a refused admission is400naming the same refusal codes, and a moved or non-draft revision is409.POST .../stoppreviews 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 is400(already terminal).GET .../countsreturns per-arm ledger counts only —assigned,exposed,outcomes_provisional,outcomes_reconciled,outcomes_refused,outcomes_missing— plusidentity_changed_before_exposure. No per-person rows, no inference.GET .../resultreturns{"experiment", "result"}. Whilerunning/observing, and for acanceled/invalidatedrun,resultcarries descriptive per-arm counts only (descriptive_only: true,inference/sample_ratio/snapshotallnull,decisionunspecified). After the finalization deadline it is computed once, toEXPERIMENT_RESULT_STATE_FINAL(rate, Miettinen–Nurminen difference, z, p value and 95% CI, sample-ratio check and a direction-awaredecision— never an automatic rollout) orEXPERIMENT_RESULT_STATE_INSUFFICIENT_EVIDENCE(reasonsnames why:FLOOR_NOT_MET,MINIMUM_SAMPLE_NOT_MET,MISSING_OUTCOME_EVIDENCE,DEGENERATE_TABLE,NUMERICAL_NONCONVERGENCE, orSAMPLE_RATIO_MISMATCH— the last one still reportssample_ratiobut sets no decision). An optionalguardrailis descriptive only and never affects the decision. A draft has no result (400); an unknown key is404.- Delivery:
.../assignmentsis stable per canonical person (aliases, retries and concurrent calls agree); an unidentifieddistinct_idis400withdetails.reasonEXPERIMENT_SUBJECT_UNRESOLVED— never retryable..../exposuresand.../outcomesare idempotent onidempotency_key/ the(exposure_id, source_id, event_id)triple;.../outcomesis400withdetails.reasonEXPERIMENT_OUTCOME_CONFLICTwhen 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 .../surveyslists newest first;stateisdraft,activeorstopped,limitis 1 to 200 (50 by default) andnext_cursorcontinues 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 .../surveyscreates 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}withoptions: [{id, label}]for a choice type,scale: {min, max, low_label, high_label}for a rating and nothing more for open text.targetingis{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 is409withSURVEY_REVISION_CONFLICT.POST .../launchpreviews the draft at{"expected_revision"}with proposedSURVEY_STATE_ACTIVE; confirmation freezes it. A refused launch names its code:SURVEY_INCOMPLETE,SURVEY_SCHEDULE_ENDED,SURVEY_AUDIENCE_UNAVAILABLE,SURVEY_FLAG_NOT_FOUNDorSURVEY_LIMIT; a moved or non-draft revision is409.POST .../stoptakes{"expected_revision", "reason"?}(reason at most 1,024 characters).POST .../duplicatetakes{"expected_revision", "idempotency_key", "name"?}and previews a new draft.DELETE ...requiresexpected_revisionand previews removal of a draft or stopped survey; an active survey is refused withSURVEY_ACTIVE. Confirmation applies each change.GET /api/v1/surveys/limitsreturns{limits, refusal_codes}: the bounds a definition is held to andeligibility_staleness_seconds, the most time a launch, stop or reached response cap takes to change what is offered.eligibleanswers{"surveys": [{id, revision, questions}]}, at most three, oldest launch first, with the questions and nothing about targeting, and is never cached.shown,responsesanddismissalsanswer{shown_at|responded_at|dismissed_at, replayed}; a repeat of the same idempotency id, and a second display for the same person, isreplayed: true. A response'sanswershold one{question_id, option_ids | rating | text}per answered question. Delivery refusals begin withSURVEY_NOT_ACTIVE,SURVEY_REVISION_MISMATCH,SURVEY_ANSWER_INVALID,SURVEY_ALREADY_ANSWERED,SURVEY_RESPONSE_CAP_REACHEDorSURVEY_NOT_OFFERED(the visitor was not returned this survey byPOST /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 /monitorscreates a monitor fromname,configand exactly one route:routing(escalation_policy_id,severity) creates its dedicated alert rule, orservice_keynames 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 are400 MONITOR_ROUTING_CONFLICT; an unknown or deleted key is400 MONITOR_SERVICE_INVALID, a service whose rule is not bound yet400 MONITOR_SERVICE_NOT_ROUTABLE.configcarries exactly one ofhttp,tls,domainorheartbeat, 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); noregions.heartbeat: exactly one ofheartbeat.interval_seconds(60-604800) orheartbeat.cron(five-field,heartbeat.timezonean IANA zone, default UTC),heartbeat.grace_seconds(60-86400, default 300); nointerval_secondsorregions. The kind cannot change after creation (400 MONITOR_KIND_IMMUTABLE).pausedcreates it without scheduling. An optionalidempotency_key(1-128 characters) makes a retry safe: the identical key and body returns200withreplayed: trueand the original monitor instead of creating a second one; the same key with a different body is409.201on creation,200on a replay,400for a rejected configuration (a closedMONITOR_*code named inmessage), and503namingMONITOR_BINDING_PENDINGwhen the monitor was stored but its alert rule could not be confirmed — repeat the exact request (sameidempotency_key) to retry, or resume it. Creating aheartbeatmonitor returnsheartbeat_token(anhb_+ 43 characters) in this response only — it is never returned again; a lost token is replaced withPOST /monitors/{id}/heartbeat-token.
PATCH /monitors/{id}replaces whichever ofname,config(the whole configuration),routingandservice_keyare present in the body, comparing and swapping on the requiredexpected_revision; a name or configuration change writes a new revision and fences every job issued for an older one. A presentservice_keymoves the monitor to that service (its own alert rule is deleted);"service_key": ""together withroutingmoves it back to its own rule. A failing monitor's open alert moves with it: the old route's alert resolves withresolution: withdrawnand the new route is paged at once.routingon a service-owned monitor is400 MONITOR_SERVICE_OWNED; an emptyservice_keywithoutroutingis400 MONITOR_ROUTING_REQUIRED; a move whose old rule could not be removed yet is503 MONITOR_RELEASE_PENDING(the monitor already pages through the service — repeat the request). An empty body, or one naming none of those four fields, is400. A staleexpected_revisionis409; aconfigthat changes the monitor's kind is400 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 withresolution: withdrawn;expected_revisionis required. Repeating the delete on an already-deleted monitor skips the revision check and retries the alert-rule release —200either way, or503namingMONITOR_RELEASE_PENDINGif the release itself is still pending.POST /monitors/{id}/pauseand/resumeoptionally compare and swap onexpected_revision. Resuming re-validates routing coverage and is refused (400,ROUTING_NOT_COVERED) unless the escalation policy can page.POST /monitors/{id}/testadmits one bounded, extra round (one job per configured region) outside the schedule.page(defaultfalse) controls whether a failing result of this round may page; a paging test against uncovered or not-yet-bound routing is400. At most one test round per monitor is open at a time; a second one is400namingMONITOR_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 withGET /monitors/{id}/rounds?kind=test. If too few of the monitor's regions currently have a serving executor to reach a verdict, the response'sexecutor_statusisunavailable(ordegradedwhen some but not all do) andreason_codenames why.GET /monitors/{id}/history?kind=lists a monitor'sscheduledortestjobs, newest first, including a deleted monitor's.GET /monitors/{id}/auditlists 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 majorityverdict(failed,healthy,unknownorpending),quorum,coverage(full,degradedorunknown, plus which regions reported, were missing or were invalid), what the round did to the response (effect: empty,opened,updated,recovered,gated,stale_revisionorrecorded), and each region'sobservations[](outcome,status_code, a closederror_class, a redacteddetail, per-phasetimingin 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.stateisresponse_ended_while_unhealthy);expected_gate_versionmust equal the currenthealth.response_gate.version, shown on every monitor read. The next failed round then opens a new episode and pages once. A stale version is409; re-arming a monitor whose gate was never ended, or one that is deleted, is400namingMONITOR_NOT_GATEDorMONITOR_DELETED.GET /monitoring/regionslists the organization's probe region catalog and each region's executor diagnostics:status(serving,staleorabsent), a worker count,last_seen_at, and the distinct executorbuildsseen recently. Never worker ids or addresses.POST /monitors/{id}/heartbeat-tokenreplaces aheartbeatmonitor'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_HEARTBEATfor any other kind.GET /monitors/{id}/pings?page_size=&page_token=lists aheartbeatmonitor's received pings, newest first:event(start,success,fail),run_id,exit_code(when the ping carried one),disposition(acceptedorduplicate— a repeated start or completion of the samerun_id, which changed nothing), andeffect— 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;statefilters toupcoming,active,completedorcanceled.POST /maintenance-windowscreates a window fromproject_id,name,scope(project— every monitor in the project, including ones created later — ormonitorswithmonitor_ids[], 1-200),timezone(default UTC, display only),starts_at(may be in the past: the window is then active at once) andends_at(must be in the future, at most 14 days afterstarts_at, andstarts_atat most 365 days ahead).400 MAINTENANCE_INVALIDfor a rejected window (a monitor outside the project, bad bounds),429 MAINTENANCE_LIMITpast 50 open windows in one project.PATCH /maintenance-windows/{id}replaces whichever ofname,scope(withmonitor_ids),timezone,starts_at,ends_atare present, comparing and swapping onexpected_revision. An active window accepts onlynameandends_at(400 MAINTENANCE_ACTIVEfor 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 asX-Anectico-Heartbeat-Token(never as a query parameter or in the body). Send an empty request body and putevent,run_idandexit_codein the query string. Atokenquery parameter or any non-empty request body is400, 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 is401; beyond 30 accepted pings per minute for one monitor is429 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-catalogcreates a service fromkey(1-64 ofa-z,0-9,-,_, unique among the project's live services),name,escalation_policy_id(required — a service pages through a policy) and optionaldescription,on_call_schedule_id,default_runbook_id,severity(info,warningorcritical, defaultcritical).201on creation;200with"replayed": truewhen this identical create completed an earlier one whose alert rule had not been confirmed yet.409 SERVICE_KEY_TAKENfor a duplicate key;400for an invalid key, missing or invalidescalation_policy_id;503 SERVICE_BINDING_PENDINGwhen 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}andGET /service-catalog?project_id=&include_deleted=read one service or list a project's live (or, withinclude_deleted=true, also tombstoned) services. Each carriesrouting: {status, detail}—coveredonly when its policy exists, is active and has at least one level.PATCH /service-catalog/{id}replaces whichever ofname,description,on_call_schedule_id,default_runbook_id,escalation_policy_id,severityare present in the body (an emptyon_call_schedule_idordefault_runbook_idclears it), comparing and swapping on the requiredexpected_revision. Changingescalation_policy_idorseverityre-routes the bound alert rule.409for 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_revisionis required for a live service. A delete supplying a revision for an already-tombstoned service is refused with400 CATALOG_ENTRY_DELETED; omit the revision to retry releasing its rule. This recovery form cannot delete a live service.503 SERVICE_RELEASE_PENDINGmeans the service was tombstoned but its rule could not be released yet — repeat the REST delete withoutexpected_revisionto 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 resolvedservice,used_fallback,on_call_user_id, therunbook_id/runbook_revision/runbook_titlean incident would snapshot, anddiagnostics[]{code, detail}(UNKNOWN_SERVICE,FALLBACK_USED,SERVICE_NOT_ROUTABLE,NO_ROUTING_COVERAGE,NO_SCHEDULE,NO_RESPONDER,RESPONDER_UNVERIFIED,NO_RUNBOOK).POST /runbookscreates a runbook fromkey,titleandsteps[]{id, title, detail, links[]}(1-50 steps, each at most 4 KiB, eachlinks[]at most 5 absolutehttp(s)URLs of at most 2048 bytes).400 RUNBOOK_STEPS_INVALIDfor 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_USEwhile a live service names it as its default, and400 CATALOG_ENTRY_DELETEDif 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 .../revisionslists every revision, newest first, without their steps.POST /incidents(manually declaring an incident) accepts an optionalservice_keynaming 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.404if the key names no live service in the credential's project;400for a malformed key.GET /incidents/{id}/ownershipreturns{"ownership": {...}|null, "checklist": [...]}— the incident's immutable snapshot (service, owner schedule, policy, runbook revision, its steps,sourcealert|manual,captured_at) taken once when it opened, and each step's current{step_id, done, actor, at}.ownershipisnullfor an incident with no cataloged service.PUT /incidents/{id}/checklist/{stepId} {"done": true|false}marks one step; the response'schangedisfalsewhen the step already had that state (nothing was recorded).400 NO_OWNERSHIP_SNAPSHOTon an incident with no snapshot;404for 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; requiresincidents:respond.incident_idis the idempotency key — a duplicate request for the same incident returns the already-linked channel (samestate) 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"}}, wherestatestartsrequestedand settles toactive,missing(the channel itself was rejected), orpermission_lost(the connection needs reinstalling) once the platform finishes syncing with Slack, usually within a few seconds.GET /incidents/{id}/slack-channelreads the same shape and requiresincidents:read;404means 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/responserequiresfromandto(RFC 3339,[from, to), at most 366 days);severity,service_keyandsourcerepeat.mttaandmttreach carryavailable,mean_seconds,median_secondsandsample_count; with no samples,availableisfalseand the mean and median are absent. The counts and stats cover every matching episode;episodeslists the newest 1,000 androws_truncatedistruewhen more matched.format=csvreturns the episode table; addpart=summaryfor the one-row summary.GET /reports/availabilityrequiresfromandto(at most 90 days) and accepts up to 50monitor_idvalues; with none, it reports the project's first 50 live monitors and setstruncatedwhen there are more.availabilityis absent whenavailability_knownisfalse.format=csvreturns the per-monitor table.PUT /incidents/{id}/retrospectivereplaces the whole document (summary,impact,contributing_factors,went_well,went_wrong,timeline_refs,status) underexpected_revision(0creates the first revision).timeline_refsare at most 100 ids of this incident's own timeline events.409for a stale revision.POST .../retrospective/actionstakestitle, optionalowner_user_id(a current member of your organization),due_date,ticket_url(http/httpsonly) andidempotency_key; a retry with the same key and body returns the first item, and the same key with a different body is409.PATCHchanges only the fields present in the body.- An action item's
owner_user_idis returned only to callers that also holdmembers: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-pagescreates a draft page fromslug(3-48 ofa-z,0-9and-, starting and ending with a letter or digit — globally unique and permanent, even for a deleted page),titleand an optionaldescription.201, or409 STATUS_SLUG_TAKENfor 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-pageslist 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 onexpected_revision. Every draft edit below shares this same compare-and-swap: a staleexpected_revisionis409 STATUS_REVISION_CONFLICTnaming the revision observed at the comparison in the message anddetails.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 atkey(1-64 ofa-z,0-9,-and_):name, an optionalgroupheading,position(sort order),association_kind(none,serviceormonitor, withassociation_idrequired for the latter two — verified to exist, live and in the page's project before the write),auto_update(monitor association only), andmanual_status(operational,degraded,partial_outage,major_outageormaintenance).400 STATUS_AUTO_UPDATE_NEEDS_MONITORforauto_updatewithout a monitor association;400 STATUS_ASSOCIATION_NOT_FOUNDfor a missing, deleted or cross-project target. A component's association andauto_updateare never published — only itsstatusis.DELETE /status-pages/{id}/components/{key}?expected_revision=removes a component;409 STATUS_COMPONENT_IN_USEwhile a public incident or maintenance still names it,409 STATUS_COMPONENT_LIMITis instead returned by thePUTthat 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;impactisnone,minor,majororcritical,statusisinvestigating,identified,monitoringorresolved.internal_incident_idoptionally links an internal incident — verified to exist in the page's project — and is never published.201withincident_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: truereplaces the affected-component list (including with an empty one) — omit it to leave that list unchanged. Settingupdate_statustoresolvedresolves the incident and recordsresolved_at; moving it offresolvedreopens 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 (201withmaintenance_id);PUT /status-pages/{id}/maintenance/{maintenanceId} {"expected_revision", "maintenance": {...}}replaces one.maintenancecarriestitle, an optionalbody, affectedcomponents[], andstarts_at/ends_at(RFC 3339;ends_atmust be strictly afterstarts_at, else400 STATUS_INVALID).DELETE /status-pages/{id}/maintenance/{maintenanceId}?expected_revision=removes a maintenance from the draft.GET /status-pages/{id}/previewreturns{"document", "size_bytes", "limit_bytes":262144, "page_revision"}— exactly the public document a publish would write for the current draft, with nothing published.documentcarries 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_secondsandchecked_at).POST /status-pages/{id}/publish {"expected_revision"}snapshots the draft into a new publication and queues it for the public renderer.202with{"publication", "replayed": false}when newly queued;200with"replayed": truewhen that exact draft revision already has a pending or published publication — nothing new is written.400 STATUS_PAYLOAD_LIMITif the resulting document exceeds 256 KiB.POST /status-pages/{id}/unpublish {"expected_revision"}queues the page's removal from the public renderer.202with{"page", "publication"}when a removal was queued;200with nopublicationwhen 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,failedorsuperseded),digest,size_bytes,attemptsand any renderererror. A pending publication is never the page's live one — watch forstateto becomepublished(or read the page'slive_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}/domainreturns{"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"}}.404when the page has no custom domain.POST /status-pages/{id}/domain {"hostname"}claims a subdomain you control, e.g.status.example.com.201with the domain in statepending_ownershipand its DNS instructions.400 STATUS_INVALIDfor a malformed hostname;400 STATUS_DOMAIN_UNSUPPORTEDfor an apex, wildcard, IP address, or a hostname equal to or under a platform domain;409 STATUS_DOMAIN_TAKENwhen another page already holds it;409 STATUS_DOMAIN_EXISTSwhen this page already has one;400 STATUS_DOMAINS_UNAVAILABLEwhen custom domains are not configured on this deployment.POST /status-pages/{id}/domain/verifyasks for an immediate re-check rather than waiting for the next scheduled one. Pass the custom domain’supdated_atas theexpected_versionquery argument; a stale clock is refused.400 STATUS_DOMAIN_REMOVINGwhile 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.202with the domain in stateremoving— 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"}.statenarrows topending,confirmedorunsubscribed.masked_emailkeeps only the first character of the local part and the full domain (e.g.p***@example.com).uncertain_sendscounts emails whose delivery could not be confirmed one way or the other (never retried);failed_sendscounts 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"?}subscribesemailto 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.404when the slug is not a live public page;429withRetry-Afterover the rate limit (5 per minute per address, 60 per minute per page);503when the subscription system is temporarily unavailable;501when 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.GETrenders an HTML confirmation page (a button thatPOSTs — nothing is confirmed by fetching the link);POSTacts. WithAccept: application/json, a successfulPOSTreturns{"status":"ok","page_title"}; an unknown, already-used or expired token is404(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 thePOSTform is also the RFC 8058 one-click target of the email'sList-Unsubscribeheader. Same response shapes asconfirm.
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-sourcescreates a source fromprovider(generic_jsonoralertmanager),display_nameand optionalfallback_service_key,service_label(Alertmanager only, defaultservice).201with{"source", "secret"}— the signing secret (aninc_+ 43 characters), returned here and only here (and again fromrotate-secret); it is never shown again. The response also setsCache-Control: no-store.PATCH /incoming-alert-sources/{id}replaces whichever ofdisplay_name,fallback_service_key,service_labelare 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}/revokestops the source; every later delivery is401.GET .../{id}/deliveriesreturns the source's last 100 outcomes, newest first — never a payload:{received_at, outcome, service_key, dedup_key_digest, state, alert_id, detail}.outcomeis one ofaccepted,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). Thesourcequery 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>.401for 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.413past 256 KiB.429 INCOMING_ALERT_RATE_LIMITEDpast 120 requests per minute for one source.400 INCOMING_ALERT_INVALIDfor a payload that does not match the provider's documented shape (an unrecognized field, a bad timestamp, and so on).503for 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/jsonunless an upload endpoint requires a raw symbol artifact. - JSON field names are
snake_case. - Times are RFC 3339; common windows use
start_timeandend_time. Omitting an optional timestamp selects the endpoint's documented default, while a non-empty malformed timestamp returns400 invalid_requestinstead of silently selecting that default. GET /persons/{person_id}/timelineacceptsstart_timeandend_timeto 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
limitand an opaquecursor. 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_moretogether with anext_cursor, and sets neither once the page is the last one. Treat a full page withhas_morefalse as the complete result, and keep followingnext_cursorwhilehas_moreis true rather than assuming one page is everything. total_countis a hint, andhas_more/next_cursorare 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_countis present only when the endpoint counted the matches. An endpoint that does not count leaves the field out ofpagination. An absenttotal_countmeans "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_countis 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_moreis 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 treatingtotal_countas the loop condition, switch tohas_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 answer202instead.202is 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 returns404because 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. lineandcolumnlocate 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, orunclassified. fieldandtype, when present, name the field that rejected the value and the type it expects. A list field readslist of <type>and a map fieldmap 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.