Ingest API
You need this page only if you are writing your own client, sending from a language with no SDK, or debugging a transport problem. The Python and TypeScript SDKs implement all of it.
Base URL for the hosted service: https://app.metergraph.dev. For a self-hosted
deployment, whatever you set as METERGRAPH_INGEST_URL.
Endpoints at a glance
Section titled “Endpoints at a glance”| Method and path | Auth | Scope | Purpose |
|---|---|---|---|
POST /v1/ingest | mgs_ session token, or mg_ app token | ingest | Send captured call rows |
POST /v1/ingest/sessions | mg_ app token | ingest | Exchange an app token for a repository-scoped session token |
POST /v1/traces | mg_ app token | ingest | Send OTLP spans, JSON or protobuf |
GET /v1/config | mg_ app token | ingest | Read the per-route model configuration document |
GET /healthz | none | — | Liveness and database reachability |
Authentication
Section titled “Authentication”Every authenticated endpoint takes Authorization: Bearer <token>. There are
three token formats and they are not interchangeable.
| Prefix | Shape | Where it comes from | Lifetime |
|---|---|---|---|
mg_ | mg_ plus 48 hex characters | Minted in the dashboard under Ingest keys, or by the operator CLI | Until revoked |
mgs_ | mgs_ plus 48 hex characters | POST /v1/ingest/sessions | 1 hour |
mgl_ | mgl_ plus 48 hex characters | Bot delivery leases. Not part of ingestion | Lease-scoped |
Only the SHA-256 digest of a token is stored. A token is shown once, at creation, and cannot be recovered.
Scopes
Section titled “Scopes”| Scope | What it opens | Can the dashboard mint it? |
|---|---|---|
ingest | POST /v1/ingest, POST /v1/ingest/sessions, POST /v1/traces, GET /v1/config | Yes. This is the only scope a dashboard key gets |
read | Read APIs | No. Operator CLI only |
agent:read | The MCP server | No. Operator CLI only |
A dashboard-created key carries exactly ["ingest"]. A token that resolves but
lacks the required scope gets 403, not 401.
POST /v1/ingest
Section titled “POST /v1/ingest”The main path. Sends a batch of captured call rows.
Request
Section titled “Request”POST /v1/ingest HTTP/1.1Host: app.metergraph.devAuthorization: Bearer mgs_...Content-Type: application/jsonContent-Encoding: gzip{ "schema_version": 1, "rows": [ { "ts": "2026-09-10T12:00:00+00:00", "provider": "openai", "model": "gpt-5.5" } ], "meta": { "dropped": 0, "transport_errors": 0 }}| Field | Type | Required | Notes |
|---|---|---|---|
schema_version | integer | No | Must be exactly 1. A missing value is treated as 1. A boolean or a non-integer is a 400 |
rows | array of objects | Yes | Non-empty, at most 5,000 entries, every entry an object |
meta | object | No | Free-form batch metadata. Must be an object if present. The SDKs send dropped and transport_errors |
Row fields are documented in
Captured fields. No row field is required.
Unknown fields are preserved into the row’s meta column rather than rejected.
Headers
Section titled “Headers”| Header | Values | Notes |
|---|---|---|
Authorization | Bearer mgs_... or Bearer mg_... | A mgs_ token is resolved on its own path and its tenant is authoritative. The two formats are mutually exclusive |
Content-Type | application/json | |
Content-Encoding | identity or gzip | Anything else is 415. Absent means identity |
Response
Section titled “Response”202 Accepted.
{ "accepted": 100, "ignored": 0, "batch": "<opaque object key>" }| Field | Type | Meaning |
|---|---|---|
accepted | integer | Rows written durably |
ignored | integer | len(rows) - accepted |
batch | string or null | The batch’s storage key. null when no row survived validation, in which case accepted is 0 and ignored equals the row count |
The batch is durable before the 202 returns. Postgres and the queue carry only
pointers to it.
Status codes
Section titled “Status codes”| Code | Condition |
|---|---|
202 | Accepted, including the zero-accepted case above |
400 | More than 5,000 rows, an empty or non-list rows, a row that is not an object, a body that is not JSON, a body that is not a JSON object, a non-integer schema_version, a schema_version other than 1, a non-object meta, or a Content-Encoding: gzip body that is not valid gzip |
401 | Unknown or revoked key, an expired or invalid session token, or an inactive tenant |
402 | quota_exceeded. The monthly captured-call allowance is spent |
403 | The token resolves but lacks the ingest scope |
413 | The body exceeds 8 MiB, checked both before and after gzip decompression |
415 | A Content-Encoding other than identity or gzip |
The 402 body is structured, not a bare string:
{ "detail": { "code": "quota_exceeded", "message": "This workspace has reached its monthly captured-call limit.", "quota": { "period_month": "2026-09-01", "calls_counted": 100000, "allowance": 100000, "over_quota": true, "percent_used": 100.0 } }}What a client should do with each code
Section titled “What a client should do with each code”| Code | Correct behavior |
|---|---|
400, 422 | Drop the batch. It will never succeed. Log once |
401, 403 | With a session token, invalidate it and re-exchange. With an app token, stop sending for the life of the process |
402 | Stop sending until the next calendar month, or until the allowance is raised |
413 | Split the batch in half and retry each half, down to a single row. Drop a single row that still fails |
415 | Fix the header. Do not retry unchanged |
5xx and network errors | Exponential backoff. The SDKs start at 1 second and double to a 60 second ceiling |
POST /v1/ingest/sessions
Section titled “POST /v1/ingest/sessions”Exchanges a long-lived mg_ app token for a short-lived, repository-scoped
mgs_ session token. This is how SDK 0.4 and later send rows: the app token
touches only this endpoint, never /v1/ingest.
Published schemas: request and response. Both are listed in the contract index with a SHA-256 you can pin.
Request
Section titled “Request”{ "protocol_version": 2, "repository": "acme/checkout", "sdk_version": "0.6.5" }| Field | Type | Required | Validation |
|---|---|---|---|
protocol_version | integer | Yes | Must be exactly 2 |
repository | string | Yes | Non-empty. Matches ^[^/\s]+/[^/\s]+$, so owner/name with no spaces and exactly one slash |
sdk_version | string | Yes | Non-empty |
additionalProperties is false in the published schema.
Response
Section titled “Response”200 OK, with Cache-Control: no-store.
{ "session_token": "mgs_...", "expires_at": "2026-09-10T13:00:00+00:00", "repository_id": "..."}| Field | Type | Notes |
|---|---|---|
session_token | string | mgs_ plus 48 hex characters. Returned once, never persisted in plaintext |
expires_at | ISO-8601 date-time | One hour from issue |
repository_id | string | The server’s stable identifier for repository. Stamped on every row you then send |
The repository row is created on first use, so no setup step is needed.
Status codes
Section titled “Status codes”| Code | Condition |
|---|---|
200 | Session minted |
401 | Unknown or revoked app token |
403 | The token lacks the ingest scope |
422 | protocol_version is not 2, repository is missing or not a non-empty string, sdk_version is missing or not a non-empty string, or the repository identifier is rejected |
Refresh behavior
Section titled “Refresh behavior”The SDKs cache the token in memory and re-exchange when it is within 30 seconds
of expiry, on the background delivery thread rather than on your request path. A
failed exchange backs off from 1 second to a 60 second ceiling, and rows are
dropped or buffered in the meantime. The app token is never sent to /v1/ingest
as a fallback.
POST /v1/traces
Section titled “POST /v1/traces”OTLP/HTTP span ingestion, for a codebase already instrumented with
OpenTelemetry. This endpoint behaves differently from /v1/ingest in four ways,
so read the table below rather than assuming symmetry.
Request
Section titled “Request”| Header | Values |
|---|---|
Authorization | Bearer mg_.... Session tokens are not accepted here |
Content-Type | application/json or application/x-protobuf. Parameters after ; are ignored |
Content-Encoding | identity or gzip |
The body is one OTLP ExportTraceServiceRequest, in whichever of the two
encodings the Content-Type names.
Response
Section titled “Response”200 OK, with an OTLP ExportTraceServiceResponse in the same encoding as
the request. An empty body means every span was accepted. A partial success
looks like this in JSON:
{ "partialSuccess": { "rejectedSpans": "12", "errorMessage": "Metergraph accepts GenAI spans only" } }rejectedSpans is a string in the JSON encoding, as the OTLP JSON mapping
requires. In the protobuf encoding it is partial_success.rejected_spans.
Status codes
Section titled “Status codes”| Code | Condition |
|---|---|
200 | Accepted, including a partial success and including the case where zero spans were GenAI spans |
400 | The body is not decodable as OTLP in the declared encoding, or is not a JSON object |
401 | Unknown or revoked key, or an inactive tenant |
402 | quota_exceeded |
403 | The token lacks the ingest scope |
413 | Body over 8 MiB, before or after decompression, or more than 5,000 GenAI spans. Note that /v1/ingest returns 400 for the row-limit case and this endpoint returns 413 |
415 | A Content-Type other than the two supported, or a Content-Encoding other than identity or gzip |
The four differences from /v1/ingest
Section titled “The four differences from /v1/ingest”/v1/ingest | /v1/traces | |
|---|---|---|
| Encodings | JSON only | JSON and binary protobuf |
| Success code | 202 with a Metergraph body | 200 with an OTLP body |
| Row limit exceeded | 400 | 413 |
| Unusable input | 400 | Counted as rejectedSpans inside a 200 |
Function-level attribution is not available over OTLP, because the span carries no stack. Set route names yourself. See OTLP spans.
GET /v1/config
Section titled “GET /v1/config”Returns the per-route model configuration document the SDKs poll for
model_for().
Request
Section titled “Request”| Header | Values |
|---|---|
Authorization | Bearer mg_.... Requires the ingest scope |
If-None-Match | The ETag from a previous response |
Response
Section titled “Response”200 OK, with ETag: "<32 hex characters>" and
Cache-Control: max-age=30.
{ "routes": { "checkout.summarize": { "version": 3 } } }routes maps a route name to its configuration object, with the row’s version
merged in. The ETag is a SHA-256 over the sorted document, truncated to 32 hex
characters, so an unchanged document always produces the same tag.
304 Not Modified when If-None-Match matches, with the ETag header repeated
and no body.
| Code | Condition |
|---|---|
200 | Document returned |
304 | If-None-Match matched |
401 | Unknown or revoked key |
403 | The token lacks the ingest scope |
GET /healthz
Section titled “GET /healthz”Liveness. No authentication, no body.
{ "ok": true }| Code | Condition |
|---|---|
200 | The server ran select 1 against Postgres successfully |
503 | The database check raised. Body is {"ok": false} |
Limits
Section titled “Limits”| Limit | Value | Applies to |
|---|---|---|
| Rows per request | 5,000 | /v1/ingest (400), /v1/traces GenAI spans (413) |
| Body size | 8 MiB, checked before and after decompression | Both ingest endpoints |
| Monthly captured calls | 100,000 on a free workspace, unlimited on managed | Both ingest endpoints (402) |
| Content per field, retained | 100 KiB | Written at ingest, not a request-time rejection |
A minimal client
Section titled “A minimal client”curl -sS -X POST https://app.metergraph.dev/v1/ingest \ -H "Authorization: Bearer $METERGRAPH_APP_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "schema_version": 1, "rows": [{ "ts": "2026-09-10T12:00:00+00:00", "route": "checkout.summarize", "provider": "openai", "model": "gpt-5.5", "input_tokens": 1200, "output_tokens": 340, "latency_ms": 820, "status": "success" }] }'For a fuller worked example, including the session exchange and the fields worth sending, see Write your own client.
Related
Section titled “Related”- Captured fields for every row field
- Errors for every status code with its fix
- Limits and allowances
- Identity model for how tokens, repositories and workspaces relate