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

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.

Scope What it opens Can the dashboard mint it?
ingest POST /v1/ingest, POST /v1/ingest/sessions, POST /v1/traces, GET /v1/config Yes, through Create key (ingest)
read Read APIs No. Operator CLI only
agent:read Tenant-scoped Agent Access Yes, through a coding-agent key with Debug access
agent:replay Bounded non-persistent provider replay Optional on a coding-agent connection

A dashboard-created ingest 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
Idempotency-Key: import-2026-09-16-batch-0001
{
"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.

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
Idempotency-Key 1 to 255 characters Optional. Reusing the key with the same normalized rows and batch metadata returns the original accepted batch without writing it again. Changing the normalized payload returns 409

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.

202 confirms storage and an entry in the durable processing outbox. It does not mean the worker has finished creating calls. Queue publication and processing happen after acceptance. A replay returns the original acceptance counts and batch identifier; those counts do not represent newly inserted rows.

Use one stable key per batch and preserve the rows, metadata, and batch boundaries when retrying. Keys are scoped to a workspace and remain valid while its batch ledger exists. Workspace deletion removes them. Metadata retention can also remove a processed batch ledger after its calls have expired, so keys are not a permanent deduplication guarantee after retention. Do not reuse an old key for a different import.

For bulk uploads, use at most 5,000 rows and 8 MiB of JSON per request, including the uncompressed size. Start with small batches and one request at a time. When admission or database contention exceeds its bounded wait, the API returns 503 with Retry-After. Respect that delay and retry the identical payload and key. The hosted proxy allows 60 seconds for an origin response. If a connection or proxy fails before you receive a response, the outcome is unknown: retry the same key to recover the original acceptance instead of starting a new batch.

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
409 The Idempotency-Key was already used for a different normalized payload
503 Ingest admission or database contention exceeded its bounded wait. Retry the same batch and key after Retry-After

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
}
}
}
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
409 Stop and check the checkpoint and payload. Do not change the key to bypass a payload conflict
503 Respect Retry-After, then retry the same payload with the same idempotency key
5xx and network errors Exponential 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.8" }
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.

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.

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

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.

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
Idempotency-Key 1 to 255 characters. Optional, with the same replay behavior as /v1/ingest

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.

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, or (protobuf only) more than 10,000 spans of any kind or 250,000 nested messages. 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
409 The Idempotency-Key was already used for a different batch size
/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.

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

Header Values
Authorization Bearer mg_.... Requires the ingest scope
If-None-Match The 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.

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

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}
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
Protobuf structure 10,000 spans of any kind and 250,000 nested messages /v1/traces with application/x-protobuf (413)
Monthly captured calls 100,000 on a free workspace, unlimited on managed Both ingest endpoints (402)
Content per field, retained 500 KiB Written 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.