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

# Ingest

> The three routes an ingest token opens: post record batches, send heartbeats, set a capture policy.

Three routes take an `ingest` token. The relay daemon calls two of them: it posts batches of
records tailed from the spool directory and sends a heartbeat per `(service, instance_id)`, then
writes the capture policy each heartbeat returns to a file the SDK reads. It never calls
`PUT /v1/policy`. The command that does is `vexlo policy set`, which an operator runs. Any client
holding an ingest token can call all three routes, and this page does so with `curl`.

Every request and response on this page was captured against a server under bearer
authentication on `127.0.0.1:18200`. The batch posted is the spec's conformance trace, the 49
records of `17-full-worked-trace.json`, held in `$FIXTURE`. `$FIXTURE_INGEST_TOKEN` is an ingest
token for team `fixture` pinned to environment `prod`; `$SERVICE_PINNED_INGEST_TOKEN` is one for
the same team pinned to `-service other-service`.

## POST /v1/records

Ingests a batch of records. The body is a bare JSON array; the fixture file's
`{description, trace_id, records}` envelope is not the wire shape, so the examples post its
`records` array.

| Header | Meaning |
| - | - |
| `Authorization: Bearer <ingest-token>` | Required. The team comes from the token, never from the body. |
| `Content-Type: application/json` | The body is a JSON array. |
| `Content-Encoding: gzip` | Optional. The daemon always compresses; a plain body is accepted. |
| `X-Vexlo-Environment: <name>` | Optional. Applies to every record in the batch: 1 to 64 printable ASCII characters, no comma. Required, and required to match, when the token is pinned to an environment. |

A record is one of five kinds, each an object with a `kind` field:

| `kind` | Ships | Carries |
| - | - | - |
| `trace-open` | Once, when the trace's entry point is entered. | `format_version`, `trace_id`, `service`, `instance_id`, `sdk_version`, `started_at`, `tags`. |
| `step-open` | At most once per attempt, for an attempt reported while it was still running. | `trace_id`, `path`, `name`, `parent_name`, `attempt`, `started_us`, `declared_retryable`. |
| `step-attempt` | Once per attempt, when it completes. | `trace_id`, `path`, `name`, `parent_name`, `attempt`, `started_us`, `duration_us`, `status`, `error_class` on `error`, `declared_retryable`, `continues_retry`, `slots`. |
| `value` | Once per distinct `content_id` per trace, when the root returns. | `trace_id`, `content_id`, `encoding`, `bytes_count`, `truncated`, `value`. |
| `trace-close` | Once, when the root step returns. | `trace_id`, `records_lost`, `truncated`. |

A `step-open` carries what a marked function knows on entry and nothing that depends on how the
call ends: the completion record's `duration_us`, `status`, `error_class`, `continues_retry` and
`slots` are absent from it, and a `step-open` carrying any of the five is rejected. Version 2 of
the record format is what added the kind and changed no field of the other four, so a version-1
trace carries none, and a step known only from a `step-open` reads as an `in_flight` attempt on
the read API ([conventions](https://docs.vexlo.ai/api/conventions.md#the-three-status-fields)).

The exact field rules are the execution graph spec's. An uncompressed body is capped at 4 MiB;
a gzip body is capped at 32 MiB on the wire and at 4 MiB once decompressed. Past either cap the
answer is `413 body_too_large`.

### Responses

Every record accepted is `204` with no body. This is the response to the fixture post that
seeded the Vexlo server:

```bash theme={null}
jq -c '.records' $FIXTURE > fixture-records.json
curl -s -i -X POST http://127.0.0.1:18200/v1/records \
  -H "Authorization: Bearer $FIXTURE_INGEST_TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Vexlo-Environment: prod" \
  --data-binary @fixture-records.json
```

```http theme={null}
HTTP/1.1 204 No Content
X-Ratelimit-Limit-Bytes: 4194304
X-Ratelimit-Limit-Records: 4000
X-Ratelimit-Remaining-Bytes: 8365602
X-Ratelimit-Remaining-Records: 7951
X-Ratelimit-Reset: 1
X-Request-Id: 1149805cd0e6caf5
Date: Mon, 14 Sep 2026 08:55:34 GMT

```

The route is rate limited on two units at once, records and bytes: 4,000 records per second with
a burst of 8,000, and 4 MiB per second with a burst of 8 MiB. `X-Ratelimit-Limit-Records` and
`X-Ratelimit-Limit-Bytes` are the sustained rates, the two `Remaining` headers count down from
the bursts, and one `X-Ratelimit-Reset` covers both. These headers appear once a batch has been
decoded; the `400`, `413`, and environment `422` responses below carry none.

When some records are rejected and the rest accepted, the answer is `200` with `accepted_count`
and one `rejected[]` entry per rejected record, sorted by the record's index in the batch. Here
the batch is the fixture's `trace-close` record followed by an object of unknown kind:

```bash theme={null}
jq -c '[.records[-1], {kind: "bogus"}]' $FIXTURE | curl -s -i -X POST \
  -H "Authorization: Bearer $FIXTURE_INGEST_TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Vexlo-Environment: prod" \
  --data-binary @- http://127.0.0.1:18200/v1/records
```

```http theme={null}
HTTP/1.1 200 OK
Content-Type: application/json
X-Ratelimit-Limit-Bytes: 4194304
X-Ratelimit-Limit-Records: 4000
X-Ratelimit-Remaining-Bytes: 8388485
X-Ratelimit-Remaining-Records: 7998
X-Ratelimit-Reset: 1
X-Request-Id: fea7e69a9fb9e93c
Date: Mon, 14 Sep 2026 09:00:09 GMT
Content-Length: 110

{"accepted_count":1,"rejected":[{"index":1,"code":"invalid_record","message":"unrecognized kind \"bogus\""}]}
```

| `rejected[].code` | Meaning | Retry? |
| - | - | - |
| `invalid_record` | The element is not a valid record: an unrecognized `kind`, a missing field, a value outside its bound, a `step-open` carrying a field that belongs only to a completion record, or a `trace-open` whose `started_at` is older than the shape retention. | No. |
| `trace_shed` | The record's trace is over the shed threshold. Ingest admits per trace under pressure and counts the rejection in the trace's `records_shed`. | No. |
| `trace_team_mismatch` | The record's `trace_id` already belongs to a trace owned by another team. | No. |

A rejected record never blocks the rest of the batch. `trace_shed` and `trace_team_mismatch` were
not captured; the fixture is one trace on one team, and the server was idle.

Two other answers are whole-request. The body must be a decodable JSON array:

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

```http theme={null}
HTTP/1.1 400 Bad Request
Content-Type: application/json
X-Request-Id: 32550ce219c190c9
Date: Mon, 14 Sep 2026 09:00:09 GMT
Content-Length: 117

{"error":{"code":"malformed_batch","message":"decode batch: invalid character 'o' in literal null (expecting 'u')"}}
```

A 5 MiB body of whitespace inside brackets is refused before it is read as JSON:

```bash theme={null}
python3 -c 'print("[" + " " * (5 << 20) + "]")' | curl -s -i -X POST \
  -H "Authorization: Bearer $FIXTURE_INGEST_TOKEN" \
  -H "Content-Type: application/json" -H "X-Vexlo-Environment: prod" -H "Expect:" \
  --data-binary @- http://127.0.0.1:18200/v1/records
```

```http theme={null}
HTTP/1.1 413 Request Entity Too Large
Content-Type: application/json
X-Request-Id: cd1081e3d2df8502
Date: Mon, 14 Sep 2026 09:01:59 GMT
Content-Length: 95
Connection: close

{"error":{"code":"body_too_large","message":"ingest: body exceeds the documented size limit"}}
```

A `429 rate_limited` from this route means one of two things, and the message says which:
`ingest rate limit exceeded` is the token bucket, and `the writer's queue is full` with
`Retry-After: 1` means the server's writer could not take the batch and nothing was written.
Both are safe to retry with the same body.

### Pins

An ingest token carries two optional pins. An environment pin requires `X-Vexlo-Environment` to
carry that exact value; the header is compared as sent, so a comma-separated list is refused
before the pin is consulted:

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

```http theme={null}
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
X-Request-Id: 212647d37952b031
Date: Mon, 14 Sep 2026 09:00:09 GMT
Content-Length: 90

{"error":{"code":"invalid_environment","message":"environment must not contain a comma"}}
```

Omitting the header with a pinned token is a mismatch:

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

```http theme={null}
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
X-Request-Id: b49d6479e0d1ae08
Date: Mon, 14 Sep 2026 09:00:09 GMT
Content-Length: 116

{"error":{"code":"environment_scope_mismatch","message":"token's environment_scope pin excludes this environment"}}
```

A relay daemon forwarding for a pinned token therefore runs with `-environment <that value>`.

A service pin is checked against every `trace-open` in the batch. The fixture declares
`delivery-service`, and this token is pinned to `other-service`:

```bash theme={null}
jq -c '.records' $FIXTURE | curl -s -i -X POST \
  -H "Authorization: Bearer $SERVICE_PINNED_INGEST_TOKEN" \
  -H "Content-Type: application/json" -H "X-Vexlo-Environment: prod" \
  --data-binary @- http://127.0.0.1:18200/v1/records
```

```http theme={null}
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
X-Ratelimit-Limit-Bytes: 4194304
X-Ratelimit-Limit-Records: 4000
X-Ratelimit-Remaining-Bytes: 8365602
X-Ratelimit-Remaining-Records: 7951
X-Ratelimit-Reset: 1
X-Request-Id: 73d6c5a1d17b4e12
Date: Mon, 14 Sep 2026 09:00:09 GMT
Content-Length: 121

{"error":{"code":"service_scope_mismatch","message":"token's service_scope pin excludes a service this batch declares"}}
```

`POST /v1/heartbeat` checks neither pin.

### Duplicates and order

A step's identity is its `path`, so ingest is an upsert: a record delivered twice, or a child
arriving before its parent, changes nothing about the trace's shape. The same 49 records posted
a second time, gzip-compressed this time, are accepted again:

```bash theme={null}
jq -c '.records' $FIXTURE | gzip | curl -s -i -X POST \
  -H "Authorization: Bearer $FIXTURE_INGEST_TOKEN" \
  -H "Content-Type: application/json" -H "Content-Encoding: gzip" \
  -H "X-Vexlo-Environment: prod" \
  --data-binary @- http://127.0.0.1:18200/v1/records
```

```http theme={null}
HTTP/1.1 204 No Content
X-Ratelimit-Limit-Bytes: 4194304
X-Ratelimit-Limit-Records: 4000
X-Ratelimit-Remaining-Bytes: 8365602
X-Ratelimit-Remaining-Records: 7951
X-Ratelimit-Reset: 1
X-Request-Id: dd619fed86208660
Date: Mon, 14 Sep 2026 09:00:09 GMT

```

The trace's steps, counts, and coverage block read as they did after the first post:

```bash theme={null}
curl -s -H "Authorization: Bearer $READ_TOKEN" \
  http://127.0.0.1:18200/v1/traces/0f1a2b3c4d5e6f708192a3b4c5d6e7f8 \
  | jq '{step_count, error_step_count, completeness, root_status, coverage}'
```

```json theme={null}
{
  "step_count": 17,
  "error_step_count": 4,
  "completeness": "complete",
  "root_status": "error",
  "coverage": {
    "records_lost": 0,
    "records_shed": 0,
    "truncated": false,
    "instance_mismatch": false,
    "service_loss_24h": {
      "delivery-service": 0
    }
  }
}
```

One thing did change. `last_seen_at` advanced to the second post, because the upsert keeps the
later of the two arrival instants, and `GET /v1/traces` sorts on `last_seen_at` descending: the
resent trace comes back as the newest row in the list. `first_seen_at` stays where it was.

The function index does not move. Each of its two counters is bumped by a delta the writer
computes against the state the record is about to overwrite, never by a fixed 1, so a record that
reports nothing the index has not already counted bumps it by 0. That is what makes both counts
idempotent under the relay daemon's at-least-once delivery. The three functions below read the
same counts after the resend as before it:

```bash theme={null}
curl -s -H "Authorization: Bearer $READ_TOKEN" \
  "http://127.0.0.1:18200/v1/functions?team=fixture&limit=3" \
  | jq '[.items[] | {name, observed_count, error_count}]'
```

```json theme={null}
[
  {
    "name": "delivery.api.WriteAPIResponse",
    "observed_count": 1,
    "error_count": 0
  },
  {
    "name": "delivery.dispatch.WriteAuditRecord",
    "observed_count": 1,
    "error_count": 0
  },
  {
    "name": "delivery.dispatch.SelectEligibleDasher",
    "observed_count": 1,
    "error_count": 1
  }
]
```

The server reads no `Idempotency-Key` header. Correctness rests on the upsert for the trace and
on those computed deltas for the index, so a spool file the relay daemon delivers twice inflates
neither ([functions](https://docs.vexlo.ai/api/functions.md)).

## POST /v1/heartbeat

Reports one instrumented process's delivery counters and returns the capture policy that
process's service resolves to. The relay daemon sends one per live `(service, instance_id)` every
30 seconds, the default of its `-heartbeat-interval` flag.

| Header or field | Meaning |
| - | - |
| `Authorization: Bearer <ingest-token>` | Required. The team comes from the token. |
| `X-Vexlo-Environment` | Optional. Recorded for `GET /v1/coverage` and `GET /v1/health` attribution only. |
| `service` | Required, 1 to 128 printable ASCII characters. |
| `instance_id` | Required, 1 to 128 characters. |
| `counters.records_sent` | Records the daemon has forwarded for this writer instance. |
| `counters.dropped` | A map of drop cause to count, at most 64 causes. The increase since the previous heartbeat is added to the service's `records_lost_24h`. |
| `counters.outbox_depth` | Records for this writer instance still unsent in the spool directory. |

The body is capped at 16 KiB. The route shares the read surface's rate limit (20 requests per
second, burst 40) and carries its headers. The response is always `200` with the resolved
capture policy, never `204`. A team that has never set one resolves to the system default:

```bash theme={null}
curl -s -i -X POST -H "Authorization: Bearer $FIXTURE_INGEST_TOKEN" \
  -H "Content-Type: application/json" -H "X-Vexlo-Environment: prod" \
  --data '{"service":"delivery-service","instance_id":"delivery-service-7f9c8d6b5-x2n4q","counters":{"records_sent":49,"dropped":{"trace_overflow":0},"outbox_depth":0}}' \
  http://127.0.0.1:18200/v1/heartbeat
```

```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: 5ab1832be68a3de9
Date: Mon, 14 Sep 2026 09:00:09 GMT
Content-Length: 123

{"enabled":true,"value_sample_rate":1,"max_value_bytes":16384,"capture_logs":true,"updated_at":"2026-09-14T09:00:09.780Z"}
```

Every field of the default is the identity value of the clamp the SDK applies (`enabled` and
`capture_logs` under AND, `value_sample_rate` under min, `max_value_bytes` at the SDK's own 16 KiB
ceiling), so it changes nothing in a process regardless of its `init()` settings. `updated_at` is
stamped fresh on every response: it is a liveness signal, not the time the capture policy last
changed.

A body that is not JSON is `400 malformed_request`, and a body that decodes but fails validation
is `422 invalid_heartbeat`:

```http theme={null}
HTTP/1.1 400 Bad Request
Content-Type: application/json
X-Ratelimit-Limit: 20
X-Ratelimit-Remaining: 38
X-Ratelimit-Reset: 1
X-Request-Id: 2d92c003923f6f9f
Date: Mon, 14 Sep 2026 09:00:09 GMT
Content-Length: 86

{"error":{"code":"malformed_request","message":"request body is not decodable JSON"}}
```

```bash theme={null}
curl -s -i -X POST -H "Authorization: Bearer $FIXTURE_INGEST_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{"service":"","instance_id":"i-1"}' http://127.0.0.1:18200/v1/heartbeat
```

```http theme={null}
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
X-Ratelimit-Limit: 20
X-Ratelimit-Remaining: 37
X-Ratelimit-Reset: 1
X-Request-Id: f8c3d60fcff7b42e
Date: Mon, 14 Sep 2026 09:00:09 GMT
Content-Length: 91

{"error":{"code":"invalid_heartbeat","message":"service must be 1-128 characters, got 0"}}
```

## PUT /v1/policy

Sets the capture policy a heartbeat resolves to. A heartbeat resolves the service's own row
first, then the team-wide row, then the system default above.

| Parameter or field | Meaning |
| - | - |
| `?service=<name>` | Optional. Omit it to set the team-wide default. |
| `enabled` | Whether the SDK captures at all. |
| `value_sample_rate` | The fraction of `ok` traces whose values are kept, in `[0, 1]`. |
| `max_value_bytes` | The per-slot byte ceiling, greater than 0. |
| `capture_logs` | Whether log capture is enabled. |

The body is capped at 4 KiB, the route takes no `?environment=`, and the team comes from the
token. A successful write echoes the body back with `200`:

```bash theme={null}
curl -s -i -X PUT -H "Authorization: Bearer $FIXTURE_INGEST_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{"enabled":true,"value_sample_rate":0.5,"max_value_bytes":8192,"capture_logs":false}' \
  "http://127.0.0.1:18200/v1/policy?service=delivery-service"
```

```http theme={null}
HTTP/1.1 200 OK
Content-Type: application/json
X-Ratelimit-Limit: 20
X-Ratelimit-Remaining: 36
X-Ratelimit-Reset: 1
X-Request-Id: 43908f9572544c6b
Date: Mon, 14 Sep 2026 09:00:09 GMT
Content-Length: 85

{"enabled":true,"value_sample_rate":0.5,"max_value_bytes":8192,"capture_logs":false}
```

The next heartbeat for `delivery-service` resolves to it:

```json theme={null}
{"enabled":true,"value_sample_rate":0.5,"max_value_bytes":8192,"capture_logs":false,"updated_at":"2026-09-14T09:00:09.826Z"}
```

An out-of-range field is `422 invalid_policy`, and `details.field` names it:

```bash theme={null}
curl -s -i -X PUT -H "Authorization: Bearer $FIXTURE_INGEST_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{"enabled":true,"value_sample_rate":1.5,"max_value_bytes":8192,"capture_logs":false}' \
  "http://127.0.0.1:18200/v1/policy?service=delivery-service"
```

```http theme={null}
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
X-Ratelimit-Limit: 20
X-Ratelimit-Remaining: 35
X-Ratelimit-Reset: 1
X-Request-Id: 88c897faaa06ebb3
Date: Mon, 14 Sep 2026 09:00:09 GMT
Content-Length: 123

{"error":{"code":"invalid_policy","message":"value_sample_rate must be in [0,1]","details":{"field":"value_sample_rate"}}}
```

A token pinned to a service writes only that service's row. Writing another service's row, or the
team-wide row, is `422 service_scope_mismatch`:

```http theme={null}
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
X-Ratelimit-Limit: 20
X-Ratelimit-Remaining: 39
X-Ratelimit-Reset: 1
X-Request-Id: 22d19bd4fdc9f19e
Date: Mon, 14 Sep 2026 09:00:09 GMT
Content-Length: 104

{"error":{"code":"service_scope_mismatch","message":"token's service_scope pin excludes this service"}}
```

A body that is not JSON is `400 malformed_request`, the same answer the heartbeat route gives.
The SDK applies a resolved capture policy downward only, and the daemon writes it to
`<spool_dir>/policy/<service>.json`; [capture policy](https://docs.vexlo.ai/server/capture-policy.md) covers that side.


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