Skip to content

Write your own client

You need this page if you are on a runtime with no Metergraph SDK, if you are backfilling from your own store, or if your architecture already funnels telemetry through one collector and you want that collector to speak to Metergraph directly.

Everything here is HTTP and JSON. There is nothing in the SDK you cannot do yourself.

The SDK’s published default is https://d2xus7mp8zdv6t.cloudfront.net, and it is overridden with METERGRAPH_INGEST_URL. If you run Metergraph yourself, the base URL is your own origin: the customer-local stack serves on port 8080 and the open-source server on port 8787. The examples below use $METERGRAPH_INGEST_URL so you can paste them either way.

Every request carries Authorization: Bearer and one of two credentials.

TokenLooks likeWhere it comes fromLifetime
App tokenmg_ and 48 hex charactersThe Keys page in the dashboardUntil revoked
Session tokenmgs_ and 48 hex charactersPOST /v1/ingest/sessions1 hour

An app token is enough on its own. A session token adds one thing: repository attribution, which is what lets a recommendation be turned into a pull request in the right repository. The two are mutually exclusive on a request. Send one, never both, and never a session token to an endpoint that expects an app token.

POST /v1/ingest
curl -X POST "$METERGRAPH_INGEST_URL/v1/ingest" \
-H "Authorization: Bearer $METERGRAPH_APP_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"schema_version": 1,
"rows": [
{
"ts": "2026-09-10T12:00:00Z",
"route": "ticket-classifier",
"provider": "openai",
"model": "gpt-5.6-luna",
"input_tokens": 1412,
"output_tokens": 318,
"cache_read_tokens": 900,
"latency_ms": 4120,
"ttft_ms": 610,
"status": "success",
"stream": false,
"session_id": "ticket-8841",
"environment": "production",
"func": "classify",
"module": "support.agent",
"sdk": "acme-collector",
"sdk_version": "1.4.0"
}
],
"meta": {}
}'

schema_version must be 1. rows must be a non-empty list of objects. meta is optional and must be an object if present; the SDKs use it to report how many rows they have dropped and how many transport errors they have seen.

No individual row field is mandatory, but the ones you omit cost you something concrete:

  • No route and no template_hash, and the call is not attributed to a route at all.
  • No model, or a model that is not in the catalog, and the call is stored with no cost and counted in the unpriced coverage bucket.
  • No ts, and the row is timestamped on arrival.

See Captured fields for every field a row can carry and what each one feeds.

Gzip the body and set Content-Encoding: gzip. Only identity and gzip are accepted. The 8 MiB body limit is checked twice, once on what you sent and once on what it decompressed to, so a compression bomb is refused rather than expanded.

202 Accepted
{ "accepted": 100, "ignored": 0, "batch": "tenant-slug/2026/09/10/..." }

batch is the durable key the batch was written under, and is null when no row survived. A 202 means the batch was accepted for processing, not that every row is now queryable: normalization, pricing and enrichment happen after the response. A malformed outcome row is dropped at that point without telling you.

Outcomes go in the same rows array as calls, distinguished by event_type.

An outcome row
{
"event_type": "outcome",
"event_id": "b1e0d1e2-3f44-4a5b-9c6d-7e8f90a1b2c3",
"ts": "2026-09-10T12:05:00Z",
"route": "ticket-classifier",
"session_id": "ticket-8841",
"model": "gpt-5.6-luna",
"task_completed": true,
"feedback_score": 0.8,
"turns_to_resolution": 3,
"escalated": false,
"abandoned": false,
"edit_distance_ratio": 0.12,
"regeneration_count": 1
}

event_id, route, session_id, model and a boolean task_completed are all required, and the row is silently discarded without them. The route must already exist in the workspace, which means it must already have captured calls. Ranges and meanings are on Record real outcomes.

CodeMeaning
202Accepted
400More than 5,000 rows, an empty or non-list rows, a row that is not an object, a meta that is not an object, an unsupported schema_version, or a body that claims gzip and is not
401Unknown or revoked key, an expired session token, or an inactive workspace
402quota_exceeded. The monthly captured-call allowance is spent
403The key does not carry the ingest scope
413The body is larger than 8 MiB, before or after decompression
415A Content-Encoding other than identity or gzip
422An invalid session exchange request

This is what the SDK transport does, and it is a reasonable contract to implement:

ResponseWhat to do
202Reset the backoff
401 or 403Stop. With a session token, discard it and exchange a new one. With an app token, disable delivery for the process: the credential is wrong and retrying will not fix it
413 with more than one rowSplit the batch in half and deliver each side
400, 404, 413 with one row, or 422Drop the batch and record the failure. The payload is the problem
Anything else, including 402 and 5xxBack off and retry. The SDK starts at 1 second and doubles to a 60-second ceiling, dropping rows queued while it waits

Two rules matter more than the rest. Never let delivery block the call you are measuring, and never retry so hard that a Metergraph outage becomes your outage.

Outcome rows are unique per workspace and event_id, so re-sending one is harmless. Call rows are not deduplicated: a request that already returned 202 and that you send again produces a second set of rows. If your client can retry after an ambiguous timeout, keep the batch and treat a repeat 202 as the risk it is, or accept the duplicate knowingly.

Repository attribution with a session token

Section titled “Repository attribution with a session token”

Do this if you want approved recommendations to be deliverable as pull requests. Skip it otherwise: an app token alone captures everything else.

  1. POST /v1/ingest/sessions
    curl -X POST "$METERGRAPH_INGEST_URL/v1/ingest/sessions" \
    -H "Authorization: Bearer $METERGRAPH_APP_TOKEN" \
    -H "Content-Type: application/json" \
    -d '{
    "protocol_version": 2,
    "repository": "your-org/your-repo",
    "sdk_version": "acme-collector/1.4.0"
    }'

    protocol_version must be 2. repository must be exactly one owner/name pair, with no extra path segments and no whitespace. sdk_version identifies your client and is required. Anything else is a 422.

    The two schemas are published and versioned: request and response, listed with their checksums in the contract index. Validate against them in your own tests rather than against these examples.

  2. 200 OK
    {
    "session_token": "mgs_...",
    "expires_at": "2026-09-10T13:00:00Z",
    "repository_id": "9a1f..."
    }

    The response is Cache-Control: no-store, and only a hash of the token is kept server-side. The token is shown once. Hold it in memory.

  3. Send Authorization: Bearer mgs_... instead of the app token. The server stamps the session’s repository onto every row and strips any repository_id or protocol_version you supplied, so a client cannot claim a repository it did not exchange for.

  4. The token lives one hour. Re-exchange on expiry, and re-exchange immediately on a 401 rather than treating that 401 as fatal.

POST /v1/traces accepts OTLP, both JSON and binary protobuf, and answers 200 with an ExportTraceServiceResponse. It behaves differently from /v1/ingest in ways that will bite you if you assume otherwise: it returns 413 rather than 400 when the row limit is exceeded, and it counts spans that are not GenAI spans as rejected without saying which. You also give up automatic function attribution and have to set route names yourself. See OTLP spans.