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, 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.
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: gzipIdempotency-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.
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 |
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 |
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.
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.
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 |
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 } }}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 |
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 |
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.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.
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 |
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.
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, 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 |
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 |
| 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 |
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