/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:
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:
403 forbidden_role. Here a read token bound to one team
posts to the records route:
$READ_TOKEN (teams fixture and demo) the same request returns:
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:
-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:
400 conflicting_filters:
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 acoverage 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:
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 includesPOST /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:
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.