Event shape & API
A cost event carries either an explicit cost, or the data Marginal needs to compute one:
| Field | Rules |
|---|---|
cost | Explicit cost in dollars — finite number ≥ 0. Required unless provider, model, and usage are all present. When both are sent, cost wins. |
provider | Provider name — openai, anthropic, gemini, groq, … Any string is accepted (lowercased, ≤ 64 chars). |
model | The response-reported model (response.model), ≤ 200 chars. Dated snapshots price differently, so don't send the alias you requested. |
usage | The 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. |
fields | Flat 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
| Status | Meaning |
|---|---|
400 | Body isn't valid JSON, isn't { "events": [...] }, or exceeds 500 events |
401 | Missing, invalid, or revoked API key |
413 | Body exceeds 1 MB |