Skip to content

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.

Method and pathAuthScopePurpose
POST /v1/ingestmgs_ session token, or mg_ app tokeningestSend captured call rows
POST /v1/ingest/sessionsmg_ app tokeningestExchange an app token for a repository-scoped session token
POST /v1/tracesmg_ app tokeningestSend OTLP spans, JSON or protobuf
GET /v1/configmg_ app tokeningestRead the per-route model configuration document
GET /healthznoneLiveness and database reachability

Every authenticated endpoint takes Authorization: Bearer <token>. There are three token formats and they are not interchangeable.

PrefixShapeWhere it comes fromLifetime
mg_mg_ plus 48 hex charactersMinted in the dashboard under Ingest keys, or by the operator CLIUntil revoked
mgs_mgs_ plus 48 hex charactersPOST /v1/ingest/sessions1 hour
mgl_mgl_ plus 48 hex charactersBot delivery leases. Not part of ingestionLease-scoped

Only the SHA-256 digest of a token is stored. A token is shown once, at creation, and cannot be recovered.

ScopeWhat it opensCan the dashboard mint it?
ingestPOST /v1/ingest, POST /v1/ingest/sessions, POST /v1/traces, GET /v1/configYes. This is the only scope a dashboard key gets
readRead APIsNo. Operator CLI only
agent:readThe MCP serverNo. Operator CLI only

A dashboard-created key carries exactly ["ingest"]. A token that resolves but lacks the required scope gets 403, not 401.

The main path. Sends a batch of captured call rows.

POST /v1/ingest HTTP/1.1
Host: app.metergraph.dev
Authorization: Bearer mgs_...
Content-Type: application/json
Content-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 }
}
FieldTypeRequiredNotes
schema_versionintegerNoMust be exactly 1. A missing value is treated as 1. A boolean or a non-integer is a 400
rowsarray of objectsYesNon-empty, at most 5,000 entries, every entry an object
metaobjectNoFree-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.

HeaderValuesNotes
AuthorizationBearer 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-Typeapplication/json
Content-Encodingidentity or gzipAnything else is 415. Absent means identity

202 Accepted.

{ "accepted": 100, "ignored": 0, "batch": "<opaque object key>" }
FieldTypeMeaning
acceptedintegerRows written durably
ignoredintegerlen(rows) - accepted
batchstring or nullThe 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.

CodeCondition
202Accepted, including the zero-accepted case above
400More 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
401Unknown or revoked key, an expired or invalid session token, or an inactive tenant
402quota_exceeded. The monthly captured-call allowance is spent
403The token resolves but lacks the ingest scope
413The body exceeds 8 MiB, checked both before and after gzip decompression
415A 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
}
}
}
CodeCorrect behavior
400, 422Drop the batch. It will never succeed. Log once
401, 403With a session token, invalidate it and re-exchange. With an app token, stop sending for the life of the process
402Stop sending until the next calendar month, or until the allowance is raised
413Split the batch in half and retry each half, down to a single row. Drop a single row that still fails
415Fix the header. Do not retry unchanged
5xx and network errorsExponential backoff. The SDKs start at 1 second and double to a 60 second ceiling

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.

{ "protocol_version": 2, "repository": "acme/checkout", "sdk_version": "0.6.5" }
FieldTypeRequiredValidation
protocol_versionintegerYesMust be exactly 2
repositorystringYesNon-empty. Matches ^[^/\s]+/[^/\s]+$, so owner/name with no spaces and exactly one slash
sdk_versionstringYesNon-empty

additionalProperties is false in the published schema.

200 OK, with Cache-Control: no-store.

{
"session_token": "mgs_...",
"expires_at": "2026-09-10T13:00:00+00:00",
"repository_id": "..."
}
FieldTypeNotes
session_tokenstringmgs_ plus 48 hex characters. Returned once, never persisted in plaintext
expires_atISO-8601 date-timeOne hour from issue
repository_idstringThe 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.

CodeCondition
200Session minted
401Unknown or revoked app token
403The token lacks the ingest scope
422protocol_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

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.

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.

HeaderValues
AuthorizationBearer mg_.... Session tokens are not accepted here
Content-Typeapplication/json or application/x-protobuf. Parameters after ; are ignored
Content-Encodingidentity or gzip

The body is one OTLP ExportTraceServiceRequest, in whichever of the two encodings the Content-Type names.

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.

CodeCondition
200Accepted, including a partial success and including the case where zero spans were GenAI spans
400The body is not decodable as OTLP in the declared encoding, or is not a JSON object
401Unknown or revoked key, or an inactive tenant
402quota_exceeded
403The token lacks the ingest scope
413Body 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
415A Content-Type other than the two supported, or a Content-Encoding other than identity or gzip
/v1/ingest/v1/traces
EncodingsJSON onlyJSON and binary protobuf
Success code202 with a Metergraph body200 with an OTLP body
Row limit exceeded400413
Unusable input400Counted 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.

Returns the per-route model configuration document the SDKs poll for model_for().

HeaderValues
AuthorizationBearer mg_.... Requires the ingest scope
If-None-MatchThe ETag from a previous 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.

CodeCondition
200Document returned
304If-None-Match matched
401Unknown or revoked key
403The token lacks the ingest scope

Liveness. No authentication, no body.

{ "ok": true }
CodeCondition
200The server ran select 1 against Postgres successfully
503The database check raised. Body is {"ok": false}
LimitValueApplies to
Rows per request5,000/v1/ingest (400), /v1/traces GenAI spans (413)
Body size8 MiB, checked before and after decompressionBoth ingest endpoints
Monthly captured calls100,000 on a free workspace, unlimited on managedBoth ingest endpoints (402)
Content per field, retained100 KiBWritten at ingest, not a request-time rejection

See Limits and allowances.

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.