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. |
event_id | Optional 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
});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.
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#
| 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 |