Event shape & API

A cost event carries either an explicit cost, or the data Marginal needs to compute one:

FieldRules
costExplicit cost in dollars — finite number ≥ 0. Required unless provider, model, and usage are all present. When both are sent, cost wins.
providerProvider name — openai, anthropic, gemini, groq, … Any string is accepted (lowercased, ≤ 64 chars).
modelThe response-reported model (response.model), ≤ 200 chars. Dated snapshots price differently, so don't send the alias you requested.
usageThe provider's usage object, pasted as-is. Marginal detects the dialect by its shape — OpenAI (Chat Completions and Responses), Anthropic, Gemini/Vertex, Bedrock, and OpenAI-compatible providers all work — and normalizes the token counts server-side, including the cached/uncached split.
fieldsFlat object of key–value pairs from your registered vocabulary. Values are strings, numbers, or booleans, each ≤ 256 characters.
event_idOptional idempotency key, 1–128 characters — events sharing one land once (30-day window), and repeats are skipped and still counted accepted. The SDKs stamp a random UUID per event automatically, so a retried batch whose response was lost never double-counts; supply your own (e.g. a request id) to also dedupe re-runs of your own pipeline.

So the two valid shapes are: an LLM call —

{
  "provider": "openai",
  "model": "gpt-4o-2024-08-06",
  "usage": { "prompt_tokens": 2006, "completion_tokens": 300,
             "prompt_tokens_details": { "cached_tokens": 1920 } },
  "fields": { "customer": "acme", "feature": "chat" }
}

— and generic spend, for anything Marginal can't price (voice minutes, images, tool calls) or a cost you've already computed:

{ "cost": 0.05, "fields": { "customer": "acme", "feature": "voice" } }

Timestamps are assigned server-side on arrival — the API does not accept client-supplied timestamps, and there is no historical backfill. The keys timestamp, service_tier, and requested_model are reserved: accepted today, ignored until implemented.

Pricing#

LLM events are priced at ingest against Marginal's model price catalog: your project's custom prices (Models page) are checked first, then the global catalog under provider/model, then the bare model string. The computed cost is frozen — later price edits never rewrite history.

If the model isn't in the catalog, the event still lands, with its token counts but no cost — never a silent $0 — and the model is reported in the response's unpriced array (the SDKs surface it as an unpriced-models warning). Add a custom price on the Models page to start pricing it.

SDK#

Both SDKs are fire-and-forget: track is synchronous, never throws, and never blocks your request path. Events are buffered and flushed as batches — every 5 seconds, when 100 events are buffered, or on flush()/shutdown(). Failed sends retry up to 3 times (network errors, 429s, and 5xx only), and the buffer caps at 10,000 events (oldest dropped first).

const marginal = new Marginal({
  apiKey: "mgl_…",          // required
  flushInterval: 5_000,     // ms between automatic flushes
  flushAt: 100,             // buffered events that trigger a flush
  maxRetries: 3,
  maxBufferedEvents: 10_000,
  onError: (error) => {},   // defaults to console.warn("[marginal] …")
  flushOnExit: true,        // flush on beforeExit / SIGTERM / SIGINT
});

Errors never raise into your code — they're reported through onError / on_error. Two worth knowing during integration: ignored-fields fires when the API strips field keys that aren't registered, and unpriced-models fires when a model isn't in the price catalog — both name the offenders, so typos and missing prices surface immediately instead of silently disappearing.

Delivery guarantees#

Delivery is best-effort, at-most-once. Events are buffered in memory — there is no disk persistence — so a hard crash (kill -9, OOM, an uncaught exception, process.exit()) loses whatever is still buffered: with the defaults, at most ~5 seconds or 100 events. A batch whose retries are exhausted is dropped and reported through onError / on_error; the SDKs never block or crash your app to save an event.

On a clean exit both SDKs flush what's left automatically: Python via an atexit hook, TypeScript (0.1.2+) via beforeExit plus SIGTERM/SIGINT handlers (Node, Bun, Deno). The TS signal handlers deregister themselves first — so a second Ctrl-C terminates immediately — and re-raise the signal after flushing when your app has no handler of its own, preserving the process's exit code. If your app does handle the signal, the SDK only flushes and leaves the exit to you. Opt out with flushOnExit: false / flush_on_exit=False. One Python caveat: atexit does not run on an unhandled SIGTERM — call shutdown() from your own signal handler if you need that.

In serverless runtimes the sandbox can be frozen or killed as soon as a response is produced, before any exit hook runs — await marginal.flush() (or shutdown()) before returning.

Duplicates are rare but possible: a batch is retried when the response is lost (say, a network timeout after the server already accepted it), and events carry no idempotency keys yet, so such a batch can land twice. In practice: no duplicates under normal operation, and loss bounded to seconds on a crash.

HTTP API#

If you'd rather not use an SDK, the wire contract is one endpoint:

curl -X POST https://api.marginalhq.com/v1/events \
  -H "Authorization: Bearer mgl_…" \
  -H "Content-Type: application/json" \
  -d '{
    "events": [
      { "provider": "anthropic", "model": "claude-sonnet-5",
        "usage": { "input_tokens": 810, "output_tokens": 512,
                   "cache_read_input_tokens": 5100 },
        "fields": { "customer": "acme", "feature": "chat" } },
      { "cost": 0.0042, "fields": { "customer": "acme", "feature": "voice" } }
    ]
  }'

Limits: up to 500 events per request and a 1 MB body.

Response#

A well-formed batch always returns 202 — acceptance is per event:

{
  "accepted": 2,
  "rejected": [{ "index": 1, "error": "event needs cost or (provider, model, usage)" }],
  "ignored": ["modle"],
  "unpriced": ["my-fine-tune"]
}
  • accepted — events that landed.
  • rejected — invalid events, by index in your batch, with the reason. One bad event never poisons the rest.
  • ignored — field keys that were stripped because they aren't registered, deduplicated across the batch. The events themselves still land, minus those keys.
  • unknown — top-level keys that aren't part of the event envelope, deduplicated across the batch. Usually a typo (costs, feilds) — the events still land, but whatever those keys carried was not read.
  • unpriced — models the price catalog couldn't price, deduplicated across the batch. Those events landed with their token counts but no cost.

Error responses#

StatusMeaning
400Body isn't valid JSON, isn't { "events": [...] }, or exceeds 500 events
401Missing, invalid, or revoked API key
413Body exceeds 1 MB