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 base URL
Section titled “The base URL”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.
Authenticate
Section titled “Authenticate”Every request carries Authorization: Bearer and one of two credentials.
| Token | Looks like | Where it comes from | Lifetime |
|---|---|---|---|
| App token | mg_ and 48 hex characters | The Keys page in the dashboard | Until revoked |
| Session token | mgs_ and 48 hex characters | POST /v1/ingest/sessions | 1 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.
Send a batch
Section titled “Send a batch”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
routeand notemplate_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 theunpricedcoverage 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.
Compression
Section titled “Compression”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.
The response
Section titled “The response”{ "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.
Send an outcome
Section titled “Send an outcome”Outcomes go in the same rows array as calls, distinguished by event_type.
{ "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.
Status codes
Section titled “Status codes”| Code | Meaning |
|---|---|
| 202 | Accepted |
| 400 | More 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 |
| 401 | Unknown or revoked key, an expired session token, or an inactive workspace |
| 402 | quota_exceeded. The monthly captured-call allowance is spent |
| 403 | The key does not carry the ingest scope |
| 413 | The body is larger than 8 MiB, before or after decompression |
| 415 | A Content-Encoding other than identity or gzip |
| 422 | An invalid session exchange request |
Retry rules worth copying
Section titled “Retry rules worth copying”This is what the SDK transport does, and it is a reasonable contract to implement:
| Response | What to do |
|---|---|
| 202 | Reset the backoff |
| 401 or 403 | Stop. 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 row | Split the batch in half and deliver each side |
| 400, 404, 413 with one row, or 422 | Drop the batch and record the failure. The payload is the problem |
| Anything else, including 402 and 5xx | Back 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.
Duplicates
Section titled “Duplicates”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.
-
Exchange the app token
Section titled “Exchange the app token”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_versionmust be2.repositorymust be exactly oneowner/namepair, with no extra path segments and no whitespace.sdk_versionidentifies 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.
-
Keep the token, not the plaintext
Section titled “Keep the token, not the plaintext”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. -
Use it on
Section titled “Use it on POST /v1/ingest”POST /v1/ingestSend
Authorization: Bearer mgs_...instead of the app token. The server stamps the session’s repository onto every row and strips anyrepository_idorprotocol_versionyou supplied, so a client cannot claim a repository it did not exchange for. -
Refresh before it expires
Section titled “Refresh before it expires”The token lives one hour. Re-exchange on expiry, and re-exchange immediately on a 401 rather than treating that 401 as fatal.
If you already speak OpenTelemetry
Section titled “If you already speak OpenTelemetry”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.
See also
Section titled “See also”- Limits and allowances
- Errors
- Automate changes with MetergraphBot, which is what repository attribution unlocks