> ## Documentation Index
> Fetch the complete documentation index at: https://docs.vexlo.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Conventions

> The headers, filters, fields, error shape, pagination, and rate limits every /v1 route shares.

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](https://docs.vexlo.ai/api/traces.md), [functions](https://docs.vexlo.ai/api/functions.md),
[tags, coverage, health](https://docs.vexlo.ai/api/tags-coverage-health.md), [ingest](https://docs.vexlo.ai/api/ingest.md), and
[identity and bootstrap](https://docs.vexlo.ai/api/identity.md).

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](https://docs.vexlo.ai/server/authentication.md) 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:

```bash theme={null}
curl -H "Authorization: Bearer $READ_TOKEN" "http://127.0.0.1:18200/v1/traces?team=fixture"
```

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](https://docs.vexlo.ai/api/identity.md#get-/v1/whoami)
no tokens exist and requests carry no header.

A missing header is `401 unauthenticated`:

```bash theme={null}
curl -s -i http://127.0.0.1:18200/v1/traces
```

```http theme={null}
HTTP/1.1 401 Unauthorized
Content-Type: application/json
X-Request-Id: d97bb8f57717cce5
Date: Mon, 14 Sep 2026 08:58:16 GMT
Content-Length: 78

{"error":{"code":"unauthenticated","message":"missing Authorization header"}}
```

So is a token the server does not know:

```http theme={null}
HTTP/1.1 401 Unauthorized
Content-Type: application/json
X-Request-Id: ade861683bcf598f
Date: Mon, 14 Sep 2026 08:58:16 GMT
Content-Length: 63

{"error":{"code":"unauthenticated","message":"unknown token"}}
```

A valid token of the wrong role is `403 forbidden_role`. Here a read token bound to one team
posts to the records route:

```bash theme={null}
curl -s -i -X POST -H "Authorization: Bearer $SINGLE_TEAM_READ_TOKEN" \
  -H "Content-Type: application/json" --data '[]' http://127.0.0.1:18200/v1/records
```

```http theme={null}
HTTP/1.1 403 Forbidden
Content-Type: application/json
X-Request-Id: 2f84ff55ece5e4eb
Date: Mon, 14 Sep 2026 09:00:08 GMT
Content-Length: 89

{"error":{"code":"forbidden_role","message":"this route requires an ingest-role token"}}
```

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:

```http theme={null}
HTTP/1.1 500 Internal Server Error
Content-Type: application/json
X-Request-Id: 12c10ff0ad6f1c10
Date: Mon, 14 Sep 2026 09:00:08 GMT
Content-Length: 70

{"error":{"code":"internal_error","message":"authentication failed"}}
```

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`:

```bash theme={null}
curl -s -H "Authorization: Bearer $READ_TOKEN" \
  "http://127.0.0.1:18200/v1/traces?team=other-team"
```

```json theme={null}
{"items":[],"limit":50,"next_cursor":null}
```

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:

```bash theme={null}
curl -s -H "Authorization: Bearer $READ_TOKEN" \
  "http://127.0.0.1:18200/v1/traces?team=fixture&environment_unset=true"
```

```json theme={null}
{"items":[],"limit":50,"next_cursor":null}
```

The two are mutually exclusive. Setting both is `400 conflicting_filters`:

```http theme={null}
HTTP/1.1 400 Bad Request
Content-Type: application/json
X-Ratelimit-Limit: 20
X-Ratelimit-Remaining: 36
X-Ratelimit-Reset: 1
X-Request-Id: 4a8f5275450ccd8f
Date: Mon, 14 Sep 2026 08:58:17 GMT
Content-Length: 115

{"error":{"code":"conflicting_filters","message":"environment and environment_unset=true are mutually exclusive"}}
```

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.

| Field | Level | Values | Meaning |
| - | - | - | - |
| `status` | attempt | `ok`, `error`, `in_flight` | The outcome of one attempt, or `in_flight` for an attempt that has none yet. `error_class` accompanies `error`. |
| `completeness` | trace | `running`, `complete`, `partial`, `abandoned` | Whether every record of the trace has arrived. Derived at read time. |
| `root_status` | trace | `ok`, `error` | The outcome of the root step's last attempt. Absent until that attempt's completion record arrives. |

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](https://docs.vexlo.ai/api/identity.md#get-/v1/capabilities)).

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](https://docs.vexlo.ai/api/functions.md#get-/v1/functions/detail)). 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](https://docs.vexlo.ai/api/traces.md#get-/v1/traces/id) shows whole:

```json theme={null}
... 52 lines omitted
            "input": {
              "state": "stored",
              "bytes_count": 70,
              "truncated": false,
              "encoding": "json",
              "preview": "{\"method\":\"POST\",\"order_id\":\"O-1234\",\"path\":\"/delivery/assign-da"
            }
... 585 lines omitted
```

`preview` is the first 64 Unicode code points of the value, computed at capture time. `state`
takes one of nine values.

| State | Origin | The handle carries |
| - | - | - |
| `stored` | wire | `bytes_count`, `truncated`, `encoding`, `preview` |
| `pending` | wire | the same four fields |
| `sampled_out` | wire | the same four fields |
| `dropped` | wire | the same four fields |
| `redacted` | wire | `state` only |
| `too_large` | wire | `state` only |
| `not_captured` | wire | `state` only |
| `expired` | read time | `state` only: the slot's own row says a value was captured, and no bytes are there to read now |
| `missing` | read time | `state` only: a value record was expected and never arrived |

`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](https://docs.vexlo.ai/api/functions.md#get-/v1/functions/examples)).

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:

```json theme={null}
... 19 lines omitted
  "coverage": {
    "records_lost": 0,
    "records_shed": 0,
    "truncated": false,
    "instance_mismatch": false,
    "service_loss_24h": {
      "delivery-service": 0
    }
  },
... 616 lines omitted
```

| Field | Counted by | Meaning |
| - | - | - |
| `records_lost` | the SDK, on `trace-close` | Records the SDK could not deliver for this trace. |
| `records_shed` | the server | Records ingest rejected with `trace_shed`. |
| `truncated` | the SDK | The trace hit the 10,000-step cap. |
| `instance_mismatch` | the server | Two `instance_id` values arrived under one `trace_id`. |
| `service_loss_24h` | the server | The service's `records_lost_24h` from service coverage, keyed by service. |

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](https://docs.vexlo.ai/api/tags-coverage-health.md#get-/v1/coverage).

## Errors

One error shape, on every route:

```http theme={null}
HTTP/1.1 404 Not Found
Content-Type: application/json
X-Ratelimit-Limit: 20
X-Ratelimit-Remaining: 33
X-Ratelimit-Reset: 1
X-Request-Id: 5fd062984e65b45f
Date: Mon, 14 Sep 2026 08:58:17 GMT
Content-Length: 157

{"error":{"code":"trace_not_found","message":"no trace with id 00000000000000000000000000000000","details":{"trace_id":"00000000000000000000000000000000"}}}
```

`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](https://docs.vexlo.ai/api/functions.md#get-/v1/functions/detail)).

| `code` | HTTP | Captured on | Cause |
| - | - | - | - |
| `unauthenticated` | 401 | `GET /v1/traces` | No `Authorization` header, an unparseable token, an unknown token id, a hash mismatch, or a revoked token. |
| `forbidden_role` | 403 | `POST /v1/records` | A valid token of the wrong role. |
| `trace_not_found` | 404 | `GET /v1/traces/{id}` | No trace with that id in the token's team-set. `details.trace_id` names the id. |
| `malformed_batch` | 400 | `POST /v1/records` | The body is not a decodable JSON array. |
| `malformed_request` | 400 | `POST /v1/heartbeat`, `PUT /v1/policy` | The body is not decodable JSON. |
| `conflicting_filters` | 400 | `GET /v1/traces` | `environment` and `environment_unset=true` together. |
| `invalid_cursor` | 400 | `GET /v1/traces` | `?cursor=` does not decode. |
| `invalid_request` | 400 | `GET /v1/traces`, `GET /v1/functions/detail` | A malformed query parameter: `since=yesterday`, a missing `name`, an unrecognized `completeness`, a path that does not decode. |
| `too_many_trace_ids` | 422 | `GET /v1/traces` | More than 100 ids in `?trace_id=`. |
| `too_many_paths` | 422 | `GET /v1/traces/{id}/values` | More than 8 distinct `path` values. |
| `service_required` | 422 | `GET /v1/functions/detail` | `?service=` omitted or empty on the two routes that name one function. |
| `service_scope_mismatch` | 422 | `POST /v1/records`, `PUT /v1/policy` | The token's service pin excludes the service. |
| `environment_scope_mismatch` | 422 | `POST /v1/records` | The token's environment pin excludes the `X-Vexlo-Environment` value, or the header is absent. |
| `invalid_environment` | 422 | `POST /v1/records` | `X-Vexlo-Environment` is outside its bounds (1 to 64 printable ASCII characters, no comma). |
| `invalid_heartbeat` | 422 | `POST /v1/heartbeat` | The heartbeat body fails validation. |
| `invalid_policy` | 422 | `PUT /v1/policy` | A field is out of range. `details.field` names it. |
| `body_too_large` | 413 | `POST /v1/records` | The body exceeds the route's limit (4 MiB decoded for records, 16 KiB for a heartbeat, 4 KiB for a policy). |
| `rate_limited` | 429 | `GET /v1/traces` | The token's bucket is empty. `Retry-After` says when to retry. |
| `internal_error` | 500 | `POST /v1/records` | An unexpected failure; captured with a read token spanning two teams. |
| `commit_failed` | 503 | not captured | The batch could not be committed to storage. |
| `function_not_found` | 404 | not captured | Named in the spec's table; no route on this server returned it. |

`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](https://docs.vexlo.ai/api/ingest.md#post-/v1/records).

## 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:

```bash theme={null}
curl -s -H "Authorization: Bearer $READ_TOKEN" \
  "http://127.0.0.1:18200/v1/traces?team=fixture&limit=9999" \
  | jq '{limit, next_cursor, items: (.items | length)}'
```

```json theme={null}
{
  "limit": 500,
  "next_cursor": null,
  "items": 1
}
```

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](https://docs.vexlo.ai/api/traces.md#step-paging-and-sorting)). `GET /v1/functions/examples` has no cursor, and
caps `?limit=` at 5, which is also its default.

| Route | Default sort |
| - | - |
| `GET /v1/traces` | `last_seen_at` descending |
| `GET /v1/functions` | `observed_count` descending |
| `GET /v1/tags` | `key` ascending |
| `GET /v1/traces/{id}/related` | `started_at` descending |

A cursor that does not decode is `400 invalid_cursor`:

```json theme={null}
{"error":{"code":"invalid_cursor","message":"cursor does not decode"}}
```

## 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:

```bash theme={null}
curl -s -o /dev/null -D - -H "Authorization: Bearer $READ_TOKEN" \
  "http://127.0.0.1:18200/v1/traces?team=fixture"
```

```http theme={null}
HTTP/1.1 200 OK
Content-Type: application/json
X-Ratelimit-Limit: 20
X-Ratelimit-Remaining: 39
X-Ratelimit-Reset: 1
X-Request-Id: 3c560540d80d8b8a
Date: Mon, 14 Sep 2026 08:58:16 GMT
Content-Length: 507

```

Sixty requests fired back to back with one token, followed by one more with its headers shown:

```bash theme={null}
for i in $(seq 1 60); do
  curl -s -o /dev/null -w '%{http_code}\n' \
    -H "Authorization: Bearer $SINGLE_TEAM_READ_TOKEN" \
    "http://127.0.0.1:18200/v1/traces?team=fixture"
done | uniq -c
curl -s -i -H "Authorization: Bearer $SINGLE_TEAM_READ_TOKEN" \
  "http://127.0.0.1:18200/v1/traces?team=fixture"
```

```http theme={null}
  46 200
   4 429
   1 200
   6 429
   1 200
   2 429
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 1
X-Ratelimit-Limit: 20
X-Ratelimit-Remaining: 0
X-Ratelimit-Reset: 1
X-Request-Id: 1591aab456d4c749
Date: Mon, 14 Sep 2026 09:00:09 GMT
Content-Length: 66

{"error":{"code":"rate_limited","message":"rate limit exceeded"}}
```

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](https://docs.vexlo.ai/api/ingest.md#post-/v1/records).

Two cases carry no rate-limit headers. A `401` or `403` is rejected before the limiter runs, as
the responses under [authentication](#base-url-and-authentication) show. And under no-auth mode
there is no token to key a bucket on, so limiting is off and no header appears:

```bash theme={null}
curl -s -o /dev/null -D - http://127.0.0.1:18201/v1/traces
```

```http theme={null}
HTTP/1.1 200 OK
Content-Type: application/json
X-Request-Id: 1f30e19fb987442e
Date: Mon, 14 Sep 2026 08:58:17 GMT
Content-Length: 43

```

## 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`](https://docs.vexlo.ai/api/traces#get-/v1/traces/id/values), addressed by `path` and
`slot`, and
[`GET /v1/functions/examples?include=values`](https://docs.vexlo.ai/api/functions#get-/v1/functions/examples), which
serves the `input` and `output` of up to five example attempts of one function.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.