Log inSign up

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.

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

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.

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

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] …")
});
marginal = Marginal(
    "mgl_…",                  # api_key, required
    flush_interval=5.0,       # seconds between automatic flushes
    flush_at=100,             # buffered events that trigger a flush
    max_retries=3,
    max_buffered_events=10_000,
    on_error=None,            # defaults to logging.getLogger("marginal").warning
    flush_on_exit=True,       # atexit auto-flush
)

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.

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