Skip to main content
The /v1 API is the HTTP surface of the Vexlo server: three ingest routes, eleven read routes, and one unauthenticated capabilities route. This page covers what those routes share. Each route’s own parameters and fields sit on its own page: traces, functions, tags, coverage, health, ingest, and identity and bootstrap. Every response body and header on this page was captured from a server seeded with the spec’s conformance trace, running under bearer authentication on 127.0.0.1:18200. $READ_TOKEN holds a read token whose team-set is fixture and demo; the trace lives on team fixture. $SINGLE_TEAM_READ_TOKEN holds a read token for team fixture alone, minted with vexlo token create -role read -team fixture; authentication and tokens covers minting.

Base URL and authentication

Routes live under /v1 on the server’s own address (127.0.0.1:8080 unless vexlo serve -api-addr says otherwise). Every route but two takes an API token in the Authorization header:
The two routes that take no credential are GET /config.json and GET /v1/capabilities; a client reaches both before it holds a token. GET /v1/health and GET /v1/whoami are authenticated. A token has one role. Read routes take a read token, the three ingest routes take an ingest token, and GET /v1/whoami takes either. Under no-auth mode no tokens exist and requests carry no header. A missing header is 401 unauthenticated:
So is a token the server does not know:
A valid token of the wrong role is 403 forbidden_role. Here a read token bound to one team posts to the records route:
A read token whose team-set holds more than one team gets a different answer from the three ingest routes. With $READ_TOKEN (teams fixture and demo) the same request returns:
Every response carries an X-Request-Id header. The server generates one per request, or echoes the one the caller sent.

Team and environment filters

A team is the isolation boundary. An ingest token binds one team; a read token binds one or more, its team-set. Every team-scoped read route accepts ?team=<id>[,<id>...], which narrows the token’s team-set and never widens it. A value outside the team-set drops out of the filter, so the result for that team is empty rather than a 403 or 404:
An environment is the name the relay daemon declares with -environment and sends as X-Vexlo-Environment; it filters and never isolates. ?environment=<name>[,<name>...] matches any of the named values. ?environment_unset=true matches traces whose daemon sent no header instead of naming a value:
The two are mutually exclusive. Setting both is 400 conflicting_filters:
Omitting both applies no environment filter. PUT /v1/policy and POST /v1/heartbeat take neither parameter: capture policy resolves on (team, service) alone.

The three status fields

Three fields describe outcome and arrival. The conformance trace is "completeness": "complete" and "root_status": "error" at once: every record arrived, and the request failed. Switch on ok and error and read anything else as “this attempt has no outcome yet”, rather than exhaustively over the values above. status is an enum this API extends within /v1, and in_flight is the first extension it gained; GET /v1/capabilities names it in features as in_flight_attempts for a client that has to know before it fetches a trace (identity). An in_flight attempt carries attempt and started_us and nothing else: no duration_us, no error_class, and no slots key at all, which is not the same as a slot in state not_captured. It means an entry record for the attempt arrived and a completion record did not, which is not the same as the attempt still running, because a completion record can be lost like any other. Read it together with the trace’s completeness: on a running trace the attempt is most likely still executing, and on an abandoned one it was open when the process stopped reporting. On a complete one it outlived its own trace, a background task, unless its completion record was lost after the SDK handed it off, which this trace cannot see and service_loss_24h in the coverage block can. On a partial one it is as likely a lost record, and records_lost says how many were lost without saying of which kind. It never means the function was not called, which is what the absence of the step means instead. The three sit at different levels. A trace detail carries all three once the root attempt’s completion record has arrived: completeness, root_status, and one status per attempt inside attempts. A summary row carries completeness, adds root_status at that same point (where duration_ms appears too), and never carries status, because it carries no attempts. A root step present with an in_flight attempt leaves root_status absent and completeness at running or abandoned: the root was entered, which is not the same as having returned. Each row of GET /v1/functions/examples carries the status of the attempt it names.

Durations and instants

A trace-level duration is an integer count of milliseconds, duration_ms. Step and attempt timing is an integer count of microseconds with a _us suffix: started_us (offset from the trace’s start) and duration_us. Durations are never formatted strings. Instants are RFC 3339 UTC strings with millisecond precision, 2026-09-14T08:55:34.949Z. Two fields break that: first_seen_at and last_seen_at are empty strings on a (service, name) pair the function index has never observed (functions). Test a *_at field for "" before parsing it.

Identifiers, paths, and depth

trace_id is 32 lowercase hex characters assigned by the SDK. Every cursor is an opaque string. A step’s path is its identity within a trace, rendered on the read API as an opaque base32 string; the root step’s path is the empty string "". Because the encoding hides structure, every step in a trace detail also carries parent_path (null for the root) and depth (an integer, 0 for the root). Rebuild the tree from those two fields, never by decoding path. A step whose parent_path matches no step in the response is an orphan: its parent’s record never arrived. No route returns a content_id. Values are addressed by path and slot.

Handles

A slot is one value position on an attempt: input, output, error, or logs. On the read API a slot carries a handle in place of the bytes, here the root step’s input cut out of the trace detail that traces shows whole:
preview is the first 64 Unicode code points of the value, computed at capture time. state takes one of nine values. vexlo serve never answers missing. Telling the two read-time states apart needs a durable record that the value once arrived, and the server’s store keeps none, so a slot whose value record never arrived reads expired as well. Read expired as “no bytes to read”, not as “the server held these bytes and lost them”. GET /v1/functions/examples adds one value off that list: a slot that never formed carries "state": "" there (functions). On the seeded server, the non-root ok steps of a complete trace still read "state": "pending" in trace detail, and the values route serves their bytes under that same state. When you fetch values, branch on whether value is present, not on state.

The coverage block

Every trace detail carries a coverage object, the per-trace loss facts, here cut out of the same response:
List rows on GET /v1/traces carry the first four as plain fields and omit service_loss_24h. Service coverage, the per-service counters on GET /v1/coverage, is a different object; see tags, coverage, health.

Errors

One error shape, on every route:
code is the field to branch on. message is for people. details is optional and machine-readable; most errors carry only code and message. A 404 never distinguishes “no such trace” from “a trace outside your team-set”. A never-observed function is not a 404: GET /v1/functions/detail answers 200 with observed_count: 0 (functions). POST /v1/records also answers 200 with per-record codes in rejected[]: invalid_record (captured), trace_shed, and trace_team_mismatch (not captured). See ingest.

Pagination

Every list route paginates by cursor. ?limit= defaults to 50 and clamps to 500; a value above the maximum is applied as 500, not rejected:
The response carries items, limit (the limit applied), and next_cursor, which is null on the last page and an opaque string otherwise; pass it back as ?cursor=. Two routes differ. GET /v1/traces/{id} is not a list route and paginates anyway: it pages one trace’s own steps, default and maximum 500, and sets next_cursor when steps past the page remain (traces). GET /v1/functions/examples has no cursor, and caps ?limit= at 5, which is also its default. A cursor that does not decode is 400 invalid_cursor:

Rate limits

Limits are per token. The read surface, which includes POST /v1/heartbeat and PUT /v1/policy, allows 20 requests per second with a burst of 40. POST /v1/records is limited on two units at once: 4,000 records per second with a burst of 8,000, and 4 MiB per second with a burst of 8 MiB. GET /v1/capabilities and GET /config.json are never limited. Each read response carries three headers. X-Ratelimit-Limit is the sustained rate, X-Ratelimit-Remaining counts down from the burst, and X-Ratelimit-Reset is the number of seconds until the bucket next holds a token:
Sixty requests fired back to back with one token, followed by one more with its headers shown:
A 429 adds Retry-After in whole seconds, capped at 60. The records route carries its own pairs, X-Ratelimit-Limit-Records, X-Ratelimit-Remaining-Records, X-Ratelimit-Limit-Bytes, and X-Ratelimit-Remaining-Bytes, plus one X-Ratelimit-Reset; see ingest. Two cases carry no rate-limit headers. A 401 or 403 is rejected before the limiter runs, as the responses under authentication show. And under no-auth mode there is no token to key a bucket on, so limiting is off and no header appears:

The one widening parameter

GET /v1/functions/examples?include=values is the only ?include= in the API: it replaces each example’s preview handles with the stored value. GET /v1/traces/{id} has no widening parameter of any kind. Two routes serve captured bytes: GET /v1/traces/{id}/values, addressed by path and slot, and GET /v1/functions/examples?include=values, which serves the input and output of up to five example attempts of one function.