Browse docs

Martini Billing API

Read what your organization spends on Martini, in olives, over the same key that generates and runs workflows.

Enterprise feature

Everything on this page except the balance is enabled per organization. Request access

Overview

The Billing API reports what your organization spends on Martini. Every number it returns is the organization's own spend in olives, Martini's credit unit. It never returns what Martini pays for a generation or which provider Martini sends a request to; models are named by the Martini model you chose. The two money figures it carries are yours: the nominal value of an olive, and what you paid on an invoice.

It lives under https://api.martini.film/v1/billing and uses the same key as the Generation API. The balance is the pre-flight an operator or an agent runs before submitting a batch. The reporting routes read the account, the organization's price list, spend over time, the ledger, and invoices. Budgets are the one thing the API writes: a hard olive limit on the organization, a project, a member, or an API key.

RouteReturnsNeeds
GET /v1/billing/balanceThe olive balance, or a run’s budget.Any valid key.
GET /v1/billing/accountPlan, olives, automatic top-up, organization budget, olive value.billing:read, enterprise grant
GET /v1/billing/pricingWhat the organization pays per model and for agent work.billing:read, enterprise grant
GET /v1/billing/costsSpend as calendar buckets, grouped by up to two keys.billing:read, enterprise grant
GET /v1/billing/transactionsThe ledger, newest first, cursor-paginated.billing:read, enterprise grant
GET /v1/billing/invoicesInvoices, and a short-lived PDF link per invoice.billing:read, enterprise grant
GET /v1/billing/budgetsEvery budget with where it stands; one by id.billing:read, enterprise grant
POST /v1/billing/budgetsSet a budget; PATCH changes it, DELETE removes it.billing:write, enterprise grant

Enterprise access

The reporting routes are off by default and turned on per organization under a contract. The reference on this page is public: it describes shapes, not prices, and every number the API returns is served only to a key of the organization that owns it.

The balance is the one exception. GET /v1/billing/balance answers every valid key with no grant and no extra permission — it is operational, not financial reporting, and the same figure every member already sees in the app.

To enable the Billing API for your organization, tell us who you are and what you want to build. The request is filed under your account, so the organizations you belong to come with it.

Checking your session…

Prefer email? Write to hello@martini.film with your organization name and what you want to build.

Without the grant, every reporting route answers 403 BILLING_API_NOT_ENABLED; while the grant cannot be checked, they answer 503 BILLING_API_ACCESS_UNAVAILABLE with Retry-After: 10.

Once enabled, two more presets appear when an admin creates a key under Settings → Martini API: Finance read-only (billing:read and nothing else — a key that can read spend but never generate) and Full access (every family at write). Until then they are shown but cannot be chosen, and a key requested with a billing permission over the API answers 403 BILLING_API_NOT_ENABLED. Existing keys are untouched either way: a permission is fixed when its key is created, so enabling the Billing API never widens a key that is already out.

Authentication

Send the key as Authorization: Bearer <key>. An organization admin creates keys under Settings → Martini API. Requests belong to the key's organization; anything outside it reads as 404, never 403, so ids from another workspace cannot be probed. An optional X-Martini-API-Version: 2026-08-13 header pins the response envelope; the served version is echoed back on every response.

Three kinds of key reach the balance, and each gets the answer that fits it:

KeyWhat it reads
Organization key (mtn_live_, or martini login approved for one organization)Its own organization. Passing organizationId is accepted only for that organization; any other id is 404.
Account key (martini login approved "as you")Any organization its user belongs to: name it with ?organizationId=, or omit it for the personal organization.
Workflow run key (mtn_run_, minted for one run's sandbox)The run form of the balance: its own budget and whether the organization covers it — never the organization’s numbers. A run key is refused by every reporting route with 403 RUN_KEY_NOT_ALLOWED.

The reporting routes take the same organizationId query parameter with the same rules: an organization key reads its own organization, an account key names one of its user's or gets the personal one.

Permissions

A key holds a set of family:level permissions, chosen when it is created and never widened; write implies read. The Billing API is its own family: billing:read reads the reporting routes (account, price list, costs, ledger, invoices, budgets), billing:write additionally sets budgets. The balance needs neither — it answers any valid key — so a Runtime key (generations:write, workflows:write, assets:write) reads it and nothing else on this page.

A Finance read-only key holds billing:read alone: it reads spend and cannot generate, run a workflow, or upload, so it is the key to hand a dashboard, a warehouse loader, or a finance tool. A call the key may not make answers 403 API_KEY_PERMISSION_MISSING with the required permission, the granted set, and the settingsUrl where a key with it is created; GET /v1/me lists key.permissions so a client can check before its first call.

Rate limit

Each key may make 120 requests per sliding minute across /v1/billing, the balance included. Every answer carries X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset (Unix seconds by which the window has fully turned over); a request past the allowance answers 429 RATE_LIMITED with Retry-After. A warehouse sync that pages the ledger at 1000 rows a page stays well inside it; a dashboard that polls the balance every second does too.

Balance

GET /v1/billing/balance

The olive balance of the organization the key acts for. Call it before submitting a batch of generations or workflow runs, so the work is covered before it starts. Needs a valid key and nothing else: no enterprise grant, no permission.

curl https://api.martini.film/v1/billing/balance \ -H "Authorization: Bearer $MARTINI_API_KEY"
ParameterTypeNotes
organizationIduuidOptional. For an account key: which of the user’s organizations to read. An organization key accepts only its own id.

The response has one of two forms, told apart by kind. No form carries both olives and covered, so a client never has to guess which it got.

Organization form

What an organization key or an account key gets.

{ "kind": "organization", "organization": { "id": "019b8c2d-5e6f-7000-8000-1234567890a5", "name": "Golden Pictures" }, "billingEnabled": true, "plan": { "id": "studio", "status": "active", "periodEnd": "2026-10-01T00:00:00.000Z" }, "olives": { "available": 3250, "granted": 5000, "included": 4000, "purchased": 1000, "usedThisPeriod": 1750, "reserved": 120, "unlimited": false, "resetsAt": "2026-10-01T00:00:00.000Z" }, "asOf": "2026-09-20T08:04:11.000Z" }
FieldMeaning
organizationThe organization the numbers belong to.
billingEnabledFalse when this deployment has no billing configured: olives is null and nothing is charged. Never a zero, never a sum over past charges.
planThe subscription plan id and status, and when the current period ends; each null when billing is disabled.
olives.availableWhat a new generation or run can spend right now. Active reservation holds (a submitted generation reserves its estimate for up to 20 minutes) are already netted out, so this is the figure the next charge is checked against.
olives.grantedEverything granted this period: included plus purchased.
olives.includedThe plan’s included allowance for the period.
olives.purchasedTop-ups and other prepaid grants.
olives.usedThisPeriodConsumed since the period started.
olives.reservedOlives held by admissions that have not settled yet — work in flight. Informational: available already nets them. Null when this deployment does not record holds.
olives.unlimitedTrue when the organization’s olives are not metered; available then bounds nothing and every batch is covered.
olives.resetsAtWhen the included allowance renews, ISO 8601; null when it does not.
asOfWhen the numbers were read from the billing system; at most 10 seconds old.

With billing disabled:

{ "kind": "organization", "organization": { "id": "…", "name": "Golden Pictures" }, "billingEnabled": false, "plan": { "id": null, "status": null, "periodEnd": null }, "olives": null, "asOf": "2026-09-20T08:04:11.000Z" }

To check a batch, compare olives.available against your estimate (a run's olives.budget, or the model's price from the price list times the count). unlimited: true or billingEnabled: false covers any batch.

Run form

What a workflow run key gets. An agent executing inside a run can plan against its own budget, and learns whether the organization stands behind it, without the organization's balance ever landing in a run log. The same read answers covered; the numbers stay on the server.

{ "kind": "run", "runId": "019b8c2d-5e6f-7000-8000-1234567890a6", "billingEnabled": true, "budget": { "olives": 600, "source": "explicit", "spent": 187.5, "remaining": 412.5 }, "covered": true, "asOf": "2026-09-20T08:04:11.000Z" }
FieldMeaning
runIdThe run the key was minted for.
budget.olivesThe run’s olive budget; null for an unbudgeted run.
budget.sourceexplicit when the caller passed a budget; auto when the run started without one and Martini arms the rail at each action’s gate.
budget.spentGeneration and agent olives the run has spent so far, as the organization is charged for them.
budget.remainingolives minus spent, floored at zero; null for an unbudgeted run.
coveredWhether the organization’s available olives cover budget.remaining. For an unbudgeted run: whether the organization has any olives left to spend. Always true when billingEnabled is false.
asOfWhen the organization balance behind covered was read.

Freshness

The balance is read from the billing system and cached for 10 seconds per organization, so a launcher polling every second costs one read; asOf is the read time, so a poller can see the age. When the billing system cannot be reached and no fresh value is cached, the route answers 503 BALANCE_UNAVAILABLE with Retry-After: 10. A value past its cache window is discarded, not served with a flag: a stale number is never presented as current.

Enforcement happens at charge time, not here. A balance that covers a batch when you read it does not reserve anything; a generation submitted in between spends from the same pool. Submit soon after checking, or set an oliveBudget on each run so the total is bounded either way.

From an agent

The MCP tool get_balance answers the same question with the same shape for an agent working through the MCP connector or the CLI. It takes an optional organizationId, or a projectId to read the organization that pays for that project; omitted, it reads the organization the key or session acts for. Inside a workflow run it returns the run form regardless of arguments — the same fence as the route. It is reachable as POST /v1/tools/get_balance with a key like every other tool, and as martini get_balance from the CLI.

martini get_balance martini get_balance --project-id $PROJECT_ID

Account

GET /v1/billing/account

The status call: the organization's plan, the same olives object as the balance, automatic top-up, the organization-wide budget, and the nominal olive value, in one read. A dashboard that shows “where do we stand” needs nothing else.

curl https://api.martini.film/v1/billing/account \ -H "Authorization: Bearer $MARTINI_API_KEY"
{ "organization": { "id": "019b8c2d-5e6f-7000-8000-1234567890a5", "name": "Golden Pictures" }, "billingEnabled": true, "plan": { "id": "studio", "status": "active", "periodEnd": "2026-11-01T00:00:00.000Z" }, "olives": { "available": 3250, "granted": 5000, "included": 4000, "purchased": 1000, "usedThisPeriod": 1750, "reserved": 120, "unlimited": false, "resetsAt": "2026-11-01T00:00:00.000Z" }, "autoTopUp": { "enabled": true, "thresholdOlives": 500, "refillOlives": 2000 }, "budget": null, "oliveValueUsd": 0.1, "asOf": "2026-10-21T12:00:00.000Z" }
FieldMeaning
organizationThe organization the numbers belong to.
billingEnabledFalse when this deployment has no billing configured: olives and autoTopUp are null, every plan member is null.
planThe subscription plan id and status, and when the current period ends.
olivesThe same object the balance returns; see the olives fields above.
autoTopUpAutomatic top-up as configured in the app: whether it is on, the available figure it fires below, and how many olives it adds. Null when off or unavailable.
budgetThe organization-wide budget when one exists: its limit, period, alerts, and where it stands, as GET /v1/billing/budgets lists it. Null when none is set.
oliveValueUsdThe public nominal value of one olive in US dollars — the one dollar figure in the API, so a dashboard can convert. Not a price paid and not a rate; the same number for every organization.
asOfWhen the numbers were read from the billing system; at most 10 seconds old.

Like the balance, the account is read from the billing system and cached for 10 seconds; it answers 503 BALANCE_UNAVAILABLE the same way when that read fails.

Pricing

GET /v1/billing/pricing

What this organization pays, in olives, for every model it can generate with and for agent work. Prices are finished figures: the organization's own where it has an agreement (source: "organization"), the public list price otherwise (source: "list") — and nothing says how a figure was reached. There is no provider, no cost, and no term on this route, only the number a generation will bill.

curl "https://api.martini.film/v1/billing/pricing?modelId=kling-v3" \ -H "Authorization: Bearer $MARTINI_API_KEY"
ParameterTypeNotes
organizationIduuidOptional. For an account key: which of the user’s organizations to read.
modelIdstringOptional. One model only: a catalog id (kling-v3) or an agent-work id (opus-5.5, sandbox, transcription). An id the organization cannot use answers 400 INVALID_QUERY.
{ "organization": { "id": "019b8c2d-5e6f-7000-8000-1234567890a5", "name": "Golden Pictures" }, "models": [ { "model": { "id": "kling-v3", "name": "Kling 3", "capability": "generate-video" }, "pricing": { "kind": "card", "source": "list", "card": { "unit": "second", "olivesPerUnit": 10, "resolutionTiers": [ { "resolution": "720p", "maxHeight": 720, "olivesPerUnit": 8, "byQuality": { "draft": 4 } }, { "resolution": "1080p", "maxHeight": 1080, "olivesPerUnit": 12 } ], "byQuality": { "draft": 4 }, "surcharges": { "audioPerSecond": 2, "perReferenceImage": 0.5, "includedReferenceImages": 1, "maxReferenceImages": 9 }, "videoReferences": { "olivesPerUnit": 9, "resolutionTiers": [ { "resolution": "720p", "maxHeight": 720, "olivesPerUnit": 9, "byQuality": { "draft": 3.6 } }, { "resolution": "1080p", "maxHeight": 1080, "olivesPerUnit": 10.8 } ], "byQuality": { "draft": 3.6 }, "billsReferenceSeconds": true } } } }, { "model": { "id": "nano-banana-2", "name": "Nano Banana 2", "capability": "generate-still" }, "pricing": { "kind": "card", "source": "organization", "card": { "unit": "image", "olivesPerUnit": 2.4, "resolutionTiers": [ { "resolution": "1K", "olivesPerUnit": 2.4 }, { "resolution": "2K", "olivesPerUnit": 4 } ], "qualityFactors": { "low": 0.4, "medium": 1, "high": 1.5 } } } }, { "model": { "id": "minimax-music-3", "name": "MiniMax Music 3", "capability": "generate-audio" }, "pricing": { "kind": "card", "source": "list", "card": { "unit": "request", "olivesPerUnit": 8.4, "durationTiers": [ { "maxSeconds": 60, "olivesPerUnit": 2.5 }, { "maxSeconds": 180, "olivesPerUnit": 5.4 } ] } } }, { "model": { "id": "sora-3", "name": "Sora 3", "capability": "generate-video" }, "pricing": { "kind": "direct" } }, { "model": { "id": "topaz-image-enhance", "name": "Topaz Image Enhance", "capability": "upscale-image" }, "pricing": { "kind": "quote" } } ], "agentWork": [ { "kind": "model", "id": "opus-5.5", "name": "Claude Opus 5.5", "serviceTier": "standard", "olivesPerMillion": { "input": 48, "cacheRead": 2.4, "cacheWrite": 60, "cacheWrite1h": 96, "output": 240 } }, { "kind": "compute", "id": "sandbox", "name": "Sandbox", "serviceTier": "standard", "olivesPerMillion": { "cpuMilliseconds": 0.168, "memoryMilliseconds": 0.054, "diskMilliseconds": 0.00036 } }, { "kind": "tool", "id": "transcription", "name": "Transcription", "serviceTier": "standard", "olivesPerMillion": { "computeSeconds": 15000 } } ], "oliveValueUsd": 0.1, "asOf": "2026-10-21T12:00:00.000Z" }

Models are sorted by id and named by the Martini catalog id and display name you pick in the app. Each is one of three things, told apart by pricing.kind:

kindMeaning
cardA price card to compute from (below). mediaInputCard is present when a request that carries media inputs prices differently.
quoteThe price depends on the request’s inputs; only the generate-time quote can price it. No card is published.
directThe organization settles this model outside Martini and is charged no olives for it.

Price cards

A card is everything needed to price a request before it is submitted. Start from the tier the request lands in (or olivesPerUnit when there is none), apply the quality and aspect factors, add the surcharges, and respect minimumOlives where the card carries one. The card reads the way the charge does: a tier’s own figure wins over the card’s, and a figure the charge never applies to a model is not on its card. Members a model does not use are absent, not null.

FieldMeaning
unitWhat one priced unit is: second, image, request, minute, or thousandChars.
olivesPerUnitOlives per unit at the default resolution and quality. Null when only resolutionTiers price the model.
minimumOlivesThe least a generation bills, however short. Only the per-minute and per-thousand-character cards carry one; the other units bill no floor.
resolutionTiers[]One row per resolution the model prices differently. resolution is the label a request passes to land in it (720p, 1080p, 2K); a model priced by output height also carries maxHeight, and the first tier whose ceiling covers the output wins. A tier may carry its own byQuality, aspectFactors, and surcharges — a block that replaces the card’s surcharges whole at that tier (an empty block: none).
durationTiers[]For a per-request model priced by length: the first tier whose maxSeconds covers the output prices it; a longer output bills olivesPerUnit.
megapixelTiers[]For a per-image model priced by output size: the first tier whose maxMegapixels covers the output prices it; a larger output bills olivesPerUnit.
byQualityOlives per unit by render quality, where a quality has its own rate.
qualityFactorsFactors on the rate by render quality, where a quality has no rate of its own.
aspectFactorsFactors on the rate by aspect ratio; the rates are the 16:9 price. A tier’s own aspectFactors win at that tier.
surchargesPer-billed-second additions (audioPerSecond, voicePerSecond) and per-input charges (perReferenceImage after includedReferenceImages, up to maxReferenceImages; perThousandReferenceTokens after includedReferenceTokens), on top of the unit rate. A tier’s own surcharges win at that tier.
videoReferencesHow a request that carries video references bills when it differs: its own olivesPerUnit, resolutionTiers, and byQuality, whether trimmed reference seconds bill on top of the generated ones (billsReferenceSeconds), in whole seconds (wholeSeconds), and no less than a fraction of the output (minimumOutputFraction).
billedExtraSecondsSeconds billed once per request beyond the generated seconds.
isMaximumTrue when the figure is a ceiling that settles downward once the generation completes.
chargesBlockedResultsTrue when a result the provider’s safety filter blocks after generating it bills like a finished one and is not refunded. Absent, a blocked result is refunded.

Agent work

agentWork is what the organization pays for the work an agent does on its behalf: chat models by the id and name the picker shows, the sandbox, and tools such as transcription. Rates are olives per million units of each bucket the resource is metered in — the finished price, with nothing of its terms.

FieldMeaning
kindmodel for a chat model, compute for the sandbox, tool for a utility such as transcription.
idWhat a user picks: the chat model id (opus-5.5), sandbox, or transcription.
nameThe display name, as the picker shows it.
serviceTierstandard or fast. A model with both tiers appears twice.
olivesPerMillionOlives per million units of each bucket the resource is metered in: input, cacheRead, cacheWrite, cacheWrite1h, and output tokens for a model; cpuMilliseconds, memoryMilliseconds, and diskMilliseconds for the sandbox; computeSeconds for a tool.
longContextWhere a model charges more past a context length: thresholdTokens and the inputFactor and outputFactor applied beyond it. Absent otherwise.

Costs

GET /v1/billing/costs

Olive spend as a cube: calendar buckets over a range, each holding one cell per group. It is the route a finance dashboard or a chargeback report reads; the ledger below is the row level under it.

curl "https://api.martini.film/v1/billing/costs?from=2026-10-01T00:00:00Z&to=2026-10-05T00:00:00Z&bucket=day&groupBy=project&groupBy=model" \ -H "Authorization: Bearer $MARTINI_API_KEY"
{ "organization": { "id": "019b8c2d-5e6f-7000-8000-1234567890a5", "name": "Golden Pictures" }, "range": { "from": "2026-10-01T00:00:00.000Z", "to": "2026-10-05T00:00:00.000Z" }, "bucket": "day", "groupBy": ["project", "model"], "buckets": [ { "start": "2026-10-01T00:00:00.000Z", "end": "2026-10-02T00:00:00.000Z", "results": [ { "group": { "project": { "id": "019b8c2d-5e6f-7000-8000-1234567890a1", "name": "Moon Base" }, "model": { "id": "kling-v3", "name": "Kling 3" } }, "olives": 120, "refundedOlives": 0, "netOlives": 120, "generations": 1 } ] }, { "start": "2026-10-02T00:00:00.000Z", "end": "2026-10-03T00:00:00.000Z", "results": [ { "group": { "project": { "id": "019b8c2d-5e6f-7000-8000-1234567890a1", "name": "Moon Base" }, "model": { "id": "kling-v3", "name": "Kling 3" } }, "olives": 0, "refundedOlives": 20, "netOlives": -20, "generations": 0 } ] }, { "start": "2026-10-03T00:00:00.000Z", "end": "2026-10-04T00:00:00.000Z", "results": [ { "group": { "project": { "id": "019b8c2d-5e6f-7000-8000-1234567890a1", "name": "Moon Base" }, "model": null }, "olives": 2.5, "refundedOlives": 0, "netOlives": 2.5, "generations": 0 } ] } ], "nextCursor": null, "asOf": "2026-10-21T12:00:00.000Z" }
FieldMeaning
rangeThe resolved [from, to): what was asked, defaulted to the trailing 30 days.
bucketday, week, or month, as requested.
groupByThe group keys, in the order requested.
buckets[].start, endThe bucket, [start, end) in UTC: a calendar day, an ISO week from Monday, or a month.
buckets[].results[].groupThe group the cell belongs to, carrying exactly the groupBy members requested. A null member is unattributed: agent work has no model, a grant has no project.
buckets[].results[].olivesCharges in the bucket.
buckets[].results[].refundedOlivesRefunds in the bucket.
buckets[].results[].netOlivesolives minus refundedOlives.
buckets[].results[].generationsCharged generations in the bucket. Agent work and non-generation rows do not count.
buckets[].nextCursorPass back as cursor for the next page of buckets; null on the last page.
asOfWhen the cube was computed.

Groups and filters

ParameterTypeNotes
organizationIduuidOptional. For an account key: which of the user’s organizations to read.
fromRFC 3339Inclusive start. Defaults to 30 days before to.
toRFC 3339Exclusive end. Defaults to now. The range may cover at most 366 days.
bucketday | week | monthBucket size; calendar-aligned in UTC (weeks start Monday). Default day.
groupBylistUp to two of project, model, actor, user, apiKey, origin, type, billingMode. Each cell’s group carries exactly these members. Omitted: one cell per bucket with an empty group.
projectIdslist of uuidOnly charges in these projects.
apiKeyIdslist of uuidOnly charges made with these keys.
userIdslist of uuidOnly charges by these members.
modelslistOnly charges for these catalog ids (kling-v3).
originslistOnly charges from these surfaces: ui, mcp, api.
typeslistOnly rows of these kinds: generation, export, agentWork, refund, topup, adjustment, signupBonus, transfer.
limitintegerBuckets per page. Default 31, at most 366.
cursorstringThe nextCursor of the previous page, with the same filters.

A list parameter repeats (types=generation&types=export), takes a [] suffix, or joins with commas; at most 50 distinct values each. Filters narrow the rows before they are bucketed, so a group that has no rows in a bucket is absent from that bucket, an empty bucket holds an empty results, and the cells of a bucket are sorted by netOlives, largest first.

Paging

Pages run over buckets, oldest first: limit buckets per page and nextCursor naming the next, null on the last. A page is computed from the ledger rows it covers; one that covers too many answers 400 INVALID_QUERY — lower limit or narrow the range. A cursor belongs to its filters: change a filter and start again without one, or the server answers 400 INVALID_CURSOR.

Transactions

GET /v1/billing/transactions

The organization's olive ledger, newest first: every charge, refund, top-up, and adjustment with who caused it, which project it landed in, and — for a generation — the model the user chose and the surface it came from. It is the route for a warehouse sync: page it to the end once, then from the last createdAt on a schedule.

curl "https://api.martini.film/v1/billing/transactions?types=generation&types=refund&limit=500" \ -H "Authorization: Bearer $MARTINI_API_KEY"
{ "organization": { "id": "019b8c2d-5e6f-7000-8000-1234567890a5", "name": "Golden Pictures" }, "transactions": [ { "id": "019b8c2d-5e6f-7000-8000-000000000004", "createdAt": "2026-10-03T09:00:00.000Z", "type": "generation", "olives": -6, "billingMode": "teamTab", "project": { "id": "019b8c2d-5e6f-7000-8000-1234567890a1", "name": "Moon Base" }, "actor": { "type": "apiKey", "apiKey": { "id": "019b8c2d-5e6f-7000-8000-1234567890a7", "label": "Finance" } }, "generation": { "id": "019b8c2d-5e6f-7000-8000-123456789abc", "model": { "id": "nano-banana-2", "name": "Nano Banana 2" }, "origin": "api" }, "idempotencyKey": "shot-42" }, { "id": "019b8c2d-5e6f-7000-8000-000000000003", "createdAt": "2026-10-02T11:00:00.000Z", "type": "refund", "olives": 20, "billingMode": "teamTab", "project": { "id": "019b8c2d-5e6f-7000-8000-1234567890a1", "name": "Moon Base" }, "actor": { "type": "user", "user": { "id": "019b8c2d-5e6f-7000-8000-1234567890a2", "alias": "long" } }, "generation": { "id": "019b8c2d-5e6f-7000-8000-123456789abc", "model": { "id": "kling-v3", "name": "Kling 3" }, "origin": "ui" }, "idempotencyKey": null } ], "nextCursor": "eyJ2IjoxLCJjcmVhdGVkQXQiOiIyMDI2LTEwLTAxVDEwOjAwOjAwKzAwOjAwIiwiaWQiOiIwMTliOGMyZC01ZTZmLTcwMDAtODAwMC0wMDAwMDAwMDAwMDEifQ" }
FieldMeaning
transactions[].idStable across reads.
transactions[].createdAtWhen the row was written. Rows are ordered by it, newest first.
transactions[].typegeneration, export, agentWork (chat turns, workflow runs and builds, transcription), refund, topup, adjustment, signupBonus, or transfer.
transactions[].olivesSigned: charges negative, grants and refunds positive.
transactions[].billingModeWhose tab the charge landed on, as recorded at the time: personalTab, teamTab, or generatorPays. Null for a grant.
transactions[].projectThe project the charge landed in; null when it has none.
transactions[].actorWho caused it: { type: "user", user: { id, alias } } or { type: "apiKey", apiKey: { id, label } }. An alias, never an email. Null when unknown.
transactions[].generationFor a generation charge or refund: the generation id, the model the user chose, and the surface it came from (ui, mcp, api). Null for agent work, grants, and adjustments.
transactions[].idempotencyKeyThe Idempotency-Key the generation was submitted with, when there was one.
nextCursorPass back as cursor for the next page; null on the last.
ParameterTypeNotes
organizationIduuidOptional. For an account key: which of the user’s organizations to read.
fromRFC 3339Only rows at or after this instant.
toRFC 3339Only rows before this instant.
typeslistOnly rows of these kinds.
projectIduuidOnly rows in this project.
apiKeyIduuidOnly rows made with this key.
userIduuidOnly rows by this member.
generationIduuidOnly the rows of this generation: its charge and any refund.
limitintegerRows per page. Default 100, at most 1000.
cursorstringThe nextCursor of the previous page, with the same filters.

The ledger is keyset-paginated on createdAt and id, so a row written while you page is never skipped or repeated. The ledger holds no free text: a row's description, its reconciliation notes, and anything about how a charge was computed stay on the server.

Invoices

GET /v1/billing/invoices

The organization's invoices, newest first: id, date, status, and what the organization paid Martini. total is in major units of its currency (ISO 4217, lowercase). With oliveValueUsd it is one of the two money figures in the API, and both are the organization's own. Empty when this deployment has no billing.

curl https://api.martini.film/v1/billing/invoices \ -H "Authorization: Bearer $MARTINI_API_KEY"
{ "organization": { "id": "019b8c2d-5e6f-7000-8000-1234567890a5", "name": "Golden Pictures" }, "invoices": [ { "id": "in_2026_11", "createdAt": "2026-11-01T00:00:00.000Z", "status": "draft", "total": { "amount": 499, "currency": "usd" } }, { "id": "in_2026_10", "createdAt": "2026-10-01T00:00:00.000Z", "status": "paid", "total": { "amount": 499, "currency": "usd" } } ] }
FieldMeaning
invoices[].idThe invoice id; pass it to the download route.
invoices[].createdAtWhen the invoice was issued.
invoices[].statuspaid, open, void, uncollectible, or draft. A draft is the period in progress; it has no PDF yet.
invoices[].total{ amount, currency }: what the organization paid Martini, in major units.

GET /v1/billing/invoices/{invoiceId}/download

A fresh link to one invoice's PDF, good for expiresIn seconds (86400). Ask again rather than storing it, the way run results hand out URLs. An id that is not one of the organization's invoices, or a draft with no PDF yet, answers 404 INVOICE_NOT_FOUND.

curl https://api.martini.film/v1/billing/invoices/in_2026_10/download \ -H "Authorization: Bearer $MARTINI_API_KEY"
{ "invoiceId": "in_2026_10", "url": "https://…/in_2026_10/pdf", "expiresIn": 86400 }

Budgets

A budget is a hard olive limit on one scope: the organization as a whole, one project, one member, or one API key, for all time (lifetime) or per calendar month in UTC (month). Every charge is admitted against every budget its scopes carry before any work starts — a generation submitted with a key is checked against the key's budget, the project's, the member's, and the organization's — and a charge that would cross one is refused. A key budget bounds the generations made with the key, through POST /v1/generations and through the generate tools on POST /v1/tools/{name}; what the other tools charge (a render, agent work) counts against the organization, project, and member budgets. Budgets are the one thing the Billing API writes: the reads need billing:read, the writes billing:write.

A project spend cap or a member monthly cap set in the Martini app is a budget here too (a project budget for all time, a member budget per month), and a budget created here with that shape shows in the app. A budget goes where its scope goes: a project's follows the project to another organization and ends when the project leaves for a personal workspace or is deleted; a member's ends with the membership. The organization-wide budget is also account.budget; a key's own budget is key.budget on GET /v1/me, so a client can see its limit before its first call.

GET /v1/billing/budgets

Every budget of the organization with where it stands, the organization-wide one first. GET /v1/billing/budgets/{budgetId} reads one; an id that is not one of the organization's budgets answers 404 BUDGET_NOT_FOUND.

curl https://api.martini.film/v1/billing/budgets \ -H "Authorization: Bearer $MARTINI_API_KEY"
{ "organization": { "id": "019b8c2d-5e6f-7000-8000-1234567890a5", "name": "Golden Pictures" }, "budgets": [ { "id": "019b8c2d-5e6f-7000-8000-1234567890c1", "scope": { "type": "organization", "id": "019b8c2d-5e6f-7000-8000-1234567890a5", "name": "Golden Pictures" }, "amountOlives": 10000, "period": "month", "alerts": [{ "atOlives": 8000, "channels": ["email"] }], "status": { "spentOlives": 1750, "reservedOlives": 120, "remainingOlives": 8130, "periodStart": "2026-10-01T00:00:00.000Z", "periodEnd": "2026-11-01T00:00:00.000Z", "blocked": false }, "createdAt": "2026-10-01T09:00:00.000Z", "updatedAt": "2026-10-01T09:00:00.000Z" }, { "id": "019b8c2d-5e6f-7000-8000-1234567890c2", "scope": { "type": "apiKey", "id": "019b8c2d-5e6f-7000-8000-1234567890a7", "name": "Finance" }, "amountOlives": 500, "period": "lifetime", "alerts": [], "status": { "spentOlives": 500, "reservedOlives": 0, "remainingOlives": 0, "periodStart": null, "periodEnd": null, "blocked": true }, "createdAt": "2026-10-15T09:00:00.000Z", "updatedAt": "2026-10-20T09:00:00.000Z" } ] }
FieldMeaning
budgets[].idThe budget id; the path parameter of the single-budget routes.
budgets[].scope{ type, id, name }: what the budget bounds. type is organization, project, member, or apiKey; id the project, member (user), or key id — the organization id for an organization budget; name the project name, member alias, or key label.
budgets[].amountOlivesThe hard limit. 0 blocks every charge in the scope.
budgets[].periodlifetime, or month: the calendar month in UTC, after which spent starts again from zero.
budgets[].alerts[]{ atOlives, channels }: soft thresholds. When settled spend in the period reaches atOlives the organization’s admins are emailed, once per period per threshold. channels is ["email"] in this version.
budgets[].status.spentOlivesSettled spend against the scope in the current period.
budgets[].status.reservedOlivesAdmitted but not yet settled: generations in flight.
budgets[].status.remainingOlivesamountOlives minus spent and reserved, floored at zero.
budgets[].status.periodStartStart of the current period; null for a lifetime budget.
budgets[].status.periodEndEnd of the current period, when spent resets; null for a lifetime budget.
budgets[].status.blockedTrue when the next charge against the scope would be refused.
budgets[].createdAtWhen the budget was created.
budgets[].updatedAtWhen its limit or alerts last changed.

status is read from the same figures the admission uses, so the number a charge is refused on is the number reported here. The second budget above is spent: blocked is true, and the Finance key's next generation is refused until the limit is raised.

POST /v1/billing/budgets

Set a budget. scope is { "type": "organization" } or { "type": "project" | "member" | "apiKey", "id": "<uuid>" } — the project id, the member's user id, or the key id. One budget per scope: a second answers 409 BUDGET_EXISTS; change the existing one instead. A scope that is not the organization's (a project of another workspace, a user who is not a member, a revoked key) answers 404 BUDGET_SCOPE_NOT_FOUND. alerts are optional, at most 10, each atOlives distinct and at most amountOlives; a body that does not fit answers 400 BUDGET_INVALID naming the field. The answer is 201 with the budget as it stands now.

curl -X POST https://api.martini.film/v1/billing/budgets \ -H "Authorization: Bearer $MARTINI_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "scope": { "type": "project", "id": "019b8c2d-5e6f-7000-8000-1234567890a1" }, "amountOlives": 5000, "period": "month", "alerts": [{ "atOlives": 4000, "channels": ["email"] }] }'
{ "id": "019b8c2d-5e6f-7000-8000-1234567890c3", "scope": { "type": "project", "id": "019b8c2d-5e6f-7000-8000-1234567890a1", "name": "Moon Base" }, "amountOlives": 5000, "period": "month", "alerts": [{ "atOlives": 4000, "channels": ["email"] }], "status": { "spentOlives": 0, "reservedOlives": 0, "remainingOlives": 5000, "periodStart": "2026-10-01T00:00:00.000Z", "periodEnd": "2026-11-01T00:00:00.000Z", "blocked": false }, "createdAt": "2026-10-21T12:00:00.000Z", "updatedAt": "2026-10-21T12:00:00.000Z" }

PATCH /v1/billing/budgets/{budgetId} raises or lowers amountOlives, replaces alerts, or both; name at least one. The scope and the period are fixed — delete and create again to change them. A new limit re-arms the period's alerts, so a threshold already reached notifies again once spend reaches it under the new limit. DELETE /v1/billing/budgets/{budgetId} removes the budget: the scope is no longer capped, its alerts no longer fire, and spend already recorded against it stays in the ledger. It answers { "id": "…", "deleted": true }.

curl -X PATCH https://api.martini.film/v1/billing/budgets/019b8c2d-5e6f-7000-8000-1234567890c3 \ -H "Authorization: Bearer $MARTINI_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "amountOlives": 6000 }' curl -X DELETE https://api.martini.film/v1/billing/budgets/019b8c2d-5e6f-7000-8000-1234567890c3 \ -H "Authorization: Bearer $MARTINI_API_KEY"

When a budget blocks

A charge a budget refuses is answered 402 before any work starts, and nothing is charged. On POST /v1/generations the code is SPEND_CAP_EXCEEDED — the Generation API's one code for every cap — and the message names the scope and its limit:

{ "error": "This charge would cross the API key's budget of 500 olives.", "code": "SPEND_CAP_EXCEEDED" }

Martini's own surfaces (the app's generate, chat, uploads, and set creation) answer BUDGET_EXCEEDED for an organization or key budget, with budget naming the one that refused; a project or member budget keeps the codes the app has always shown (PROJECT_SPEND_CAP_EXCEEDED, MEMBER_CAP_EXCEEDED) with the same budget detail.

{ "error": "API key budget reached", "code": "BUDGET_EXCEEDED", "message": "This charge would cross the API key's budget of 500 olives.", "budget": { "id": "019b8c2d-5e6f-7000-8000-1234567890c2", "scope": { "type": "apiKey", "id": "019b8c2d-5e6f-7000-8000-1234567890a7" } } }

Either way, GET /v1/billing/budgets shows which budget is blocked. Agent work inside a workflow run is admitted the same way, operation by operation, against the organization, project, and member budgets. Alerts never block: an alerts threshold emails the organization's admins once per period when settled spend reaches it, and the limit itself is what refuses.

Errors

Errors are JSON { "error": "<copy for a human>", "code": "<STABLE_CODE>" }. Branch on code; the copy may change. The balance accepts every key kind, so it never answers RUN_KEY_NOT_ALLOWED or ORGANIZATION_REQUIRED; no billing route answers GENERATION_API_DISABLED: like the tool routes, they do not check the organization's generation-API grant — the reporting routes check the Billing API's own.

HTTP statusCodeWhen
401INVALID_API_KEYMissing, malformed, or unknown key.
401API_KEY_REVOKEDThe key was revoked.
401API_KEY_EXPIREDThe key passed its expiry; CLI keys expire after 90 days — run martini login again.
403API_KEY_DEFAULTS_MISSINGThe key lost its default project. Contact support.
403API_KEY_PERMISSION_MISSINGThe key does not hold the permission the route needs: billing:read for the reads, billing:write for the budget writes. The body names the required permission, the granted set, and the settingsUrl where a key with it is created. Reporting routes only.
403RUN_KEY_NOT_ALLOWEDA workflow run key called a reporting route. A run reads its own budget from the balance and nothing else.
403BILLING_API_NOT_ENABLEDThe organization has no Billing API grant. Reporting routes only; request access as described above.
400UNSUPPORTED_API_VERSIONUnknown X-Martini-API-Version header.
400INVALID_REQUESTBalance only: a query parameter is not accepted, or organizationId is not a UUID.
400INVALID_QUERYReporting routes: a parameter is not accepted or out of range, from is not before to, the range exceeds 366 days, modelId names no model, or a costs page covers too many ledger rows. The message names the parameter.
400INVALID_CURSORA cursor this server did not issue, or one outside the range. Start again without one.
400BUDGET_INVALIDA budget body does not fit: a negative or non-numeric amountOlives, a period other than lifetime or month, more than 10 alerts, an alert above the limit or repeated, a channel other than email, or a PATCH naming no field. The message names the field.
404ORGANIZATION_NOT_FOUNDorganizationId names an organization the key’s user is not a member of, or an organization key named an organization other than its own.
404WORKFLOW_RUN_NOT_FOUNDA run key whose run no longer exists.
404INVOICE_NOT_FOUNDThe id is not one of the organization’s invoices, or a draft has no PDF yet.
404BUDGET_NOT_FOUNDThe id is not one of the organization’s budgets — also the answer to deleting one twice.
404BUDGET_SCOPE_NOT_FOUNDThe scope of a new budget is not the organization’s: a project of another workspace, a user who is not a member, or a revoked key.
409BUDGET_EXISTSThe scope already has a budget. Change it with PATCH; GET /v1/billing/budgets lists it.
429RATE_LIMITEDThe key spent its allowance on /v1/billing. Wait the Retry-After seconds; the X-RateLimit-* headers on every answer say how close you are.
503BALANCE_UNAVAILABLEThe billing system could not be read and no fresh value was cached. Wait the Retry-After seconds (10) and call again; a stale balance is never served.
503BILLING_API_ACCESS_UNAVAILABLEThe organization’s grant could not be checked. Wait the Retry-After seconds (10) and call again.

Using the client

@martini-film/client 0.11.0 and later (JavaScript, TypeScript) and martini-client 0.4.0 and later (Python) expose the balance as martini.billing.balance(). Both return the wire object typed on kind; the Python client adds covers(olives) on the organization form, which is true for an unlimited or billing-disabled organization. The reporting routes are account(), pricing(), costs(), transactions(), invoices(), and invoiceDownload() (invoice_download() in Python) on the same billing namespace, in the client release that ships with them; list parameters take arrays, instants take an ISO string or a Date (an aware datetime in Python). Budgets are budgets(), budget(id), createBudget(), updateBudget(), and deleteBudget() (create_budget(), update_budget(), delete_budget() in Python, which take the scope as scope_type= and scope_id=).

import { createMartiniClient, MartiniApiError } from '@martini-film/client' const martini = createMartiniClient({ apiKey: process.env.MARTINI_API_KEY }) // Before a batch of 20 runs at roughly 165 olives each: const needed = 20 * 165 const balance = await martini.billing.balance() if (balance.kind === 'organization') { const covered = !balance.billingEnabled || balance.olives === null || balance.olives.unlimited || balance.olives.available >= needed if (!covered) throw new Error(`Need ${needed} olives, ${balance.olives?.available} available`) } // Last month's spend by project and model, for a chargeback report (needs billing:read): const costs = await martini.billing.costs({ from: '2026-09-01T00:00:00Z', to: '2026-10-01T00:00:00Z', bucket: 'month', groupBy: ['project', 'model'], }) for (const cell of costs.buckets[0].results) { console.log(cell.group.project?.name ?? '(no project)', cell.group.model?.name ?? 'agent work', cell.netOlives) } // The whole ledger, for a warehouse sync: let cursor: string | undefined do { const page = await martini.billing.transactions({ limit: 1000, cursor }) await load(page.transactions) cursor = page.nextCursor ?? undefined } while (cursor) // Cap a project at 5,000 olives a month, with a warning at 4,000 (needs billing:write): const budget = await martini.billing.createBudget({ scope: { type: 'project', id: projectId }, amountOlives: 5000, period: 'month', alerts: [{ atOlives: 4000, channels: ['email'] }], }) console.log(budget.status.remainingOlives, 'olives left this month') try { await martini.billing.account() } catch (error) { if (error instanceof MartiniApiError && error.code === 'BILLING_API_NOT_ENABLED') { // request access from the billing docs page } }

For any other language, the complete /v1 surface is described by an OpenAPI 3.1 document at api.martini.film/v1/openapi.json (no authentication); the Billing tag carries every route on this page, each stamped with the permission it requires in x-martini-permission.

Not yet available

The rest of the Billing API is designed and lands behind the same enterprise grant. Until a route ships it is absent, not a placeholder: calling it answers 404.

  • Alert channels beyond email (a webhook, Slack); the channels list on an alert is where they will appear.

© 2026 C47 Inc.