# Agents Source: https://docs.marginfront.com/api-reference/agents Manage the AI products that do work for your customers # Agents An **agent** is the thing doing the work. In MarginFront, an agent represents anything that produces billable activity (an AI agent, a microservice, a worker, a team of humans, a Lambda function, you name it). Whatever produces the usage you want to track, that's an agent. > **You don't have to call this endpoint to create an agent.** When you fire a usage event with an `agentCode` MarginFront has not seen before, the agent record is created automatically. Use this endpoint when you want to set the display `name`, description, or other fields up front, or to update an existing agent. You attach metrics (signals) to an agent, attach pricing plans to it, and log usage events against it. Everything in MarginFront (pricing, usage tracking, invoicing) ultimately traces back to an agent. *** ## Why agents exist A common question: "Why do I need agents at all? Can't I just track customer usage directly?" Short answer: because a single customer might use multiple different products/services you offer, and each one might bill differently. Agents let you split that up. Example: you run an AI company with three products — a chatbot, a document analyzer, and an image generator. Each one has different costs, different pricing, and different metrics. If you just tracked "usage per customer," you'd smoosh all three together and lose the ability to bill them differently. Instead you create three agents (`chatbot`, `doc-analyzer`, `image-gen`), and every usage event says "customer X used agent Y." Now you can set different prices per agent, see per-agent profitability, and give each customer an itemized bill. If you only have one product, you only need one agent. Still, create it — it's the anchor everything else attaches to. *** ## Create an agent **In plain English:** Register a new agent (service, product, worker) with MarginFront so you can track and bill usage against it. **Method & URL:** ``` POST /v1/agents ``` **Authentication:** API key in the `Authorization` header. See [Authentication](./authentication). **Required fields:** * `name` *(string, max 255 chars)* — Human-readable name for the agent. Shows up in the dashboard, on reports, and on invoices. * `agentCode` *(string, max 255 chars)* — A short, unique code for this agent that YOU pick. Think of it like `externalId` for customers — it's the handle you'll use to reference this agent in other API calls (especially usage events via the SDK). Pick something short and clear, like `chatbot`, `doc-analyzer`, `cs-bot-v2`. **Optional fields:** * `description` *(string)* — A longer explanation of what this agent does. Useful for your team's reference. * `isActive` *(boolean, default `true`)* — Whether the agent is currently in use. Set to `false` to retire an agent without deleting it. * `context` *(object)* — Any custom key/value pairs you want to attach. MarginFront stores them but doesn't interpret them. Good place to stash things like `{ "department": "support", "tier": 1 }`. **Example curl call:** ```bash theme={null} curl -X POST https://api.marginfront.com/v1/agents \ -H "x-api-key: mf_sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "name": "Customer Support Agent", "agentCode": "cs-bot-v2", "description": "Our main GPT-4 powered support bot" }' ``` **What you get back (`201 Created`):** ```json theme={null} { "id": "b47e12fa-abcd-4567-8901-234567890abc", "name": "Customer Support Agent", "agentCode": "cs-bot-v2", "description": "Our main GPT-4 powered support bot", "isActive": true, "createdAt": "2026-04-10T14:30:00.000Z" } ``` **Common errors:** * **`400 Bad Request`** — You're missing `name` or `agentCode`, or one of them is too long (255 char max). The response body tells you which. * **`409 Conflict`** — An agent with that `agentCode` already exists in this org. Pick a different code, or update the existing agent. * **`401 Unauthorized`** — Your API key is missing or wrong. **When to call this:** Before you create any signals (metrics), pricing plans, or usage events. Agents are the foundation everything else builds on. *** ## Read an agent **Method & URL:** ``` GET /v1/agents/{agentId} ``` **What it returns:** The full agent object, same shape as the create response. **Common errors:** * **`404 Not Found`** — Agent doesn't exist in this org. *** ## List agents **Method & URL:** ``` GET /v1/agents ``` **What it returns:** Array of agents. **When to use it:** Syncing a local cache, populating dropdowns, checking what exists before creating. *** ## Update an agent **Method & URL:** ``` PATCH /v1/agents/{agentId} ``` **Fields:** Any field from create. All optional on update — only send what you want to change. **Example — retire an agent without deleting it:** ```bash theme={null} curl -X PATCH https://api.marginfront.com/v1/agents/b47e12fa-... \ -H "x-api-key: mf_sk_test_..." \ -H "Content-Type: application/json" \ -d '{"isActive": false}' ``` **Common errors:** * **`404 Not Found`** — Agent doesn't exist. *** ## Delete an agent **Method & URL:** ``` DELETE /v1/agents/{agentId} ``` **What it does:** Permanently removes the agent. You have to delete or reassign any signals and pricing plans attached to this agent first — if you don't, the delete fails with a foreign-key error. > **Usually you want `isActive: false` instead of delete.** Setting `isActive: false` (via PATCH) keeps all the historical usage data intact and just marks the agent as "no longer in use." Deleting an agent is rare and mostly useful for cleaning up mistakes. **Common errors:** * **`409 Conflict`** — Agent still has signals or pricing plans attached. Clean those up first. *** ## Using the Node SDK The SDK doesn't have an `mf.agents` resource yet, but it references agents by `agentCode` when you log usage events: ```js theme={null} await mf.usage.record({ customerExternalId: "acme-001", agentCode: "cs-bot-v2", // ← this is your agent's agentCode signalName: "messages", model: "gpt-4o", inputTokens: 523, outputTokens: 117, }); ``` The `agentCode` has to match an agent you've already created via the admin API. If it doesn't match anything, MarginFront will auto-create a minimal agent for you using that code — convenient for prototyping but you'll want to go back and flesh out the agent's name and description afterwards. # Analytics Source: https://docs.marginfront.com/api-reference/analytics Query rolled-up usage data # Usage Analytics The usage analytics endpoint is how you read raw usage data back out of MarginFront. It returns rolled-up event counts, quantity totals, and cost for any customer, agent, or time window you specify. Use it to: * Power "current period usage" widgets in your own product * Build cost dashboards that show your customers what they've used this month * Project a customer's end-of-month bill before the invoice is generated * Feed data into your own BI tools This is a read-only endpoint. It doesn't create or modify anything. > **Looking for revenue, margin, or MRR?** This page covers the raw usage roll-up. For revenue and margin use [`/v1/analytics/revenue`](./analytics-revenue). For cost with breakdowns by agent, customer, signal, day, plan, or model use [`/v1/analytics/cost`](./analytics-cost). For monthly recurring revenue use [`/v1/analytics/mrr`](./analytics-mrr). *** ## The endpoint **Method & URL:** ``` GET /v1/analytics/usage ``` Same auth as everything else: API key in the `x-api-key` header. *** ## Query parameters All optional. If you don't specify any, you get a roll-up for the whole org over the default time window. | Param | Type | Description | | ------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | `startDate` | ISO date | Start of the time window (inclusive). | | `endDate` | ISO date | End of the time window (inclusive). | | `customerId` | UUID | Filter to a single customer. Use MarginFront's internal customer UUID (not your `externalId`). | | `agentId` | UUID | Filter to a single agent. | | `signalId` | UUID | Filter to a single signal. | | `groupBy` | string | Time-bucket size for `timeSeriesData`. One of `"daily"`, `"weekly"`, `"monthly"`. Default: `"daily"`. | | `breakdownBy` | string | Add a `dimensionBreakdown` section broken out by one of `"customer"`, `"signal"`, `"agent"`. When omitted, the response has no `dimensionBreakdown` field. | If you pass an invalid `groupBy` or `breakdownBy` value, the API returns `400 Bad Request` with a message listing the accepted values. *** ## Example curl calls **Get everything for April 2026:** ```bash theme={null} curl "https://api.marginfront.com/v1/analytics/usage?startDate=2026-04-01&endDate=2026-04-30" \ -H "x-api-key: mf_sk_test_..." ``` **Get usage for one customer:** ```bash theme={null} curl "https://api.marginfront.com/v1/analytics/usage?customerId=bc8eceda-50e4-4138-b2a2-47e92d344540&startDate=2026-04-01&endDate=2026-04-30" \ -H "x-api-key: mf_sk_test_..." ``` **Get weekly totals for one agent:** ```bash theme={null} curl "https://api.marginfront.com/v1/analytics/usage?agentId=3a1948ea-a701-4752-8c3d-df6c2f5833cf&startDate=2026-04-01&endDate=2026-04-30&groupBy=weekly" \ -H "x-api-key: mf_sk_test_..." ``` **Break down totals by customer:** ```bash theme={null} curl "https://api.marginfront.com/v1/analytics/usage?startDate=2026-04-01&endDate=2026-04-30&breakdownBy=customer" \ -H "x-api-key: mf_sk_test_..." ``` *** ## What you get back (`200 OK`) ```json theme={null} { "dateRange": { "start": "2026-04-01", "end": "2026-04-30", "groupBy": "daily" }, "filters": {}, "summary": { "totalEvents": 1247, "totalQuantity": 1247, "totalCost": 42.18, "avgCostPerEvent": 0.0338 }, "timeSeriesData": [ { "date": "2026-04-01", "eventCount": 87, "totalQuantity": 87, "totalCost": 2.94 }, { "date": "2026-04-02", "eventCount": 93, "totalQuantity": 93, "totalCost": 3.12 } ], "metadata": { "signals": { "69145379-8b26-4050-b54d-e08d6059ca18": "Messages Processed" }, "customers": { "19a53f22-3cba-4224-bf1b-7b541ed7d12f": "Acme Inc" }, "agents": { "84165d91-1136-4960-97d7-e24136476ce7": "Customer Support Bot" } } } ``` When you pass `breakdownBy`, an additional `dimensionBreakdown` field appears at the top level: ```json theme={null} { "dimensionBreakdown": { "dimensionType": "customer", "summary": [ { "dimensionId": "19a53f22-3cba-4224-bf1b-7b541ed7d12f", "dimensionLabel": "Acme Inc", "totalEvents": 412, "totalQuantity": 412, "totalCost": 18.23 } ], "timeline": [ { "date": "2026-04-01", "dimensionId": "19a53f22-3cba-4224-bf1b-7b541ed7d12f", "dimensionLabel": "Acme Inc", "eventCount": 14, "totalQuantity": 14, "totalCost": 0.62 } ] } } ``` ### Understanding the response * **`summary`**: totals across the entire date range. This is what most dashboards want. Fields: `totalEvents`, `totalQuantity`, `totalCost`, `avgCostPerEvent`. * **`timeSeriesData`**: one row per time bucket (`daily`, `weekly`, or `monthly` depending on `groupBy`). Good for drawing charts. Note the row field is `eventCount` (not `totalEvents`). * **`metadata`**: lookup dictionaries mapping UUIDs to human-readable names for signals, customers, and agents referenced in the results. Use it to render labels without a second round-trip. * **`dimensionBreakdown`**: present only when you pass `breakdownBy=customer|signal|agent`. An object with `dimensionType`, a `summary` array (one row per dimension with totals across the window), and a `timeline` array (per-bucket rows tagged with the dimension). * **`dateRange`** and **`filters`**: echo back what you queried so the response is self-describing. `filters` contains only the keys you actually passed (it's `{}` when you passed no filters). **Values in this endpoint are returned as numbers.** That includes `totalCost`, `totalQuantity`, `totalEvents`, and `avgCostPerEvent` on `summary`, every field on `timeSeriesData` rows, and every field on `dimensionBreakdown` rows. (Contrast with `GET /v1/events`, where per-event `usageCost` and `quantity` are strings.) Analytics values are already rolled up, so sub-cent precision only shows up in averages. *** ## Common errors * **`400 Bad Request`**: a query parameter is in the wrong format (e.g., `startDate` isn't a valid ISO date). * **`401 Unauthorized`**: API key missing or wrong. *** ## Using the Node SDK ```js theme={null} // All usage this month for a specific customer const stats = await mf.analytics.usage({ customerId: "bc8eceda-50e4-4138-b2a2-47e92d344540", startDate: "2026-04-01", endDate: "2026-04-30", }); console.log(`Customer used $${stats.summary.totalCost} worth of usage`); ``` *** ## What this endpoint is for (and what it isn't) **Use this for:** * Live dashboards, projections, "usage so far this month" displays * Triggering alerts when a customer hits a usage threshold * Feeding simple BI reports **Don't use this for:** * **Revenue, margin, or MRR.** Use [`/v1/analytics/revenue`](./analytics-revenue) or [`/v1/analytics/mrr`](./analytics-mrr) instead. * **Cost broken down by agent / customer / signal / day / plan / model.** Use [`/v1/analytics/cost`](./analytics-cost) instead. * **Actual invoice data.** Use [invoices](./invoices). Usage analytics gives you "here's the raw usage roll-up," but invoices are the finalized billing documents that include discounts, taxes, prorations, etc. * **High-resolution per-event drill-down.** This endpoint returns aggregates. For event-level detail, use the signal-events listing endpoint at `GET /v1/events`. # Analytics · Cost Source: https://docs.marginfront.com/api-reference/analytics-cost Cost sliced by agent, customer, signal, day, plan, and model, with optional period-over-period trend # Cost analytics `GET /v1/analytics/cost` is the single place to read cost out of MarginFront with a full breakdown attached. Use it when you need to answer: * Which agents cost the most to run? * Which customers are driving the cost? * What is our cost trend day over day? * How did this window compare to the window before it? Cost is tracked independently of pricing and subscriptions. An event contributes cost as long as it has a recorded `usageCost`, whether or not the customer has a paid plan. Useful for Cost-Tracking-mode organizations that are not billing yet. This is a read-only endpoint. *** ## The endpoint **Method and URL:** ``` GET /v1/analytics/cost ``` Auth: API key in the `x-api-key` header. *** ## Query parameters | Param | Type | Required | Description | | -------------------- | -------- | -------- | ----------------------------------------------------------------------------------------------- | | `startDate` | ISO date | yes | Start of the window (inclusive). Example: `2026-04-01`. | | `endDate` | ISO date | yes | End of the window (inclusive). | | `customerId` | UUID | no | Narrow to one customer. | | `subscriptionId` | UUID | no | Narrow to one subscription. | | `agentId` | UUID | no | Narrow to one agent. | | `signalId` | UUID | no | Narrow to one signal. | | `includePriorWindow` | boolean | no | When `true`, the response also includes a `prior` field with the same shape for the prior span. | ### How the prior window works If you pass `includePriorWindow=true`, the API computes a second window of the same length immediately before your primary window, and runs the same aggregation on it. You get both shapes back in one call. That is how you build period-over-period trend widgets without a second round-trip. One extra database query is used when this flag is set. Omit it when you do not need the trend. *** ## Example curl calls **Org-wide cost for April 2026:** ```bash theme={null} curl "https://api.marginfront.com/v1/analytics/cost?startDate=2026-04-01&endDate=2026-04-30" \ -H "x-api-key: mf_sk_test_..." ``` **One customer's cost with trend:** ```bash theme={null} curl "https://api.marginfront.com/v1/analytics/cost?customerId=bc8eceda-50e4-4138-b2a2-47e92d344540&startDate=2026-04-01&endDate=2026-04-30&includePriorWindow=true" \ -H "x-api-key: mf_sk_test_..." ``` **One agent's cost broken down by day (the response always includes `byDay`):** ```bash theme={null} curl "https://api.marginfront.com/v1/analytics/cost?agentId=3a1948ea-a701-4752-8c3d-df6c2f5833cf&startDate=2026-04-01&endDate=2026-04-30" \ -H "x-api-key: mf_sk_test_..." ``` *** ## What you get back (`200 OK`) ```json theme={null} { "cost": 3122.18, "eventCount": 41233, "eventCountWithNullCost": 12, "byAgent": [ { "agentId": "b47e12fa-...", "cost": 1982.4, "eventCount": 24102 } ], "byCustomer": [ { "customerId": "5e7f8a3d-...", "cost": 742.8, "eventCount": 9801 } ], "bySignal": [ { "signalId": "69145379-...", "cost": 2104.5, "eventCount": 28940 } ], "byDay": [ { "date": "2026-04-01T00:00:00.000Z", "cost": 94.82, "eventCount": 1243 }, { "date": "2026-04-02T00:00:00.000Z", "cost": 102.15, "eventCount": 1378 } ], "byPlan": [ { "planId": "a1b2c3d4-...", "cost": 2843.1, "eventCount": 37120 }, { "planId": null, "cost": 279.08, "eventCount": 4113 } ], "byModel": [ { "model": "gpt-4o-mini", "cost": 1820.4, "eventCount": 22014 }, { "model": "claude-3-5-sonnet", "cost": 1301.78, "eventCount": 19219 }, { "model": null, "cost": 0.0, "eventCount": 0 } ] } ``` When `includePriorWindow=true` is set, a `prior` field appears at the top level with the same shape (minus its own `prior`): ```json theme={null} { "cost": 3122.18, "eventCount": 41233, "eventCountWithNullCost": 12, "byAgent": [], "byCustomer": [], "bySignal": [], "byDay": [], "byPlan": [], "byModel": [], "prior": { "cost": 2847.62, "eventCount": 38015, "eventCountWithNullCost": 4, "byAgent": [], "byCustomer": [], "bySignal": [], "byDay": [], "byPlan": [], "byModel": [] } } ``` ### Understanding the response * **`cost`** is the total cost for the window: the sum of `usageCost` across every event that fell inside. * **`eventCount`** counts every event in the window, including events whose `usageCost` is blank. * **`eventCountWithNullCost`** is the subset whose cost could not be computed. They are in `eventCount` but contribute `0` to `cost`. Treat this as a "needs attention" indicator. * **`byAgent` / `byCustomer` / `bySignal` / `byPlan`** are one row per entity, with cost and event count for that entity in the window. * **`bySignal`** can include a row where `signalId` is `null`. Those are events whose signal could not be attributed. * **`byPlan`** can include a row where `planId` is `null`. Those are orphan events: events with no subscription, or whose subscription was deleted. They still contribute to cost. * **`byDay`** is one row per UTC day that had activity. `date` is UTC midnight. Use this to chart daily trends. * **`byModel`** is one row per LLM model name, pulled from the event's payload. `model: null` covers non-LLM events and LLM events where the model field was missing. * **`prior`** appears only when you pass `includePriorWindow=true`. Same shape as the primary window, same length of time, immediately before the primary window. All money fields are `number`, not strings. *** ## When to use this vs. other endpoints Use **this endpoint** when you want cost broken down by something: which agent, which customer, which signal, which day, which plan, which model. Use [`/v1/analytics/revenue`](./analytics-revenue) when you also need revenue, margin, and pricing-strategy attribution. Use [`/v1/analytics/usage`](./analytics) when you want event counts and quantity totals without pricing math. *** ## Using the Node SDK ```typescript theme={null} import { MarginFrontClient } from "@marginfront/sdk"; const mf = new MarginFrontClient(process.env.MF_API_SECRET_KEY); const cost = await mf.analytics.costBreakdown({ startDate: "2026-04-01", endDate: "2026-04-30", includePriorWindow: true, }); console.log(`Cost this month: $${cost.cost.toFixed(2)}`); if (cost.prior) { const delta = cost.cost - cost.prior.cost; console.log(`Change vs prior window: $${delta.toFixed(2)}`); } ``` See the [SDK analytics page](/sdk/analytics) for all seven analytics methods. *** ## Common errors * **`400 Bad Request`**: `startDate` or `endDate` is missing, in the wrong format, or a UUID filter is invalid. * **`401 Unauthorized`**: API key missing or wrong. # Analytics · MRR Source: https://docs.marginfront.com/api-reference/analytics-mrr Monthly recurring revenue. Three variants: last calendar month billed, run-rate, and contractual floor # MRR analytics `GET /v1/analytics/mrr` is the single place to read monthly recurring revenue (MRR) out of MarginFront. It returns one of three MRR variants, or all three at once. Use it when you need to answer: * "What did we actually bill for last month?" * "If the last 30 days kept going, what would we bill per month?" * "What is the contractual floor we will bill regardless of usage?" * "How does a single customer (or subscription) contribute to each of those?" Each variant answers a different question. They can and do produce different numbers. Pick the one that matches what you are trying to show. This is a read-only endpoint. *** ## The endpoint **Method and URL:** ``` GET /v1/analytics/mrr ``` Auth: API key in the `x-api-key` header. *** ## Query parameters | Param | Type | Required | Description | | ---------------- | ------ | -------- | ----------------------------------------------------------------------------------------------------- | | `variant` | string | no | Which MRR variant to return. One of `canonical`, `runRate`, `committed`, `all`. Default: `canonical`. | | `customerId` | UUID | no | Narrow to one customer. Use MarginFront's internal customer UUID, not your `externalId`. | | `subscriptionId` | UUID | no | Narrow to one subscription. | *** ## The three MRR variants ### `canonical` (last-calendar-month billed) **Plain English:** "What did we actually bill last calendar month?" Looks at non-draft invoices whose invoice date lands in the most recent complete calendar month. Sums the totals. That is your MRR. This is a backward-looking number based on what finance actually sent out. Good for: finance reporting, board decks, anything where "actual billed" is what you want. ### `runRate` (last 30 days, monthly-normalized) **Plain English:** "If the current pace keeps going, what would we bill per month?" For each active subscription: take recurring fees plus seat fees plus actual usage from the last 30 days, and normalize everything to a monthly number (yearly subs get divided by 12). Add it up across every active subscription. That is run-rate MRR. Good for: forecasting, growth dashboards, anything where you want the forward-looking trajectory. ### `committed` (contractual floor) **Plain English:** "What will we bill per month even if usage goes to zero?" Same shape as run-rate, but actual usage is replaced with the subscription's `minimumCommitment` times its rate (zero if no minimum is set). So it strips out variability and shows only what is guaranteed by contract. Good for: revenue predictability, churn-risk analysis, anything where you want to see the floor. ### `all` Returns all three variants in one response. Use this when you need to render a comparison (e.g., a dashboard tile showing all three side by side). *** ## Example curl calls **Last calendar month's billed MRR (default):** ```bash theme={null} curl "https://api.marginfront.com/v1/analytics/mrr" \ -H "x-api-key: mf_sk_test_..." ``` **Run-rate MRR for one customer:** ```bash theme={null} curl "https://api.marginfront.com/v1/analytics/mrr?variant=runRate&customerId=bc8eceda-50e4-4138-b2a2-47e92d344540" \ -H "x-api-key: mf_sk_test_..." ``` **All three variants at once:** ```bash theme={null} curl "https://api.marginfront.com/v1/analytics/mrr?variant=all" \ -H "x-api-key: mf_sk_test_..." ``` *** ## What you get back (`200 OK`) ### `variant=canonical` ```json theme={null} { "variant": "canonical", "mrr": 12480.75, "arr": 149769.0 } ``` * **`mrr`** is last calendar month's non-draft invoice total. * **`arr`** is `mrr × 12`. ### `variant=runRate` ```json theme={null} { "variant": "runRate", "mrr": 13842.5, "breakdown": [ { "subscriptionId": "9a8b7c6d-...", "customerId": "5e7f8a3d-...", "agentId": "b47e12fa-...", "planId": "a1b2c3d4-...", "recurring": 299.0, "seatBased": 50.0, "usage": 493.5, "total": 842.5 } ] } ``` * **`mrr`** is the sum of `total` across every active subscription. * **`breakdown`** is one row per active subscription. * **`recurring`** is the recurring-fee contribution, monthly-normalized (yearly subs divided by 12). * **`seatBased`** is the seat-fee contribution: `max(seatsCount, minimumCommitment) × rate`, monthly-normalized. * **`usage`** is actual last-30-days usage revenue for the subscription. * **`total`** is `recurring + seatBased + usage` (the sub's run-rate MRR contribution). ### `variant=committed` ```json theme={null} { "variant": "committed", "mrr": 10948.0, "breakdown": [ { "subscriptionId": "9a8b7c6d-...", "customerId": "5e7f8a3d-...", "agentId": "b47e12fa-...", "planId": "a1b2c3d4-...", "recurring": 299.0, "seatBased": 50.0, "usage": 200.0, "total": 549.0 } ] } ``` Same fields as `runRate`, but `usage` is replaced with the strategy's contractual floor (`minimumCommitment × rate`, or `0` if no minimum is set). ### `variant=all` ```json theme={null} { "variant": "all", "canonical": { "mrr": 12480.75, "arr": 149769.0 }, "runRate": { "mrr": 13842.5, "breakdown": [] }, "committed": { "mrr": 10948.0, "breakdown": [] } } ``` All three variants nested under one response. All money fields are `number`, not strings. *** ## When to use this vs. other endpoints Use **this endpoint** when you specifically want MRR or ARR. Use [`/v1/analytics/revenue`](./analytics-revenue) when you want revenue for an arbitrary window (not a monthly roll-up), with cost and margin attached. Use [`/v1/invoices`](./invoices) when you want the individual invoice records that make up the `canonical` MRR total. *** ## Using the Node SDK ```typescript theme={null} import { MarginFrontClient } from "@marginfront/sdk"; const mf = new MarginFrontClient(process.env.MF_API_SECRET_KEY); // Last calendar month's billed MRR const { mrr, arr } = await mf.analytics.mrr(); console.log(`MRR: $${mrr}, ARR: $${arr}`); // Run-rate MRR with per-subscription breakdown const runRate = await mf.analytics.runRateMrr(); console.log(`Run-rate MRR: $${runRate.mrr}`); for (const row of runRate.breakdown) { console.log(` sub ${row.subscriptionId}: $${row.total}/mo`); } // Committed MRR (contractual floor) const committed = await mf.analytics.committedMrr(); console.log(`Committed floor: $${committed.mrr}`); ``` See the [SDK analytics page](/sdk/analytics) for all seven analytics methods. *** ## Common errors * **`400 Bad Request`**: `variant` is not one of `canonical`, `runRate`, `committed`, `all`, or a UUID filter is invalid. * **`401 Unauthorized`**: API key missing or wrong. # Analytics · Revenue Source: https://docs.marginfront.com/api-reference/analytics-revenue Canonical revenue, cost, and margin for any customer, agent, signal, or subscription # Revenue analytics `GET /v1/analytics/revenue` is the single place to read revenue, cost, and margin out of MarginFront. Same math the dashboard runs, same shape. Use it when you need to know: * How much you earned in a time window * What it cost you to earn that * Your margin (dollars and percent) * Where the revenue came from (per subscription, per pricing strategy) This is a read-only endpoint. It never creates or modifies anything. *** ## The endpoint **Method and URL:** ``` GET /v1/analytics/revenue ``` Auth is the same as every other endpoint: API key in the `x-api-key` header. See [Authentication](./authentication). *** ## Query parameters | Param | Type | Required | Description | | ---------------- | -------- | -------- | ----------------------------------------------------------------------------------------------------------------------- | | `startDate` | ISO date | yes | Start of the window (inclusive). Example: `2026-04-01`. | | `endDate` | ISO date | yes | End of the window (inclusive). | | `variant` | string | no | Response shape. `canonical` for the shape documented below. Omit or pass `legacy` to get the older dashboard breakdown. | | `customerId` | UUID | no | Narrow to one customer. Use MarginFront's internal customer UUID, not your `externalId`. | | `subscriptionId` | UUID | no | Narrow to one subscription. | | `agentId` | UUID | no | Narrow to one agent. | | `signalId` | UUID | no | Narrow to one signal. | **About `variant`:** if you pass any of `customerId`, `subscriptionId`, or `signalId`, the response is always the canonical shape shown below. The `variant` flag only matters when you call the endpoint org-wide with no entity filter. The SDK method `client.analytics.revenue()` always passes `variant=canonical`, so SDK users never have to think about this. *** ## Example curl calls **Org-wide revenue for April 2026:** ```bash theme={null} curl "https://api.marginfront.com/v1/analytics/revenue?startDate=2026-04-01&endDate=2026-04-30&variant=canonical" \ -H "x-api-key: mf_sk_test_..." ``` **One customer's revenue for the month:** ```bash theme={null} curl "https://api.marginfront.com/v1/analytics/revenue?customerId=bc8eceda-50e4-4138-b2a2-47e92d344540&startDate=2026-04-01&endDate=2026-04-30" \ -H "x-api-key: mf_sk_test_..." ``` **One subscription's revenue:** ```bash theme={null} curl "https://api.marginfront.com/v1/analytics/revenue?subscriptionId=9a8b7c6d-...&startDate=2026-04-01&endDate=2026-04-30" \ -H "x-api-key: mf_sk_test_..." ``` *** ## What you get back (`200 OK`) ```json theme={null} { "revenue": 12480.75, "cost": 3122.18, "margin": 9358.57, "marginPercent": 74.98, "usageRevenue": 8231.25, "recurringRevenue": 3999.0, "seatRevenue": 250.5, "onetimeRevenue": 0.0, "eventCount": 41233, "eventCountWithNullCost": 12, "bySubscription": [ { "subscriptionId": "9a8b7c6d-...", "customerId": "5e7f8a3d-...", "agentId": "b47e12fa-...", "planId": "a1b2c3d4-...", "revenue": 842.0, "cost": 184.2, "margin": 657.8, "usageRevenue": 543.0, "recurringRevenue": 299.0, "seatRevenue": 0.0, "onetimeRevenue": 0.0, "eventCount": 2710 } ], "byStrategy": [ { "strategyId": "c3d4e5f6-...", "chargeType": "usage", "pricingModel": "flat", "signalId": "69145379-...", "revenue": 8231.25, "quantity": 823125 } ] } ``` ### Understanding the response * **`revenue`** is the full revenue total for the window: usage-based charges plus recurring fees plus seat fees plus one-time fees, all prorated if the subscription only partly overlapped the window. * **`cost`** is what it cost you to deliver the underlying usage: the sum of `usageCost` across every usage event in the window. * **`margin`** is `revenue` minus `cost`. * **`marginPercent`** is margin divided by revenue, expressed as a percent (so `74.98` means 74.98%). If revenue is `0` the field is `null`, never `0` and never `NaN`. That way "no revenue yet" shows up differently from "zero margin." * **`usageRevenue` / `recurringRevenue` / `seatRevenue` / `onetimeRevenue`** split the revenue total by charge type. They always add up to `revenue`. * **`eventCount`** counts every usage event that fell inside the window. * **`eventCountWithNullCost`** is the subset of those events whose cost could not be computed (no matching price in the catalog). They are counted in `eventCount` but contribute `0` to `cost`. Treat this as a "needs attention" indicator. * **`bySubscription`** is one row per subscription that produced revenue in the window. Useful for customer detail pages and per-subscription drill-down. * **`byStrategy`** is one row per pricing strategy that contributed revenue. Useful for the "revenue by type" donut chart. All money fields are `number`, not strings. *** ## When to use this vs. other endpoints Use **this endpoint** when you want the revenue answer: Dollars in, cost out, margin. Use [`/v1/analytics/usage`](./analytics) when you want raw usage roll-ups (event counts, quantity totals) without pricing math. Use [`/v1/analytics/cost`](./analytics-cost) when you want cost broken down by agent, customer, signal, day, plan, or model, and you do not need revenue. Use [`/v1/analytics/mrr`](./analytics-mrr) when you want monthly recurring revenue specifically (last-calendar-month billed, run-rate, or contractual floor). Use [`/v1/invoices`](./invoices) when you want what was actually billed. Analytics tells you the revenue math; invoices are the documents you sent. *** ## Using the Node SDK ```typescript theme={null} import { MarginFrontClient } from "@marginfront/sdk"; const mf = new MarginFrontClient(process.env.MF_API_SECRET_KEY); const metrics = await mf.analytics.revenue({ startDate: "2026-04-01", endDate: "2026-04-30", }); console.log(`Revenue: $${metrics.revenue.toFixed(2)}`); console.log(`Margin: ${metrics.marginPercent ?? "n/a"}%`); ``` See the [SDK analytics page](/sdk/analytics) for all seven analytics methods and the [SDK types reference](/sdk/types-reference) for every response shape. *** ## Common errors * **`400 Bad Request`**: `startDate` or `endDate` is missing, in the wrong format, or one of the UUID filters is not a valid UUID. * **`401 Unauthorized`**: API key missing or wrong. # Authentication Source: https://docs.marginfront.com/api-reference/authentication API key types, where to get them, and how to keep them safe # Authentication Every MarginFront API call needs an API key. This doc tells you where to get one, how to use it, which type to use when, and how to keep it safe. *** ## How to use an API key Every request to the MarginFront API includes an `x-api-key` header with your key: ``` x-api-key: mf_sk_test_... ``` That's it. No OAuth dance, no token refresh, no session cookies. One header, one key, every request. **With curl:** ```bash Bash theme={null} curl https://api.marginfront.com/v1/verify \ -H "x-api-key: $MF_API_SECRET_KEY" ``` ```powershell PowerShell theme={null} curl.exe https://api.marginfront.com/v1/verify ` -H "x-api-key: $env:MF_API_SECRET_KEY" ``` On Windows the two are not interchangeable. See [Windows and PowerShell](#windows-and-powershell) below for why. **With the Node SDK:** ```typescript theme={null} import { MarginFrontClient } from "@marginfront/sdk"; const mf = new MarginFrontClient(process.env.MF_API_SECRET_KEY); ``` The SDK handles the header for you — you just pass the key to the constructor and it takes care of the rest. *** ## Two types of API key MarginFront issues **two kinds of keys**, and they have different permissions. This is the same pattern Stripe uses — if you've used Stripe's API before, this will feel familiar. | Key type | Prefix | Where it's safe to put | What it can do | | --------------- | ----------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | **Secret** | `mf_sk_...` | **Backend only.** Never ship to the browser. | Everything: read, create, update, delete. | | **Publishable** | `mf_pk_...` | **Safe in browser code.** Public-facing. | Read-only on public endpoints (`/v1/verify`, public pricing info). **All writes (POST/PUT/PATCH/DELETE) are rejected with 403.** | ### When to use which * **Server-side backend code** (Node, Python, Go, Ruby, whatever) — use a **secret key**. Your backend is trusted, so it can do anything. * **Browser code / mobile app / anywhere the key could leak** — use a **publishable key**. If a visitor reads the key out of your page source, the worst they can do is read public info. They cannot create, update, or delete anything. * **A hosted billing portal for your customers** — neither. Use [portal sessions](./portal-sessions) instead, which issue short-lived, customer-scoped tokens. ### What happens if you use the wrong one * Publishable key trying to POST/PATCH/DELETE → `403 Forbidden` with a message telling you to use a secret key * Publishable key trying to read any of your organization's own data (customers, invoices, analytics, credit balances, or your AI-spend / [Spend Controls](./spend-controls) reads) → `403 Forbidden`. A publishable key can reach only `/v1/verify` and its own `/v1/me/key`. Everything else is your org's books, not public info, so it needs a secret key. * Secret key used in browser code → technically works, but you've leaked your main credential to anyone viewing the page. Don't do this. *** ## Where to get an API key 1. Log into the MarginFront dashboard 2. Go to **Build → API keys** 3. Either use an existing key or click "Create new key" 4. Pick the type: secret or publishable 5. Copy it immediately — you can't see the full value again after the first time. If you lose it, you'll have to create a new one. Keys look like `mf_sk_test_...` or `mf_sk_live_...` (secret) and `mf_pk_test_...` or `mf_pk_live_...` (publishable), depending on environment. *** ## Checking that your key works The simplest test is the `/v1/verify` endpoint. It takes no parameters, just your key. If everything's set up right, you'll get a 200 response with your org info. ```bash Bash theme={null} curl https://api.marginfront.com/v1/verify \ -H "x-api-key: $MF_API_SECRET_KEY" ``` ```powershell PowerShell theme={null} curl.exe https://api.marginfront.com/v1/verify ` -H "x-api-key: $env:MF_API_SECRET_KEY" ``` **Success response:** ```json theme={null} { "organization": { "id": "41a1bb3d-557a-42ce-acfe-ce79bbd47cea", "name": "Your Org Name" }, "verified": true, "createdAt": "2026-04-11T16:45:27.012Z", "updatedAt": "2026-04-11T16:47:29.475Z" } ``` **If you get 401:** Your key is either missing, malformed, or wrong. Double-check the header name (`x-api-key`, not `Authorization: Bearer`). If it's definitely formatted right, the key itself is probably wrong — go back to the dashboard and copy a fresh one. **If you get 401 on Windows and the key is definitely good:** you almost certainly sent an empty key. Read [Windows and PowerShell](#windows-and-powershell) below. This accounts for most 401 reports from Windows users. **If you get 403:** You're probably using a publishable key where a secret key is required. That's either a write (POST/PATCH/DELETE), or a read of any of your organization's own data (customers, invoices, analytics, credit balances, or your AI-spend reads). A publishable key can reach only `/v1/verify` and its own `/v1/me/key`; every other authenticated read needs a secret key. See the table above. **If you get nothing (connection refused / timeout):** The API server isn't running. If you're hitting localhost, start it with `cd apps/api-nest && npm run dev`. *** ## Windows and PowerShell Examples throughout these docs are written for Bash. If you are on Windows PowerShell, three things change. Miss any of them and you get a `401` that looks like a bad key but is actually an empty one. | What you want | Bash (macOS, Linux, WSL, Git Bash) | Windows PowerShell | | --------------- | ---------------------------------- | -------------------------------- | | Set the key | `export MF_API_SECRET_KEY="..."` | `$env:MF_API_SECRET_KEY = "..."` | | Read the key | `$MF_API_SECRET_KEY` | `$env:MF_API_SECRET_KEY` | | Run curl | `curl` | `curl.exe` | | Continue a line | `\` | `` ` `` (backtick) | **Why the `$env:` prefix matters.** PowerShell keeps shell variables and environment variables in separate namespaces. `$MF_API_SECRET_KEY` refers to a PowerShell variable that was never assigned, so it expands to an empty string. The request goes out with an empty `x-api-key` header and comes back `401`. Nothing warns you first. **Why `curl.exe` and not `curl`.** In Windows PowerShell (the 5.1 version preinstalled on every Windows machine), `curl` is a built-in alias for `Invoke-WebRequest`, a different command whose parameters do not match curl's. It fails with a parameter-binding error rather than doing what you meant. PowerShell 7 dropped that alias, but `curl.exe` is correct in both, so write `curl.exe` and it works everywhere. Windows 10 and later ship real curl. **Sending a JSON body.** Put the JSON in a literal here-string, then pipe it to curl. The `@'` and `'@` markers stop PowerShell from touching the contents, and the closing `'@` must start at column 0 on its own line. ```powershell theme={null} $body = @' { "name": "Acme Inc", "externalId": "acme-001" } '@ $body | curl.exe -X POST https://api.marginfront.com/v1/customers ` -H "x-api-key: $env:MF_API_SECRET_KEY" ` -H "Content-Type: application/json" ` -d '@-' ``` **Pipe the body. Do not pass it as an argument.** `-d '@-'` tells curl to read the body from standard input, which is what the pipe feeds it. The obvious-looking `-d $body` is broken on Windows PowerShell 5.1 and on PowerShell 7.0 through 7.2. When those versions pass an argument containing spaces to a native program, they strip its embedded double quotes, so your JSON arrives as `{name: Acme Inc, externalId: acme-001}` and the API rejects it. PowerShell 7.3 changed that behavior, but piping is correct on every version, so use it and stop thinking about versions. Check yours with `$PSVersionTable.PSVersion` if you want to know. Only arguments carrying embedded double quotes are affected. Headers like `-H "x-api-key: ..."` and quoted URLs pass through every version unchanged. **Quote any URL with a query string.** `&` is reserved in PowerShell, and what an unquoted `...?startDate=X&endDate=Y` does depends on your version. Windows PowerShell 5.1 refuses to run the line at all: "The ampersand (&) character is not allowed." PowerShell 7 treats `&` as the background-job operator, cuts the command there, runs the truncated URL as a background job, and errors on the rest. Quote the URL and neither happens. **Checking the variable is set.** Run `$env:MF_API_SECRET_KEY.Length`. A number means it's set in this window. A blank line or an error means it isn't. This tells you what you need without printing the key. Environment variables set this way live only in the terminal window that set them. A new window starts clean. **Prefer Bash on Windows?** WSL and Git Bash both run the Bash examples as written, including `export`, `$VAR`, plain `curl`, and `\` line continuations. *** ## The API key alone identifies your organization **You don't need to pass your organization ID anywhere.** The API key alone tells MarginFront which org you are — the backend looks up your key, finds the org it belongs to, and uses that for the request. Every endpoint under `/v1/` works this way. This is the Stripe / Anthropic / OpenAI pattern. One credential, one identity, no redundant IDs to pass around. (If you need to find your org ID for support or debugging purposes, hit `/v1/verify` with your key and look at the `organization.id` field in the response.) *** ## Every API call is logged MarginFront automatically logs every API-key-authenticated request. The audit log captures: * The **key ID** (a non-secret reference to which of your keys was used) * Your **organization ID** * The **endpoint** (HTTP method + URL path) * The **response status code** * The **IP address** of the caller * The **timestamp** The full secret value of the key is never logged — only its non-secret ID. The audit log is stored in your own database and is only visible to you. ### What this means for compliance If you need to answer "who did what when" for a compliance audit or incident investigation, this is where you look. The data accumulates from the moment your key makes its first call and is retained indefinitely. ### Checking your own audit log (The Usage History UI on Build → API keys is the intended way to view this. If it's not there yet, you can query the `api_key_usage_logs` table directly via Prisma Studio in development.) *** ## Keeping your API key safe ### The short version * **Never commit keys to git.** Not in code, not in config files, not in comments. Use environment variables. * **Never ship secret keys to browser code.** Secret keys belong on your backend. If you need to show billing data to a customer in their browser, use a publishable key for reads, or [portal sessions](./portal-sessions) for customer-specific views. * **Never share keys across environments.** Use separate keys for development, staging, and production. * **Rotate keys if you suspect a leak.** Create a new key in the dashboard, update your code to use it, then revoke the old one. ### The longer version **Environment variables are the right pattern.** Load the key from an environment variable at runtime: ```bash Bash theme={null} export MF_API_SECRET_KEY="mf_sk_your_key_here" ``` ```powershell PowerShell theme={null} $env:MF_API_SECRET_KEY = "mf_sk_your_key_here" ``` ```js theme={null} // In your code: const mf = new MarginFrontClient(process.env.MF_API_SECRET_KEY); ``` Both forms last only for the current terminal session. For something permanent, use your deployment platform's secret manager, or on Windows `setx MF_API_SECRET_KEY "mf_sk_your_key_here"` followed by a new terminal. This way the key never hard-codes into files that could end up in git or a Docker image layer. **For local development:** put the key in `.env.local` (or whatever your framework uses) and make sure that file is in `.gitignore`. Never put it in `.env` that gets committed. **For production:** use your platform's secret management (Vercel env vars, AWS Secrets Manager, Doppler, etc.). Don't put production keys in plaintext config files. **If a key does leak:** 1. Go to the dashboard immediately 2. Create a replacement key 3. Update your code/deployment to use the new key 4. Revoke the leaked key from the dashboard 5. Check the audit log (see above) for any unusual activity using the old key # Credit Balances Source: https://docs.marginfront.com/api-reference/credit-balances Read credit-pool countdowns, add units, and pause alert emails # Credit Balances If a plan sells a **credit pool** ("5,000 tasks for $99 a month, then $0.03 each"), these endpoints are how you watch it and act on it: two reads for the countdown, and two writes for the moments a human steps in. The numbers here come from the same balance path the dashboard reads. Your code, your dashboard, and your invoice can't drift apart, because they're all looking at one calculation. > **Nothing stops at zero.** The pool is a meter, never a breaker. A customer past their pool keeps working and the extra usage bills at the plan's overage rate. If your product should stop serving at zero, your code makes that call: read the balance and decide. The [Credit Pools recipe](/recipes/credit-pools) shows the pattern. *** ## List credit balances **In plain English:** one row per credit-pool subscription in your organization, emptiest first. Subscriptions whose plan doesn't sell a pool aren't listed at all. No pool means no row, never a fake zero. **Method & URL:** ``` GET /v1/credit-balances ``` **Auth:** any valid API key for your organization. Reads have no role restriction. **Query parameters (all optional):** * `customerId` *(UUID)* - Only pools belonging to this customer. * `agentId` *(UUID)* - Only pools on subscriptions for this agent. * `belowPercent` *(number, -100 to 100)* - Only pools with this percent of the pool (or less) still left. `20` is the near-empty cut the dashboard uses; `0` narrows it to pools at or past zero. Negative values are allowed because an overdrawn pool is past zero. **Example:** ```bash theme={null} curl https://api.marginfront.com/v1/credit-balances \ -H "x-api-key: mf_sk_your_key" ``` **What you get back (`200 OK`):** ```json theme={null} { "balances": [ { "subscriptionId": "6f0d...", "subscriptionName": "Acme Corp: Pro Plan", "customerId": "9a1c...", "customerName": "Acme Corp", "customerExternalId": "acme-001", "agentId": "b2d4...", "planId": "c3e5...", "planName": "Pro Plan", "poolSizeUnits": 5000, "consumedUnits": 5300, "remainingUnits": 1200, "overageInProgressUnits": 300, "remainingPercent": 24, "overageRatePerUnit": 0.03, "periodStart": "2026-08-01T00:00:00.000Z", "periodEnd": "2026-09-01T00:00:00.000Z", "alertsPaused": false, "noActivePeriod": false, "implicitFullPool": false } ], "asOf": "2026-08-18T14:00:00.000Z" } ``` Responses carry a short private cache (up to 30 seconds), and `asOf` tells you exactly when the numbers were computed. ### Every field, in plain English | Field | Type | What it means | | ------------------------ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `subscriptionId` | UUID | The subscription this pool belongs to. Use it for the single-balance read and both writes. | | `subscriptionName` | string | The subscription's display name. | | `customerId` | UUID | The customer's MarginFront UUID. | | `customerName` | string or null | The customer's display name. | | `customerExternalId` | string or null | The customer's ID in YOUR system. | | `agentId` | UUID | The agent the subscription covers. | | `planId` | UUID | The pricing plan that sells the pool. | | `planName` | string or null | That plan's display name. | | `poolSizeUnits` | number | How many units the plan's pool covers each billing period. | | `consumedUnits` | number | Units drawn inside the current period. Top-up blind (see the two-numbers rule below). | | `remainingUnits` | number | Units left to draw, top-ups included. Goes negative once the pool is overdrawn. | | `overageInProgressUnits` | number | Units already billing as overage this period. This is the invoice's own math. | | `remainingPercent` | number | `remainingUnits` as a percent of `poolSizeUnits`. Negative once overdrawn. | | `overageRatePerUnit` | number or null | The per-unit overage price, or `null` when the plan's stored tiers can't say. | | `periodStart` | string or null | Current billing period start (ISO 8601). `null` whenever `noActivePeriod` is `true`. Honest absence, never a made-up window. | | `periodEnd` | string or null | Current billing period end. Same null rule. | | `alertsPaused` | boolean | `true` while this subscription's credit-pool alert emails are silenced. Pausing changes emails only; the countdown keeps counting. | | `noActivePeriod` | boolean | `true` when there's no period counting down right now: the subscription never got a billing period, or the current time falls outside the stored window (like the gap between a period ending and the next day's re-mint). See below. | | `implicitFullPool` | boolean | `true` when no drawable grant exists for the current period slot (it can accompany `noActivePeriod`). The pool reads FULL, because an untouched pool is a full one, not a drained one. | ### When the pool serves several metrics, "unit" means "credit" A plan can let more than one signal draw from the same pool, with each signal costing a set number of credits per unit (`creditRates` on the pricing strategy). When it does, every `...Units` field on this row counts **credits**, not events. An example: reports cost 4 credits and notes cost 0.5. Two reports and three notes take 9.5 out of the pool, and `consumedUnits` reads `9.5`. `poolSizeUnits`, `remainingUnits`, `overageInProgressUnits`, and `remainingPercent` all speak the same currency, and `overageRatePerUnit` is the price of one credit past the pool. The field names don't change, so a single-metric pool reads exactly as it always has. See [Letting several metrics share one pool](/api-reference/pricing-strategies#letting-several-metrics-share-one-pool-creditrates) for how to set the rates. ### The two numbers that can disagree (and why both are right) `remainingUnits` counts manual top-ups; `overageInProgressUnits` is the invoice's own overage math, which never sees top-ups. A topped-up pool can honestly show `remainingUnits: 1200` AND `overageInProgressUnits: 300` at the same time: the customer has units left, and 300 units are billing as overage this period. The invoice always matches `overageInProgressUnits`, so the bill can never disagree with what this endpoint told you. Count down with `remainingUnits`; talk money with `overageInProgressUnits`. ### Pools with no active period (`noActivePeriod: true`) Two situations set this flag, and both are normal: * **Never started.** A subscription created without a start date (for example, one auto-created by the SDK's first event) has no billing period, so its pool has nothing to count down in. Give the subscription a real start date and the countdown begins. * **Between periods.** A healthy pool whose period just ended sits in a short gap before the next period's grant is minted. During that gap the row reads as a full, untouched pool (`implicitFullPool: true`), not a drained one. A gap between two mint points isn't an empty pool. Either way the row still appears, with `periodStart` and `periodEnd` as `null`. *** ## Get one subscription's balance **In plain English:** the same row the list returns, for a single subscription. **Method & URL:** ``` GET /v1/subscriptions/{subscriptionId}/credit-balance ``` **Auth:** any valid API key for your organization. **Example:** ```bash theme={null} curl https://api.marginfront.com/v1/subscriptions/SUBSCRIPTION_ID/credit-balance \ -H "x-api-key: mf_sk_your_key" ``` **What you get back (`200 OK`):** one balance object, same fields as above. **Errors:** * **`404 Not Found`** - The subscription isn't in your organization, or its plan doesn't sell a credit pool. A subscription without a pool has no pool number, never a zero one. **Using the SDK:** ```typescript theme={null} const { balances } = await mf.creditBalances.list({ belowPercent: 20 }); const pool = await mf.creditBalances.get("SUBSCRIPTION_ID"); ``` **Using MCP:** the `get_credit_balances` tool wraps both reads. Ask "who's running out of credits?" *** ## Add units to a pool (top-up) **In plain English:** give a customer more units right now, mid-period. Goodwill credit, a side deal, a support resolution. The units never expire. **Method & URL:** ``` POST /v1/subscriptions/{subscriptionId}/credit-topup ``` **Auth:** an API key or dashboard user with the **owner, admin, or finance** role. Anything less gets a `403`: API-key callers see the allowed roles named in the message; dashboard sessions without the role get a generic forbidden. **Body:** * `units` *(number, required)* - How many usage units to add. Must be above zero. These are the same units the plan sells and the invoice bills, never dollars. * `idempotencyKey` *(string, required, max 255)* - Your own key for this top-up. Can't be blank. Send the same key again and you get the SAME grant back instead of a second one, so a retry can never double-credit a customer. A UUID works. * `note` *(string, optional, max 1000)* - Why you added the units. Stored on the grant itself, so "why does this customer have extra credits?" is answered from the ledger row. **Example:** ```bash theme={null} curl -X POST https://api.marginfront.com/v1/subscriptions/SUBSCRIPTION_ID/credit-topup \ -H "x-api-key: mf_sk_your_key" \ -H "Content-Type: application/json" \ -d '{ "units": 1500, "idempotencyKey": "topup-acme-2026-08-goodwill", "note": "Goodwill credit for the August incident" }' ``` **What you get back (`201 Created`):** ```json theme={null} { "grantId": "e7a9...", "subscriptionId": "6f0d...", "unitsAdded": 1500, "idempotencyKey": "topup-acme-2026-08-goodwill", "note": "Goodwill credit for the August incident", "createdBy": "api-key:1b7f...", "createdAt": "2026-08-18T14:03:00.000Z", "created": true, "remainingUnits": 1200 } ``` `created: false` means this key was already used and you're looking at the original grant (a replay, not a second credit). `remainingUnits` is the pool's drawable balance after the top-up. `createdBy` records who added the units: the user's ID for dashboard actions, or `api-key:` for API calls. The grant row itself is the audit trail. **Rules worth knowing:** * **Top-ups never expire.** The customer paid for them. Draw order burns the current period's grant first, so a top-up remainder survives into the next period. * **A top-up raises `remainingUnits`; it does NOT reduce `overageInProgressUnits`.** The invoice's overage math never sees top-ups. Overage already recorded this period stays on the bill. * **Reuse a key only to retry the exact same top-up.** A key reused with different units, or on a different subscription, is refused with a `409` so a copy-paste slip can't misapply a credit. **Errors:** * **`400 Bad Request`** - `units` missing or not above zero, `idempotencyKey` blank, or the subscription's plan sells no credit pool (the message tells you to add a credit-pool pricing strategy first). * **`403 Forbidden`** - The caller isn't owner, admin, or finance. API-key denials name the allowed roles; dashboard-session denials are a generic forbidden. * **`404 Not Found`** - Subscription not found in your organization. * **`409 Conflict`** - The idempotency key was already used with different units, or on a different subscription. The message spells out both the prior use and this request so you can see the mismatch. *** ## Pause or resume alert emails **In plain English:** quiet the credit-pool emails for one subscription (or turn them back on). You've talked to the customer, the overage is expected, and you don't need four more emails about it. **Method & URL:** ``` POST /v1/subscriptions/{subscriptionId}/credit-alerts-pause ``` **Auth:** owner, admin, or finance, same as the top-up. **Body:** * `paused` *(boolean, required)* - Always send the state you want, never a toggle: `true` silences this subscription's pool emails, `false` turns them back on. Two identical calls leave the subscription in the same place. **Example:** ```bash theme={null} curl -X POST https://api.marginfront.com/v1/subscriptions/SUBSCRIPTION_ID/credit-alerts-pause \ -H "x-api-key: mf_sk_your_key" \ -H "Content-Type: application/json" \ -d '{ "paused": true }' ``` **What you get back (`200 OK`):** ```json theme={null} { "subscriptionId": "6f0d...", "alertsPaused": true } ``` **What pausing does (and doesn't do):** * Emails for this subscription stop. That's the whole effect. * The countdown keeps counting, the balance stays visible everywhere, and the pool still appears on the dashboard's Needs Attention page when it's low (labeled Paused). * Un-pause mid-period and any alert stages the pool crossed while quiet can fire on the next evaluation. Stages re-arm every new billing period either way. **Errors:** * **`403 Forbidden`** - The caller isn't owner, admin, or finance. * **`404 Not Found`** - Subscription not found in your organization. *** ## The alert emails these writes manage As a pool drains, MarginFront emails at four stages: **50% used, 80% used, 95% used, and empty** (remaining units at or below zero). One email per stage per billing period, only the highest newly-crossed stage sends, and every stage re-arms when the period rolls over. Recipients: the organization's billing email if one is set, otherwise every owner and finance member. The [Credit Pools recipe](/recipes/credit-pools#the-alert-ladder-50-80-95-empty) covers the ladder in full. ## Related pages * **[Credit Pools recipe](/recipes/credit-pools)**: the full sell-a-pool walkthrough, including the self-limiting pattern for strict-prepaid products * **[Pricing Strategies](/api-reference/pricing-strategies)**: creating the pool with the three-number `creditPool` block * **[Errors](/api-reference/errors)**: the general error-shape reference # Customer Alerts Source: https://docs.marginfront.com/api-reference/customer-alerts Set a per-customer cost or revenue tripwire and get an email when it's crossed # Customer Alerts ## What is a customer alert? A **customer alert** is a watch on one customer: "email me when this customer's cost passes $500 this month," or "email me when this customer's revenue passes $10,000." When the number crosses the line, MarginFront emails a person you name and drops an in-app notification that links straight to that customer's page. The two numbers you can watch: * `cost` — what this customer costs you to serve (their usage cost). * `revenue` — what this customer is billed. Both are read through the same canonical metrics MarginFront uses everywhere else, so a watch sees the exact numbers the dashboard shows. You can manage these watches on a customer's page in the dashboard, or from your own code with the endpoints below. The [MCP tools](/mcp/tools) call these exact endpoints too. *** ## Who can manage watches Every endpoint here — reads included — needs a **secret** key that belongs to an **owner, admin, or finance** user. Any other key gets a `403 Forbidden` naming the role. A publishable key (`mf_pk_...`) is refused. A watch exposes one customer's money, so it sits with the people who own the numbers. *** ## The window: month, rolling, or custom Every watch measures its number over a window you pick: * `month` — the current UTC calendar month. Re-arms on its own each month, so a monthly watch keeps working month after month. * `rolling` — the last 30 days. Fires once when the number crosses, so you get one heads-up per crossing. * `custom` — a fixed date range you set with `customStartDate` and `customEndDate`. Fires at most once and expires at the end of the range. *** ## The endpoints ### List watches ``` GET /v1/customer-alerts ``` Returns every customer watch for your organization, each with the watched customer's name, the metric, the threshold, and the window. **Response (`200 OK`):** ```json theme={null} [ { "id": "a1b2c3d4-...", "customerId": "550e8400-e29b-41d4-a716-446655440000", "customerName": "Acme Corp", "customerExternalId": "acme-001", "metric": "cost", "thresholdUsd": 500, "windowMode": "month", "customStartDate": null, "customEndDate": null, "recipientEmail": "finance@yourco.com", "createdByUserId": "user-...", "createdAt": "2026-08-01T00:00:00.000Z", "updatedAt": "2026-08-01T00:00:00.000Z" } ] ``` *** ### Create a watch ``` POST /v1/customer-alerts ``` Owner, admin, or finance key required. **Request body:** ```json theme={null} { "customerId": "550e8400-e29b-41d4-a716-446655440000", "metric": "cost", "thresholdUsd": 500, "windowMode": "month", "notifyEmail": "finance@yourco.com" } ``` | Field | Type | Required | Notes | | ----------------- | ------ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `customerId` | string | Yes | The customer to watch, by MarginFront's internal UUID (not your `externalId`). | | `metric` | string | Yes | `cost` (what the customer costs you) or `revenue` (what they're billed). | | `thresholdUsd` | number | Yes | The tripwire in US dollars. Must be positive. | | `windowMode` | string | Yes | `month`, `rolling`, or `custom`. See the window section above. | | `customStartDate` | string | For `custom` | ISO 8601 date. The range start. Only allowed when `windowMode` is `custom`. | | `customEndDate` | string | For `custom` | ISO 8601 date. The range end. Only allowed when `windowMode` is `custom`. | | `notifyEmail` | string | Yes (for API-key calls) | The email that gets the alerts. Must be a current org member with an owner, admin, or finance role. An API key has no person behind it, so this is required when you call with a key. | The recipient gets a confirmation email when the watch is created, then an alert email plus an in-app notification whenever it trips. **Response (`201 Created`):** the full watch, same shape as the list response. **curl example:** ```bash theme={null} curl -X POST https://api.marginfront.com/v1/customer-alerts \ -H "x-api-key: mf_sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "customerId": "550e8400-e29b-41d4-a716-446655440000", "metric": "cost", "thresholdUsd": 500, "windowMode": "month", "notifyEmail": "finance@yourco.com" }' ``` **Common errors:** * `400 Bad Request`: an internal coding-spend customer as the target (use [Spend Controls](./spend-controls) for your own AI coding spend), a deleted customer, custom-mode dates that don't make sense, a recipient who isn't an org member with a money role, or your organization's watch limit already reached. * `403 Forbidden`: your key doesn't belong to an owner, admin, or finance user. * `404 Not Found`: no customer in your organization matches that ID. * `409 Conflict`: an identical watch already exists. Edit that one instead of creating a second. *** ### Update a watch ``` PATCH /v1/customer-alerts/{id} ``` Owner, admin, or finance key required. You can change `metric`, `thresholdUsd`, and `windowMode` (with the custom dates when you switch to `custom`). The watched customer and the recipient are fixed at create — to change either, delete the watch and create a new one. **Response (`200 OK`):** the updated watch, same shape as the list response. **Common errors:** * `400 Bad Request`: custom-mode dates that don't make sense for the resulting watch. * `403 Forbidden`: your key doesn't belong to an owner, admin, or finance user. * `404 Not Found`: no watch with that ID in your organization. * `409 Conflict`: the edit would collide with a watch that already exists. *** ### Delete a watch ``` DELETE /v1/customer-alerts/{id} ``` Owner, admin, or finance key required. Deleting a watch also clears its in-app notifications. **Response (`200 OK`):** ```json theme={null} { "deleted": true, "id": "a1b2c3d4-..." } ``` **Common errors:** * `403 Forbidden`: your key doesn't belong to an owner, admin, or finance user. * `404 Not Found`: no watch with that ID in your organization. *** ## Good to know * **A watch fires once per window.** A `rolling` or `custom` watch that has already tripped for its window won't email you again for that same window, even if you edit its threshold afterward. A `month` watch re-arms each calendar month. * **Editing never re-fires a window that already alerted.** The record of what already fired is permanent by design, so changing a threshold can't spam a fresh alert for a window you've already been warned about. * **Internal coding-spend accounts can't be watched here.** For your own team's Claude Code and Codex spend, use [Spend Controls](./spend-controls) instead. # Customers Source: https://docs.marginfront.com/api-reference/customers Create, read, update, and delete the people you bill # Customers A **customer** is someone you bill. Usually that's one of YOUR customers: the person, company, or account that's using your product and that you want to charge or track usage for. When you create a customer in MarginFront, you're saying "here's a person I want to track, here's how to reach them, here's the ID I use for them in my own system." > **You don't have to call this endpoint to create a customer.** When you fire a usage event with a `customerExternalId` MarginFront has not seen before, the customer record is created automatically. Use this endpoint when you want to set the display `name`, email, or other fields up front, or to update an existing customer. Once a customer exists in MarginFront, you can: * Log usage events against them (for usage-based billing) * Subscribe them to a pricing plan * Generate invoices for them * Read their billing history *** ## Create a customer **In plain English:** Tell MarginFront about a new customer of yours. Do this right after your own app creates a new user or account. **Method & URL:** ``` POST /v1/customers ``` **Authentication:** Secret API key in the `x-api-key` header. See [Authentication](./authentication). Publishable keys are rejected on writes with 403. **Required fields:** * `name` *(string)*: the customer's company name or full name. Shows up on invoices, so make it readable. **Optional but highly recommended:** * `externalId` *(string)*: your internal ID for this customer. Pick something you already use in your own system. This is how you'll reference the customer in other API calls (like usage events) without having to remember MarginFront's internal UUID. * `email` *(string)*: the billing email. Where invoices get sent. **Other optional fields:** * `phone` *(string)*: customer phone number * `addressLine1`, `addressLine2`, `city`, `state`, `zipCode`, `country` *(string)*: mailing address, used on invoices * `timezone` *(string, default `"UTC"`)*: customer's timezone, used for billing period calculations * `status` *(`"active"` | `"inactive"` | `"suspended"`, default `"active"`)* * `billingContactName` *(string)*: if the billing contact is different from the main contact * `billingContactEmail` *(string)*: same, for email * `netTerms` *(number, default `30`)*: payment terms in days * `invoiceEmailRecipients` *(array of strings)*: additional email addresses to CC on invoices * `metadata` *(object)*: any custom key/value pairs you want to attach. MarginFront stores and returns them but doesn't interpret them. **Example curl call:** ```bash theme={null} curl -X POST https://api.marginfront.com/v1/customers \ -H "x-api-key: $MF_API_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Acme Inc", "externalId": "acme-001", "email": "billing@acme.com" }' ``` **What you get back (`201 Created`):** ```json theme={null} { "id": "5e7f8a3d-1234-5678-9abc-def012345678", "name": "Acme Inc", "externalId": "acme-001", "email": "billing@acme.com", "status": "active", "createdAt": "2026-04-10T14:30:00.000Z" } ``` **Common errors:** * **`400 Bad Request`**: you're missing the `name` field, or one of your fields has the wrong type (e.g., an email that isn't a valid email). The response body will tell you exactly which field is wrong. * **`409 Conflict`**: a customer with that `externalId` already exists in this org. Either pick a different `externalId`, or [update the existing customer](#update-a-customer) instead. * **`401 Unauthorized`**: your API key is missing or wrong. * **`403 Forbidden`**: you used a publishable key (`mf_pk_*`) on a write. Use a secret key (`mf_sk_*`) instead. **When to call this:** Right when you create a new customer in your own system, BEFORE you try to log any usage events for them. > **Heads up:** If you log a usage event for an `externalId` that MarginFront has never seen before, MarginFront will auto-create a minimal customer record for you. This is convenient but means you miss the chance to set the name/email/etc. Creating the customer explicitly first gives you much richer data. *** ## Read a customer **Method & URL:** ``` GET /v1/customers/{customerId} ``` **What it returns:** The full customer object, same shape as the create response. **Example:** ```bash theme={null} curl https://api.marginfront.com/v1/customers/5e7f8a3d-... \ -H "x-api-key: $MF_API_SECRET_KEY" ``` **Common errors:** * **`404 Not Found`**: no customer with that ID exists in this org. *** ## Read a customer with revenue **In plain English:** fetch a customer together with their revenue, cost, and margin for a time window. Use this to power customer detail pages so you can show the record and the financial summary without two round trips. **Method & URL:** ``` GET /v1/customers/{customerId}/revenue ``` **Query parameters** (all optional): * `startDate` *(ISO date)*: start of the window, inclusive * `endDate` *(ISO date)*: end of the window, inclusive If you omit both, MarginFront defaults the window to the last 30 days (UTC). **Example:** ```bash theme={null} curl "https://api.marginfront.com/v1/customers/5e7f8a3d-.../revenue?startDate=2026-04-01&endDate=2026-04-30" \ -H "x-api-key: $MF_API_SECRET_KEY" ``` **What it returns:** a canonical `RevenueMetrics` object scoped to this customer. Same shape as [`GET /v1/analytics/revenue`](./analytics-revenue), same math. Revenue, cost, margin, margin percent, the usage/recurring/seat/onetime breakdown, per-subscription breakdown, per-strategy breakdown. See [the SDK types reference](/sdk/types-reference) for the full `RevenueMetrics` shape. **Using the SDK:** `client.customers.getWithRevenue(id, window)` wraps this endpoint and returns `{ customer, revenue }` in a single call (it fires the customer fetch and this revenue fetch in parallel). See [SDK customers](/sdk/customers). **Common errors:** * **`404 Not Found`**: no customer with that ID exists in this org. * **`401 Unauthorized`**: API key missing or wrong. *** ## List customers **Method & URL:** ``` GET /v1/customers ``` **What it returns:** An array of customers, with optional pagination info. **Example:** ```bash theme={null} curl https://api.marginfront.com/v1/customers \ -H "x-api-key: $MF_API_SECRET_KEY" ``` **When to use it:** usually for syncing. Pulling a fresh list of what's in MarginFront to compare against your own system. Not ideal for UI (the list can get long). *** ## Update a customer **Method & URL:** ``` PATCH /v1/customers/{customerId} ``` **Fields:** any of the fields you could send on create. All are optional on update. Only send what you want to change. **Example:** ```bash theme={null} curl -X PATCH https://api.marginfront.com/v1/customers/5e7f8a3d-... \ -H "x-api-key: $MF_API_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"phone": "+1-555-0100"}' ``` **What you get back (`200 OK`):** The updated customer object. **Common errors:** * **`404 Not Found`**: customer doesn't exist. * **`409 Conflict`**: you tried to change `externalId` to one that another customer in this org already has. * **`403 Forbidden`**: you used a publishable key on a write. *** ## Delete a customer **Method & URL:** ``` DELETE /v1/customers/{customerId} ``` **What it does:** soft-deletes the customer. Any subscriptions attached to this customer should be canceled or deleted first. If they aren't, the delete may fail with a foreign-key error. **Example:** ```bash theme={null} curl -X DELETE https://api.marginfront.com/v1/customers/5e7f8a3d-... \ -H "x-api-key: $MF_API_SECRET_KEY" ``` **Common errors:** * **`404 Not Found`**: customer doesn't exist. * **`409 Conflict`**: customer still has active subscriptions. Cancel or delete those first. * **`403 Forbidden`**: you used a publishable key on a write. *** ## Using the Node SDK If you're using the published `@marginfront/sdk` npm package, you never type these URLs yourself. Instead: ```typescript theme={null} import { MarginFrontClient } from "@marginfront/sdk"; const mf = new MarginFrontClient(process.env.MF_API_SECRET_KEY); // Create const customer = await mf.customers.create({ name: "Acme Inc", externalId: "acme-001", email: "billing@acme.com", }); // Read const existing = await mf.customers.get("acme-001"); // Update await mf.customers.update("acme-001", { phone: "+1-555-0100" }); // List const { data } = await mf.customers.list(); // Delete await mf.customers.delete("acme-001"); ``` The SDK wraps the exact HTTP endpoints described above. If something works via curl, it works via the SDK. They're the same thing. # Errors Source: https://docs.marginfront.com/api-reference/errors Every error code translated to plain English with fix steps # Errors When something goes wrong, MarginFront returns an HTTP status code and a JSON body describing what happened. This doc translates the most common ones from developer-speak into plain English, plus how to fix each one. *** ## The shape of an error response Every error looks roughly like this: ```json theme={null} { "statusCode": 400, "message": "name should not be empty", "error": "Bad Request" } ``` For validation errors (400s), `message` is usually the most helpful — it tells you exactly which field is wrong and why. For server errors (500s), `message` is often a generic string; the actual problem is on MarginFront's side and the response won't tell you how to fix it (because it's not your fault). *** ## `400 Bad Request` **What happened:** Something in your request is malformed. Usually a missing required field or a field with the wrong type. **Most common causes:** * You forgot a required field (check the resource's doc for which fields are required) * A UUID field isn't a valid UUID format * A date field isn't valid ISO 8601 * An enum field has a value that isn't in the allowed list * An email field isn't a valid email **How to fix:** Read the `message` field — it usually names the specific field that's wrong. Fix it and retry. **Example:** ```json theme={null} { "statusCode": 400, "message": ["name should not be empty", "agentId must be a UUID"], "error": "Bad Request" } ``` Fix: add a `name`, fix the `agentId` to be a valid UUID. *** ## `401 Unauthorized` **What happened:** Your API key is missing, malformed, or wrong. **Most common causes:** * You forgot the `x-api-key` header entirely * The header name is wrong (e.g., using `Authorization: Bearer` — that's for JWT tokens, not API keys) * The API key is from a different environment (test vs live) * The API key was revoked in the dashboard * A typo when copy-pasting the key **How to fix:** 1. Double-check the header is exactly `x-api-key: mf_sk_...` 2. Copy a fresh key from the dashboard 3. Re-export it: `export MF_API_SECRET_KEY="..."` 4. Try again If you still get 401 after a fresh key, the endpoint might require a logged-in user session (not an API key) — see the `403` section below. **A revoked key comes back labeled.** If the key you sent was revoked in the dashboard, the response body carries `"code": "key_revoked"` and a message that says when it was revoked. The `code` lets you spot a revoked key in your own code without reading the message text. The fix is to create a new key in **Build → API keys** and update whatever app still sends the old one. *** ## `403 Forbidden` **What happened:** Your API key is valid, but you're trying to do something it's not allowed to do. **Most common causes:** * **You used a publishable key (`mf_pk_*`) on a write operation.** POST/PUT/PATCH/DELETE with a publishable key are rejected. Use a secret key (`mf_sk_*`) for writes. * **You used a publishable key (`mf_pk_*`) to read your organization's own data.** A publishable key can reach only two identity endpoints: `/v1/verify` and its own `/v1/me/key`. Every other authenticated read (customers, invoices, analytics like revenue, cost, and MRR, credit balances, and your AI-spend / Spend Controls reads) is secret-key-only, because that data is your org's books, not the public info a browser-embeddable key is meant for. The message says the endpoint needs a secret key. Use a secret key (`mf_sk_*`). * **Your key doesn't carry the right role for this endpoint.** A few endpoints are restricted beyond secret-vs-publishable. Spend-cap writes (`POST`/`PATCH`/`DELETE` on `/v1/control/caps`) need a key that belongs to an **owner or finance** user. The two credit-pool writes (`POST /v1/subscriptions/:id/credit-topup` and `POST /v1/subscriptions/:id/credit-alerts-pause`) need **owner, admin, or finance**, the same gate as every other write that moves money. These write denials name the allowed roles in the message (a logged-in dashboard user without the role gets a generic forbidden instead); use a key from someone with that role, or ask them to make the change. * **You used an ingest-only key on a route it can't reach.** An **ingest-only** secret key can send usage records, confirm which organization it belongs to (`/v1/verify`), and read your spend controls (the caps, your current spend, and coverage). Every other authenticated route (customers, invoices, revenue, payouts, credit balances, and the rest) answers `403`. This denial does not list roles: its message explains what an ingest-only key can reach and tells you to use a key with a higher role. Mint one in the dashboard under **Build → API keys**. * You hit an endpoint that requires a human user session (not an API key). These include: * `GET /v1/users/me` * `POST /v1/invitations/accept/:token` * Team invitation endpoints * A few org management actions **How to fix:** * If the response message mentions a publishable key, or says the endpoint needs a secret key, switch to a secret key. See [authentication.md](./authentication) for the key type reference. * If the endpoint is a "user-scoped" endpoint, you can't use an API key — you need to be logged in as a human via the dashboard instead. *** ## `404 Not Found` **What happened:** The thing you're trying to read/update/delete doesn't exist. **Most common causes:** * Wrong UUID in the URL (typo, or using an old deleted record's ID) * The record exists but in a DIFFERENT org than yours * You're creating a subscription but the `customerId` / `agentId` / `planId` you reference doesn't exist * The URL itself is wrong (e.g., typo in the resource name) **How to fix:** * Double-check the ID by listing the resource first and finding the correct ID * Verify the ID belongs to your org (if you're using a different API key than usual, make sure it's pointing at the same org) * Check the URL for typos *** ## `409 Conflict` **What happened:** The thing you're trying to create already exists, or your update would violate a uniqueness constraint. **Most common causes:** * Creating a customer with an `externalId` that already exists in this org * Creating an agent with an `agentCode` that already exists in this org * Creating a signal with a name that already exists for that agent * Reusing a credit-topup `idempotencyKey` with different units, or on a different subscription (reuse a key only to retry the exact same top-up; see [Credit Balances](./credit-balances)) * Deleting an agent/customer/plan that still has dependent records (e.g., deleting a customer that has an active subscription) **How to fix:** * For duplicate `externalId` / `agentCode` / name: either pick a different one, or update the existing record instead of creating a new one * For delete-with-dependents: delete or reassign the dependent records first, then retry the delete *** ## `422 Unprocessable Entity` **What happened:** Your request was structurally valid (valid JSON, required fields present), but the combination of values doesn't make sense. **Most common causes:** * Creating a subscription with an `endDate` earlier than the `startDate` * Billing cycle math that doesn't work (e.g., `billingCycle: "custom"` without `customCycleDays`) * An `agentId` that belongs to a different org than the customer **How to fix:** Read the `message` field carefully — it'll describe the specific inconsistency. *** ## `429 Too Many Requests` **What happened:** You're hitting the API faster than the rate limit allows. **How to fix:** * Slow down your request rate * If logging usage events, batch them (up to 100 per call) instead of one per request * Check response headers for `Retry-After` to see how long to wait ### The other kind of 429: too many failed key attempts If the body carries `"code": "too_many_auth_failures"`, this isn't about your request rate. It means too many *failed* API-key attempts came from your network in a short window. That's a sign the key you're sending is wrong, not that you're calling too fast. **How to fix:** Stop retrying, wait a few minutes, and check the key you're sending in **Build → API keys**. A key that MarginFront accepts clears the block. *** ## `500 Internal Server Error` **What happened:** Something broke on MarginFront's side. This is NOT your fault. **Most common causes:** * A bug in MarginFront's code * A database connection issue * An upstream service (Stripe, Resend, etc.) being unavailable **How to fix:** * Retry once after a few seconds — transient errors are common * If it persists, email `team@marginfront.com` with: * The exact URL and method you called * The request body (with any API keys redacted) * The full error response body * A timestamp (roughly when you saw the error) MarginFront logs every 500 error internally. If you report it, the team can usually find the specific stack trace in the logs. *** ## `502` / `503` / `504` — Gateway errors **What happened:** MarginFront's API is temporarily unreachable or down. **How to fix:** * Wait a minute and retry * Check MarginFront's status page if one exists * If it's sustained, email `team@marginfront.com` These are usually transient — a deployment rolling, a load balancer restart, a brief upstream outage. **One specific 503 is about your key, and it's still not your fault.** If the body carries `"code": "auth_unavailable"`, MarginFront couldn't check your API key because of a problem on its side, not because your key is bad. Your key is probably fine. Wait a moment and try again; it clears on its own. *** ## "I get a response but no body" A few endpoints return an empty body on success (typically `DELETE` returning `204 No Content`). That's expected — the status code is the real signal, not the body. If you expected a body and got nothing on a different status code, treat it as a bug and report it. *** ## "The error message mentions a field I didn't send" This usually means a required field is missing, or a field you sent has the wrong type. The error's `message` array tells you exactly which field — read it carefully and cross-reference with the resource's doc file (e.g., [customers.md](./customers)) for the required and optional fields. *** ## Response-level error codes (inside a `200 OK`) Some endpoints — particularly `POST /v1/usage/record` — return `200 OK` even when individual records in a batch fail. The failures appear in the `results.failed[]` array in the response body, each with a `code` and a `stored` flag. ### The `stored` flag Every failed record tells you whether the event was saved to the database: * **`stored: true`** — The event IS in the system (with `cost: null`). **Do NOT retry** — retrying will create a duplicate. Instead, fix the root cause (map the model in the dashboard) and the event will be backfilled automatically. * **`stored: false`** — The event was NOT saved. **Safe to retry** after fixing the issue. ### Error codes | Code | Stored? | What happened | What to do | | --------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `NEEDS_COST_BACKFILL` | Yes | The event is saved with `usageCost: null` for one of two reasons: the `model` + `modelProvider` combination isn't in the pricing table, OR it is in the table but has no rate for the cache tokens your event reported (MarginFront flags it rather than pricing that cache at \$0). The `error` message says which. | Go to the dashboard → Usage Events → "Needs attention." If the model is unknown, map it to a known one. If the model is known but missing cache-token pricing, email `team@marginfront.com` to have cache pricing added. Once resolved, cost is calculated retroactively and future events auto-resolve. | | `INTERNAL_ERROR` | No | Something unexpected broke on our side during event processing. | Safe to retry. If it keeps happening, contact support with the `rawEventId` from the response. | | `VALIDATION_ERROR` | No | A required field is missing or has the wrong type (e.g., `modelProvider` not provided, `inputTokens` is negative). | Read the `error` message to see which field failed. Fix the request and resend. | ### Example: a batch with one success and one backfill ```json theme={null} { "processed": 2, "successful": 1, "failed": 1, "results": { "success": [ { "customerExternalId": "acme-001", "model": "gpt-4o", "modelProvider": "openai", "totalCostUsd": "0.0024780000", "eventId": "8a7b6c5d-..." } ], "failed": [ { "record": { "customerExternalId": "acme-001", "model": "my-custom-llm", "modelProvider": "custom" }, "code": "NEEDS_COST_BACKFILL", "stored": true, "eventId": "a1b2c3d4-...", "error": "Model \"my-custom-llm\" (provider \"custom\") not found in service_pricing. Event stored — map this model in the dashboard." } ] } } ``` The first event was fully processed with cost. The second was stored but needs the model mapped before cost can be calculated. HTTP status is `200` for both. See [usage-events.md](./usage-events) for the full endpoint documentation and mapping workflow. *** ## Still stuck? If none of the above match what you're seeing, email `team@marginfront.com` with: 1. The exact curl command you ran (API key REDACTED) 2. The full response status and body 3. What you expected to happen Treat docs-confusion as a docs bug — if the error you got isn't in this list, we want to know so we can add it. # Glossary Source: https://docs.marginfront.com/api-reference/glossary Quick definitions of every MarginFront term # Glossary Quick definitions of the words that show up throughout the MarginFront docs. If a term confuses you, start here. *** ## Customer One of **your** customers. A person, company, or account that uses your product and that you want to bill. Not a MarginFront customer. MarginFront doesn't sell to your customers directly. You create a customer record in MarginFront so it knows who to track usage and bills for. **Key fields:** `name`, `externalId` (your internal ID for them), `email`. See [customers.md](./customers). *** ## Agent The thing doing the work. An agent can be an AI agent, a microservice, a worker, a product, a Lambda function. Anything that produces billable activity. Every piece of usage in MarginFront is attributed to exactly one agent. You might have just one agent (if you sell one product), or many agents (if you sell multiple products that price differently). **Key fields:** `name`, `agentCode` (short unique handle you pick), `description`. See [agents.md](./agents). *** ## Signal (also called "metric") What you measure and bill on. A signal is the unit of work you're charging for. Examples: * `messages` (messages sent by a chatbot) * `pages_processed` (documents analyzed) * `minutes_transcoded` (video minutes) * `images_generated` Signals always belong to an agent. Each agent can have multiple signals. The signal's `shortName` is what you reference when logging usage events. See [signals.md](./signals). *** ## Pricing plan Defines how an agent charges for its work. Think "Starter Plan," "Pro Plan," "Enterprise Custom." Each pricing plan can be linked to one or more agents. A customer subscribed to a plan gets billed according to that plan's rules for anything they do with that agent. See [pricing-plans.md](./pricing-plans). *** ## Pricing strategy The actual math inside a pricing plan. A plan is a container. A strategy is one specific rule inside it, like "charge $0.10 per message" or "charge $99 per month." Plans have one or more strategies; strategies do the actual price calculation. See [pricing-strategies.md](./pricing-strategies). *** ## Subscription Ties a customer to a pricing plan. Until a customer has a subscription, MarginFront doesn't know how to charge them. A subscription has a start date, an optional end date, a billing cycle (monthly, yearly, custom), and a status (active, canceled, past\_due, trialing). See [subscriptions.md](./subscriptions). *** ## Usage event (also called "tracked event" or "signal event") A single measurement. "At this time, this customer used this agent to do this much of this thing." You log a usage event every time your agent does work. MarginFront counts them up over the billing period and applies the customer's pricing plan to produce an invoice. Logged via `POST /v1/usage/record`. See [usage-events.md](./usage-events). *** ## Invoice A finalized bill. The document you send to a customer at the end of each billing period. MarginFront generates invoices automatically based on each customer's subscription and logged usage. An invoice has line items (per signal, per agent), a total, a status (see below), and a due date. See [invoices.md](./invoices). *** ## Invoice statuses The state an invoice is in. MarginFront uses these seven: * **draft**: Still being built. Not yet shown to the customer. * **pending**: Created but waiting on a hand-off step (for example, waiting for a payment provider to confirm). * **issued**: Sent to the customer. Waiting on payment. * **paid**: Customer paid in full. * **overdue**: Issued but past the due date without payment. * **void**: Canceled. Shouldn't be collected on. Never counted in revenue numbers. * **refunded**: Payment was reversed after the customer already paid. *** ## Organization ("org") The top-level container in MarginFront. Every customer, agent, signal, plan, subscription, and invoice belongs to exactly one organization. When you sign up for MarginFront, you get an org. If you're running a multi-tenant product, you'll have one org per MarginFront account (not one per customer). Your customers go INSIDE your org as customer records. **Org ID:** a UUID identifying your MarginFront organization. You normally never need to pass this anywhere. The API key alone tells MarginFront which org you are. If you need to find your org ID for support or debugging, hit `GET /v1/verify` with your key and look at the `organization.id` field in the response. Every API key belongs to exactly one org. *** ## API key The secret that authenticates your code to MarginFront. Secret keys look like `mf_sk_test_...` or `mf_sk_live_...` (full access, backend only). Publishable keys look like `mf_pk_test_...` or `mf_pk_live_...` (read-only, safe in browser code). Goes in the `x-api-key` header of every request. See [authentication.md](./authentication) for how to use and manage them. *** ## `externalId` / `agentCode` / `shortName` String-based handles that YOU pick for customers, agents, and signals. These let you reference MarginFront resources using IDs that are meaningful in YOUR system, instead of having to remember MarginFront's internal UUIDs. * **`externalId`** your ID for a customer * **`agentCode`** your short code for an agent * **`shortName`** your short name for a signal (used in usage events) *** ## UUID vs external ID: which should I use? It depends on the endpoint: * **CRUD endpoints** (create, read, update, delete for customers, agents, signals, pricing plans, subscriptions) mostly use UUIDs (the internal `id` field) in URLs and foreign keys. When you create a subscription, you pass `customerId`, `agentId`, and `planId` as UUIDs. * **Usage event logging** (`POST /v1/usage/record`) uses the string handles instead: `customerExternalId`, `agentCode`, `signalName`. Usage events are high-volume and the string handles are more ergonomic than copying UUIDs around. Both reference the same underlying records. They're just different ways to look them up. See each resource's docs for which fields it expects where. *** ## LLM / Model MarginFront has built-in cost lookups for many popular large language models (GPT-4o, Claude Sonnet, etc.). When you log a usage event from an LLM-powered agent, you include the `model` field so MarginFront can calculate the actual per-token cost. For non-LLM services, you can still include a model identifier (MarginFront treats it as a cost lookup key) or use `quantity` instead of tokens. *** ## Billing cycle / Billing period The time window an invoice covers. If a subscription has `billingCycle: "monthly"`, MarginFront generates one invoice per calendar month covering all usage in that month. The "billing period" is one specific instance of the cycle. For example, April 1-30 is one billing period for a monthly subscription. *** ## Net terms Number of days a customer has to pay an invoice after it's issued. Default is 30 (meaning "invoice due 30 days after it's generated"). Usually set at the customer level or the subscription level. *** ## Auto-create A convenience feature. If you log a usage event for a `customerExternalId` or `agentCode` that MarginFront has never seen, it auto-creates a minimal customer or agent record using that ID. Great for prototyping. Easy to miss if you make a typo. Consider it a "helpful on-the-fly" behavior rather than a replacement for explicit setup. *** ## Model The identifier for the AI model or service that did the work. Pass whatever your provider SDK returned. `response.model` from OpenAI/Anthropic, or the service SKU for non-LLM tools. Examples: `"gpt-4o"`, `"claude-sonnet-4-6"`, `"twilio-sms"`, `"textract-standard"`. Case-insensitive, whitespace trimmed. Required on every usage event. *** ## Model Provider The name of the company that runs the model, in lowercase. Examples: `"openai"`, `"anthropic"`, `"google"`, `"twilio"`, `"aws"`. Required because different providers can have models with identical names. Without this field, MarginFront can't tell which pricing table to look in. *** ## Needs Cost Backfill An event that was stored successfully but doesn't have a cost yet. This happens either because the model+provider combination wasn't in the pricing table, or because the model is in the table but has no rate for the cache tokens the event reported (MarginFront flags it instead of pricing that cache at \$0). These events show up in the dashboard under "Needs attention." An unknown model can be mapped to a known one with one click; a known model that is only missing cache pricing is resolved by having cache pricing added for it. Once resolved, cost is calculated retroactively and all future events with that model auto-resolve. *** ## Missing Volume Data An event that was stored successfully but didn't carry the volume numbers needed to calculate cost. For an LLM event, that means `inputTokens` or `outputTokens` was missing. For a non-LLM event, that means `quantity` was missing. The event still counts in event-count totals but has no cost. Fix it by replaying the event with the missing field filled in, or leave it if you only needed the timestamp. *** ## Cost How much MarginFront's built-in pricing table says the work cost you to deliver. Calculated from the event's model, provider, and token counts (or quantity for non-LLM work). Only what MarginFront could match against its own pricing table counts. Events with an unknown model contribute zero until you map the model. This is the number you look at on your margin dashboard. Your customers never see it. *** ## Agent-Earned The first of four revenue numbers. Pure activity: what your agents produced in a time window, priced against your pricing strategies. No subscription fees, no proration, no invoices involved. It's the earliest signal of "did work happen this week that we'll eventually bill for?" Formula: for every usage event in the window, multiply quantity times the pricing strategy rate. Add them up. Useful for: spotting usage trends fast, before invoices finalize. *** ## Revenue The second of four revenue numbers. The full formula: Agent-Earned plus the recurring fees, seat fees, and onetime fees for any active subscription during the window. Prorated if the subscription only partially overlaps the window. Useful for: "what did the customer owe us for this window?" *** ## Billed The third of four revenue numbers. The total of every invoice dated in the window that has been sent out (status `issued` or `overdue`). Draft invoices don't count. Void invoices don't count. Paid invoices don't count here either (they count in Collected). Useful for: "how much did we actually invoice this period?" *** ## Collected The fourth of four revenue numbers. The cash that actually landed in the window: every payment that went through with a payment date inside the window, minus anything you refunded. It's counted by the day the money arrived, not by the date on the invoice. A payment that lands this month counts this month, even if the invoice was dated earlier. Useful for: "how much money actually came in this period?" *** ## Margin Revenue minus cost. Every one of the four revenue numbers (Agent-Earned, Revenue, Billed, Collected) has its own margin. You'll see "Agent-Earned Margin," "Billed Margin," "Collected Margin" on the dashboard. Margin percent is margin divided by revenue. When revenue is zero, the percent isn't shown (it would be meaningless). The dashboard shows a dash instead. *** ## MRR (Monthly Recurring Revenue) Your monthly run rate. MarginFront calculates MRR three different ways: * **MRR** (the default): the total of invoices sent out last complete calendar month. Backward-looking. Answers "what did we actually bill last month?" * **Run-Rate MRR**: the trajectory based on the last 30 days of activity plus any committed recurring fees, normalized to a month. Forward-looking. Answers "if the last 30 days kept going, what would we bill per month?" * **Committed MRR**: just the contractual floor. Minimum commitments on every active subscription, no actual usage included. Answers "what do we bill for sure, no matter what customers do this month?" Multiply any of these by 12 to get ARR (annual run rate). *** ## Invoice date The date stamped on the invoice when it's sent out. MarginFront uses this date (not `createdAt`, not `dueDate`, not the billing period) to decide which window an invoice falls into for Billed. Collected works differently: it counts by the date the payment landed, not the invoice date. *** ## Never-drop-events rule MarginFront never throws away a usage event, even if something is wrong with it. If the model isn't recognized, the event still stores (with cost pending backfill). If volume data is missing, the event still stores (flagged as missing). If the pricing strategy doesn't match, the event still stores. Your data is safe. You fix issues in the dashboard and MarginFront backfills. *** ## Default quantity If a usage event doesn't specify `quantity`, MarginFront stores it as 1. Most discrete events (one SMS, one API call) don't need to pass quantity. The default kicks in AFTER validation, so non-LLM events that didn't send quantity still get flagged as missing volume data. The default is a storage convenience, not a way to skip validation. # Invoices Source: https://docs.marginfront.com/api-reference/invoices Read the bills MarginFront generates # Invoices An **invoice** is a finalized bill. The document you send to a customer when it's time to pay. MarginFront generates invoices automatically based on each customer's subscription and logged usage events. The API lets you read those invoices back so you can display them in your own product, sync them to accounting tools, or hand them to your finance team. Invoice creation happens automatically at the end of each billing period (driven by the subscription's billing cycle). You can also generate a draft invoice on demand for any active subscription using the [generate endpoint](#generate-a-draft-invoice) — useful for mid-cycle billing, one-click "bill now" flows, and previewing what a customer's next invoice will look like. *** ## The endpoints ### List invoices **Method & URL:** ``` GET /v1/invoices ``` **Query parameters** (all optional): * `customerExternalId`: filter to a single customer * `status`: filter by state. Valid values are `draft`, `pending`, `issued`, `paid`, `overdue`, `void`, `refunded` (see [Invoice statuses](#invoice-statuses) below) * `startDate` / `endDate`: filter by invoice date range * `limit` / `offset`: pagination **Example:** ```bash theme={null} curl "https://api.marginfront.com/v1/invoices?customerExternalId=acme-001" \ -H "x-api-key: mf_sk_test_..." ``` **What you get back (`200 OK`):** ```json theme={null} { "data": [ { "id": "inv_abc123", "customerExternalId": "acme-001", "status": "issued", "total": 429.5, "currency": "USD", "periodStart": "2026-03-01T00:00:00.000Z", "periodEnd": "2026-03-31T23:59:59.999Z", "dueDate": "2026-04-30T00:00:00.000Z", "createdAt": "2026-04-01T00:00:00.000Z" } ], "hasMore": false } ``` *** ### Read one invoice **Method & URL:** ``` GET /v1/invoices/{invoiceId} ``` **Returns:** The full invoice including all line items. What each signal cost, per-agent breakdowns, discounts applied, taxes, the works. **Example:** ```bash theme={null} curl https://api.marginfront.com/v1/invoices/inv_abc123 \ -H "x-api-key: mf_sk_test_..." ``` *** ### Generate a draft invoice Turns a subscription's tracked usage into a draft invoice you can preview, edit, or send. Line items and totals are computed server-side from the period's real usage events plus any recurring, seat, or one-time charges on the plan. **Method & URL:** ``` POST /v1/invoices/generate ``` **Body:** | Field | Type | Required | Description | | -------------------- | ------ | -------- | ----------------------------------------------------------------------------------------- | | `customerId` | string | Yes | The customer being billed (their MarginFront UUID, not the external ID from your system). | | `subscriptionId` | string | Yes | The subscription whose usage you want to turn into an invoice. | | `billingPeriodStart` | string | No | ISO 8601 date. Defaults to the subscription's current period start. | | `billingPeriodEnd` | string | No | ISO 8601 date. Defaults to the subscription's current period end. | When `billingPeriodStart` / `billingPeriodEnd` are omitted, the subscription's current billing period is used — the right answer for "bill now" flows. **Example:** ```bash theme={null} curl -X POST https://api.marginfront.com/v1/invoices/generate \ -H "x-api-key: mf_sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "customerId": "7a2b3c4d-5e6f-7890-abcd-ef1234567890", "subscriptionId": "1f2e3d4c-5b6a-7980-1234-56789abcdef0" }' ``` **What you get back (`201 Created`):** the full draft invoice, including line items and totals. The invoice starts in `draft` status — it is not sent until you move it to `issued` (manually, or via the auto-finalize step at the end of the billing period). **Common errors:** * **`400 Bad Request`**: `customerId` or `subscriptionId` is missing or not a UUID. * **`401 Unauthorized`**: API key missing or wrong. * **`404 Not Found`**: the customer or subscription does not exist in this org, or the customer does not own that subscription. *** ### Send an invoice email Emails an invoice to the customer with a "Pay Now" button that opens Stripe Checkout pre-filled with the invoice details. Use this after [`/v1/invoices/generate`](#generate-a-draft-invoice) to actually deliver the draft, or to re-send an invoice that has already been issued. The customer's stored email address is used by default — pass `recipientEmail` to override (for example, to route the invoice to a different billing contact). **Method & URL:** ``` POST /v1/invoices/{invoiceId}/send ``` **Body** (all fields optional — omit the body entirely to send to the customer's stored email with auto-generated subject and body): | Field | Type | Required | Description | | ---------------- | ------ | -------- | --------------------------------------------------------------------------------------------------------- | | `recipientEmail` | string | No | Override the destination address. Falls back to the customer's stored email. | | `subject` | string | No | Custom subject line. Default: `Invoice {number} from {your business name}`. | | `message` | string | No | Optional note shown in a callout above the invoice details (for example, "Card on file will be charged"). | **Example:** ```bash theme={null} curl -X POST https://api.marginfront.com/v1/invoices/inv_abc123/send \ -H "x-api-key: mf_sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "recipientEmail": "billing@customer.com", "subject": "May usage invoice — auto-charge in 5 days", "message": "Card on file will be charged automatically." }' ``` **What you get back (`200 OK`):** ```json theme={null} { "success": true, "message": "Invoice email sent successfully", "emailId": "msg_88c9a928", "recipientEmail": "billing@customer.com" } ``` `emailId` is the provider's message ID for delivery tracking. `recipientEmail` echoes back the address the email was actually sent to (after applying any override). **Side effect — auto-finalize:** if the invoice was still in `draft` status when this call landed, sending it auto-finalizes the status from `draft` to `issued`. This matches the behavior of the dashboard's Send button and the end-of-period auto-finalize step. No separate "finalize" call is needed first. **After payment:** once the customer clicks the Stripe Checkout link in the email and pays, the invoice status flips to `paid` automatically via webhook. You do not need to poll or make a follow-up call to confirm. **Common errors:** * **`400 Bad Request`**: the invoice cannot be sent (for example, it is already `paid` or `void`). * **`401 Unauthorized`**: API key missing or wrong. * **`404 Not Found`**: no invoice with that ID exists in this org. *** ## Invoice statuses MarginFront invoices move through one of seven statuses over their lifetime. Every invoice in the API has its `status` field set to exactly one of these. | Status | Meaning | | ---------- | ---------------------------------------------------------------------------------------------------------- | | `draft` | Still being built. Not yet sent. Amounts can still change. Does not count as billed revenue. | | `pending` | Finalized and queued to send. Sitting in the outbox waiting for the send step. | | `issued` | Sent to the customer. Awaiting payment. Counts as billed revenue. | | `paid` | Customer paid in full. Counts as collected revenue. | | `overdue` | Past the due date without full payment. Still counts as billed revenue; payment is late. | | `void` | Canceled. Treated as if it never happened for billing purposes. Does not count toward billed or collected. | | `refunded` | Was paid, then fully refunded. Counts as neither billed nor collected once it lands here. | The natural "happy path" is `draft` → `pending` → `issued` → `paid`. Most invoices go through exactly those four. ### Refund flow Refunds are recorded against a payment, not directly against the invoice. When you create a refund using `POST /v1/invoices/:invoiceId/payments/:paymentId/refund`, MarginFront records the refund and, if the refund covers the entire invoice, moves the invoice from `paid` to `refunded`. Partial refunds leave the invoice in `paid` and just reduce the net collected amount. Once an invoice is `refunded`: * It is excluded from collected revenue totals. * It is excluded from billed revenue totals (as if it had never been invoiced). * The underlying payment and refund records are preserved for audit. The dashboard and SDK analytics endpoints already handle `refunded` correctly. If you are rolling your own accounting sync, treat `refunded` invoices the same way you would treat `void` invoices for revenue-recognition purposes. *** ## Common errors * **`401 Unauthorized`**: API key missing or wrong. * **`404 Not Found`**: no invoice with that ID exists in this org. *** ## Using the Node SDK ```js theme={null} // List recent invoices for a customer const { data } = await mf.invoices.list({ customerExternalId: "acme-001", }); // Fetch a specific invoice const invoice = await mf.invoices.get("inv_abc123"); console.log(`Amount due: $${invoice.total} ${invoice.currency}`); // Generate a draft invoice from a subscription's current billing period const draft = await mf.invoices.generate({ customerId: "7a2b3c4d-5e6f-7890-abcd-ef1234567890", subscriptionId: "1f2e3d4c-5b6a-7980-1234-56789abcdef0", }); console.log(`Draft ${draft.invoiceNumber}: $${draft.totalAmount}`); // Email the draft to the customer. If the invoice is still in draft status, // this also auto-finalizes it to "issued" as a side effect. The customer's // stored email is used by default. const sent = await mf.invoices.send(draft.id); console.log(`Sent to ${sent.recipientEmail} (message id: ${sent.emailId})`); // Override recipient + subject + add a custom note await mf.invoices.send("inv_abc123", { recipientEmail: "billing@customer.com", subject: "May usage invoice — auto-charge in 5 days", message: "Card on file will be charged automatically.", }); ``` *** ## Analytics vs invoices: which do I want? Quick disambiguation since both expose "cost" numbers: | Use analytics | Use invoices | | ---------------------------------------------------- | -------------------------------------------------- | | "How much has this customer used so far this month?" | "What's the final bill I'm sending them?" | | Live projections, dashboards | Accounting, A/R, customer portals | | Raw usage data, no taxes/discounts | Finalized totals with taxes, discounts, prorations | | Current period, real-time | Completed billing periods | If the number needs to match what lands on the customer's credit card, use invoices. If it's a "heads up, here's what you're using" display, use analytics. # Matters (for law firms) Source: https://docs.marginfront.com/api-reference/matters Tie every AI dollar to the case it belongs to, then export a per-matter cost record that reconciles to the penny # Matters ## What a matter is A **matter** is the case or file a firm bills its work against. If your product serves law firms, matters let a firm answer one question for every dollar of AI cost: *which case does this belong to?* Once usage is tied to matters, a firm can pull a per-matter cost record it can hand a client or an auditor. Anything no matter claims stays visible in an **Unassigned** bucket, so nothing gets lost. You can manage matters on the Law tab in the dashboard, from your own code with the endpoints below, or in plain English through the [MCP tools](/mcp/tools). All three do the same work against the same numbers. *** ## How usage attaches to a matter Every usage event lands on at most one matter. There are three ways it can get there, and when more than one applies, the strongest wins: 1. **Manual assignment (strongest).** Someone puts the event on a matter by hand, in the dashboard or through the assign endpoint below. A manual choice always wins, and it's never overwritten by tags or rules later. 2. **A tag on the event.** When you record a usage event, add the matter's own case number as `metadata.matterId`. If a matter with that exact number exists, the event attaches to it. The tag is matched against the matter's **number** (the `matterNumber` you set), not its UUID. 3. **A routing rule (weakest).** A rule says "events for this agent code, this customer, or this signal go to this matter." Rules fill in the events you didn't tag. If none of the three apply, the event stays Unassigned until you assign it or a rule sweeps it up. > **Tagging at record time is the cleanest path.** When your agent already knows the case it's working on, send `metadata.matterId` with the usage event (see [Usage Events](/api-reference/usage-events)). The event lands on the right matter the moment it's recorded, with nothing to clean up later. An unknown tag is never dropped and never creates a matter on its own. The event simply falls through to the rules, and to Unassigned if no rule matches. *** ## Actual cost, never an estimate Every cost figure on a matter is the sum of that matter's real usage-event costs. It's never a guess. When an event's cost isn't resolved yet (its model isn't in the pricing table), that event adds **0** to the total and is counted separately in an `eventsMissingCost` field. A nonzero `eventsMissingCost` means the true cost is *higher* than the number shown, and it tells you exactly how many events to fix first. See [Usage Events](/api-reference/usage-events) for how to resolve an unpriced model. *** ## Who can manage matters Every endpoint on this page needs a **secret** key that belongs to an **owner**, **admin**, **finance**, or **legal** user. Any other key gets a `403 Forbidden` naming the role it needs. A publishable key (`mf_pk_...`) is refused. ### The legal key A **legal key** is a secret key scoped for exactly this surface, so a firm can hand it to its own agent without handing over the books. An owner or admin mints one under **Build → API keys**. A legal key can reach: * Every endpoint on this page. * The usage-recording surface: record events, `/v1/verify`, its own key info, and read-only [Spend Controls](./spend-controls). Everything else answers `403 Forbidden`: customers, invoices, usage events, analytics, pricing, and portal sessions. That limit is always on and enforced on the server. *** ## Matters ### List matters ``` GET /v1/matters ``` Returns your matters, each with its all-time actual AI cost and event count. | Query param | Type | Required | Notes | | ----------- | ------ | -------- | ------------------------------------------- | | `page` | number | No | Page number. Defaults to `1`. | | `limit` | number | No | Results per page (1-100). Defaults to `10`. | **Response (`200 OK`):** ```json theme={null} { "results": [ { "id": "a1b2c3d4-0000-0000-0000-000000000000", "matterNumber": "2026-0142", "name": "Acme v. Widgets", "clientName": "Acme Corp", "status": "open", "responsibleUserId": null, "createdAt": "2026-09-01T00:00:00.000Z", "updatedAt": "2026-09-01T00:00:00.000Z", "actualCost": 46.0, "eventCount": 14, "eventsMissingCost": 0 } ], "page": 1, "limit": 10, "totalPages": 1, "totalResults": 1 } ``` *** ### Create a matter ``` POST /v1/matters ``` **Request body:** ```json theme={null} { "matterNumber": "2026-0142", "name": "Acme v. Widgets", "clientName": "Acme Corp" } ``` | Field | Type | Required | Notes | | ------------------- | ------ | -------- | --------------------------------------------------------------------------------------------------------------- | | `matterNumber` | string | Yes | The firm's own case number, unique in your organization. This is the value events tag with `metadata.matterId`. | | `name` | string | Yes | The matter name, such as the case caption. | | `clientName` | string | Yes | The client this matter is billed to. | | `status` | string | No | `open` or `closed`. Defaults to `open`. | | `responsibleUserId` | string | No | The responsible timekeeper, by user UUID. | **Response (`201 Created`):** the created matter. ```json theme={null} { "id": "a1b2c3d4-0000-0000-0000-000000000000", "matterNumber": "2026-0142", "name": "Acme v. Widgets", "clientName": "Acme Corp", "status": "open", "responsibleUserId": null, "createdAt": "2026-09-01T00:00:00.000Z", "updatedAt": "2026-09-01T00:00:00.000Z" } ``` **Common errors:** * `409 Conflict`: a matter with that `matterNumber` already exists in your organization. *** ### Get one matter ``` GET /v1/matters/{id} ``` Returns the matter, its all-time cost and event count, and its 100 most recent events. The full record lives on the [audit endpoint](#the-audit-record) below; `eventsCap` tells you the cutoff. **Response (`200 OK`):** ```json theme={null} { "matter": { "id": "a1b2c3d4-0000-0000-0000-000000000000", "matterNumber": "2026-0142", "name": "Acme v. Widgets", "clientName": "Acme Corp", "status": "open", "responsibleUserId": null, "createdAt": "2026-09-01T00:00:00.000Z", "updatedAt": "2026-09-01T00:00:00.000Z" }, "actualCost": 46.0, "eventCount": 14, "eventsMissingCost": 0, "events": [ { "eventId": "e1000000-0000-0000-0000-000000000000", "usageDate": "2026-09-05T12:00:00.000Z", "agentName": "Research Bot", "signalName": "documents_reviewed", "quantity": 1, "usageCost": 5.0, "source": "tag" } ], "eventsCap": 100 } ``` An event's `usageCost` is `null` when its cost isn't resolved yet. `source` is `tag`, `rule`, `manual`, or `null`. **Common errors:** * `404 Not Found`: no matter with that ID in your organization. *** ### Update a matter ``` PATCH /v1/matters/{id} ``` Only the fields you send change. Send `responsibleUserId: null` to clear the responsible timekeeper. Closing a matter (`status: "closed"`) is a label only. It doesn't stop anything at record time. **Response (`200 OK`):** the updated matter, same shape as create. **Common errors:** * `404 Not Found`: no matter with that ID in your organization. * `409 Conflict`: the new `matterNumber` is already used by another matter. *** ### Delete a matter ``` DELETE /v1/matters/{id} ``` Deleting a matter doesn't delete its usage events. They survive and return to Unassigned, where a rule or a manual assignment can pick them up again. The matter's routing rules are deleted with it. The response counts both. **Response (`200 OK`):** ```json theme={null} { "deleted": true, "eventsReturnedToUnassigned": 14, "routingRulesDeleted": 2 } ``` **Common errors:** * `404 Not Found`: no matter with that ID in your organization. *** ## Unassigned events ### List unassigned events ``` GET /v1/matters/unassigned ``` Returns the usage events no matter has claimed. `totalResults` is the true all-time count of unassigned events (it's never limited to the page or a date window), and `totalCost` is the cost of the whole unassigned set. | Query param | Type | Required | Notes | | ----------- | ------ | -------- | ------------------------------------------- | | `page` | number | No | Page number. Defaults to `1`. | | `limit` | number | No | Results per page (1-100). Defaults to `10`. | **Response (`200 OK`):** ```json theme={null} { "results": [ { "eventId": "e2000000-0000-0000-0000-000000000000", "usageDate": "2026-09-06T09:30:00.000Z", "agentName": "Intake Bot", "signalName": "messages", "quantity": 3, "usageCost": 0.75, "source": null } ], "page": 1, "limit": 10, "totalPages": 3, "totalResults": 25, "totalCost": 25.0, "eventsMissingCost": 0 } ``` *** ### Assign an event to a matter ``` POST /v1/matters/assign ``` Puts one usage event on a matter by hand, or pulls it off. A manual assignment outranks a tag or a rule, and a manual unassignment is never swept back up by the apply-rules pass. **Request body:** ```json theme={null} { "eventId": "e2000000-0000-0000-0000-000000000000", "matterId": "a1b2c3d4-0000-0000-0000-000000000000" } ``` | Field | Type | Required | Notes | | ---------- | ------ | -------- | ---------------------------------------------------------- | | `eventId` | string | Yes | The usage event to move, by UUID. | | `matterId` | string | Yes | The target matter's UUID, or `null` to unassign the event. | **Response (`201 Created`):** ```json theme={null} { "assigned": true, "eventId": "e2000000-0000-0000-0000-000000000000", "matterId": "a1b2c3d4-0000-0000-0000-000000000000", "source": "manual" } ``` **Common errors:** * `404 Not Found`: the event or the target matter isn't in your organization. *** ## Routing rules A **routing rule** attaches events to a matter automatically. A rule matches an event when **every** matcher it sets matches: `agentCode` exactly, `customerExternalId` exactly, and `signalPattern` as a case-insensitive substring of the signal name. A rule needs at least one matcher. Rules run in **evaluation order**: lowest `priority` first, and older rules before newer ones on a tie. The first rule that matches wins. ### List routing rules ``` GET /v1/matter-rules ``` Returns your rules in the exact order they're evaluated at record time and when you apply them. | Query param | Type | Required | Notes | | ----------- | ------ | -------- | ------------------------------------------- | | `page` | number | No | Page number. Defaults to `1`. | | `limit` | number | No | Results per page (1-100). Defaults to `10`. | **Response (`200 OK`):** ```json theme={null} { "results": [ { "id": "r1000000-0000-0000-0000-000000000000", "matterId": "a1b2c3d4-0000-0000-0000-000000000000", "matterNumber": "2026-0142", "matterName": "Acme v. Widgets", "priority": 100, "agentCode": "research-bot", "customerExternalId": null, "signalPattern": null } ], "page": 1, "limit": 10, "totalPages": 1, "totalResults": 1 } ``` *** ### Create a routing rule ``` POST /v1/matter-rules ``` **Request body:** ```json theme={null} { "matterId": "a1b2c3d4-0000-0000-0000-000000000000", "agentCode": "research-bot" } ``` | Field | Type | Required | Notes | | -------------------- | ------ | -------- | --------------------------------------------------------------- | | `matterId` | string | Yes | The matter this rule assigns events to, by UUID. | | `priority` | number | No | Lower runs first. Defaults to `100`. | | `agentCode` | string | No | Match this exact agent code. | | `customerExternalId` | string | No | Match this exact customer external ID. | | `signalPattern` | string | No | Match this text anywhere in the signal name (case-insensitive). | At least one of `agentCode`, `customerExternalId`, or `signalPattern` is required. **Response (`201 Created`):** the rule, including its matter's number and name. **Common errors:** * `400 Bad Request`: no matcher was provided. * `404 Not Found`: the target matter isn't in your organization. *** ### Apply routing rules ``` POST /v1/matter-rules/apply ``` Sweeps your rules across events that are still Unassigned, in priority order. It touches **only** events with no matter and no attribution history, so it never overrides a tag, an earlier rule, or a manual choice. **Response (`201 Created`):** ```json theme={null} { "assigned": 12 } ``` If the sweep stops partway, the response says how many events were assigned before it stopped. Run it again to finish the rest. *** ### Update a routing rule ``` PATCH /v1/matter-rules/{id} ``` Only the fields you send change. Send a matcher as `null` to clear it. A rule must always keep at least one matcher. **Response (`200 OK`):** the updated rule. **Common errors:** * `404 Not Found`: no rule with that ID in your organization. *** ### Delete a routing rule ``` DELETE /v1/matter-rules/{id} ``` Deleting a rule stops future matching only. Events the rule already assigned keep their matter. **Response (`200 OK`):** ```json theme={null} { "deleted": true } ``` **Common errors:** * `404 Not Found`: no rule with that ID in your organization. *** ## The audit record ### Export the matter audit ``` GET /v1/matters/audit ``` This is the record a firm shows a client or a carrier: the actual AI cost per matter over a window. The default `summary` view is one row per matter, plus an **Unassigned** row, plus organization totals. It reconciles exactly with the Law tab CSV export for the same window. | Query param | Type | Required | Notes | | ----------- | ------ | -------- | ----------------------------------------------------------------------------------------------------- | | `start` | string | No | Window start, as `YYYY-MM-DD` (start of day UTC) or a full ISO timestamp. Defaults to 30 days back. | | `end` | string | No | Window end, as `YYYY-MM-DD` (end of day UTC) or a full ISO timestamp. Capped at now. Defaults to now. | | `format` | string | No | `summary` (default) or `detail`. `detail` adds one row per event. | | `matterId` | string | No | Slice to one matter, by UUID. A sliced view has no Unassigned row and no organization totals. | **Response (`200 OK`, `summary`):** ```json theme={null} { "periodStart": "2026-08-11T00:00:00.000Z", "periodEnd": "2026-09-10T23:59:59.999Z", "summary": [ { "matterNumber": "2026-0142", "matterName": "Acme v. Widgets", "clientName": "Acme Corp", "cost": 21.0, "eventCount": 5, "eventsMissingCost": 0 }, { "matterNumber": null, "matterName": null, "clientName": null, "cost": 25.0, "eventCount": 9, "eventsMissingCost": 0 } ], "totalCost": 46.0, "totalEvents": 14, "eventsMissingCost": 0 } ``` The row with a `null` `matterNumber` is the Unassigned row. It's present whenever unassigned events fall in the window. When you pass `format=detail`, the response also carries a `rows` array, one entry per event, each with its matter, date, agent, signal, the models used, cost, quantity, and attribution `source`. Costs that aren't resolved yet count as `0` in every total and are flagged in `eventsMissingCost`. They're never invented, so the number you hand an auditor is honest. *** ## A quick pattern A firm that wants clean records from day one usually does this: 1. Create a matter for each open case, using the firm's own case number as `matterNumber`. 2. Tag each usage event with `metadata.matterId` set to that case number, so events land on the right matter as they're recorded. 3. Add a routing rule or two for the work that's hard to tag at the source. 4. Check the Unassigned list now and then, and assign anything left over by hand. 5. Export the audit for a billing window when it's time to bill. Steps 1 through 4 keep the record accurate. Step 5 turns it into a number a client can trust. # Portal Sessions Source: https://docs.marginfront.com/api-reference/portal-sessions Send your customer a one-time link to their own billing portal # Portal Sessions ## What is a portal session? A **portal session** is a one-time link you send to your customer so they can see their own bill, invoices, subscription, and usage history on a MarginFront-hosted page. Think of it like a Google Doc share link, but for billing. You generate the link on your backend with your secret API key. Your customer clicks it once. They see only their own data. The link works for one hour, then expires. ## Why use it? * Let your customers see their invoices and pay (or follow up) without bothering your support team. * Show them their usage and what they're being billed for, so there are no surprises. * Keep your API key safe. Your customer never sees it. *** ## How it works 1. You call `POST /v1/portal-sessions` from your backend. 2. MarginFront returns a URL like `https://portal.marginfront.com/r/mgl_abc123...`. 3. You send that URL to your customer (email, SMS, "View my billing" button in your app, whatever fits). 4. They click it. The link is used up. 5. They see a MarginFront page branded with your logo and colors showing their invoices, subscription, usage, and profile. 6. After one hour, they're logged out. 7. If anyone tries the same link a second time, they get a "link already used" error. *** ## The endpoints All four endpoints require a **secret** API key (`mf_sk_...`). Publishable keys (`mf_pk_...`) are rejected with `401 Unauthorized`. Always call these from your backend, never from the browser. ### Create a portal session ``` POST /v1/portal-sessions ``` **Request body:** ```json theme={null} { "customerExternalId": "acme-001", "returnUrl": "https://your-app.com/billing" } ``` | Field | Type | Required | Notes | | -------------------- | --------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `customerExternalId` | string | One of these two | Your system's customer ID (the same one you use everywhere else with MarginFront). | | `customerId` | string | One of these two | MarginFront's internal customer UUID. Use this if you already have it. | | `returnUrl` | string | No | Stored on the session for your records. The portal does NOT auto-redirect; this is informational. | | `features` | string\[] | No | Which sections to enable. Valid values: `invoices`, `subscriptions`, `usage`, `profile`. The v1 portal shows all four sections regardless of what you send, so this field is informational only. | **Response (`201 Created`):** ```json theme={null} { "id": "ps_a1b2c3d4...", "object": "portal_session", "url": "https://portal.marginfront.com/r/mgl_a1b2c3d4...", "token": "mgl_a1b2c3d4...", "customerId": "550e8400-e29b-41d4-a716-446655440000", "customerName": "Acme Corp", "customerEmail": "billing@acme.com", "expiresAt": "2026-05-13T17:30:00.000Z", "createdAt": "2026-05-13T16:30:00.000Z" } ``` Give the `url` to your customer. Save the `id` if you want to look up or revoke the session later. That's the only field you'll need. **curl example:** ```bash theme={null} curl -X POST https://api.marginfront.com/v1/portal-sessions \ -H "x-api-key: mf_sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "customerExternalId": "acme-001", "returnUrl": "https://your-app.com/billing" }' ``` **Common errors:** * `400 Bad Request`: neither `customerId` nor `customerExternalId` was provided. * `401 Unauthorized`: missing API key, or you sent a publishable (`mf_pk_*`) key. * `404 Not Found`: no customer in your org matches the ID you sent. *** ### List portal sessions ``` GET /v1/portal-sessions ``` Lists portal sessions your organization has created. Useful for audit or support. For example: "did Acme already open the link I sent yesterday?" **Query parameters (all optional):** | Param | Type | Default | Notes | | ---------------- | ------- | ------- | ----------------------------------------------------------- | | `customerId` | string | none | Show only sessions for one customer (internal UUID). | | `limit` | number | 10 | Max 100. | | `includeExpired` | boolean | false | Set to `true` to also see expired or already-used sessions. | The `token` and `url` fields are NOT returned by this endpoint. Those are only returned at creation. If you lost the URL, mint a new session. **Response (`200 OK`):** ```json theme={null} [ { "id": "ps_a1b2c3d4...", "object": "portal_session", "customerId": "550e8400-e29b-41d4-a716-446655440000", "customerName": "Acme Corp", "customerEmail": "billing@acme.com", "expiresAt": "2026-05-13T17:30:00.000Z", "isExpired": false, "isUsed": false, "createdAt": "2026-05-13T16:30:00.000Z", "features": ["invoices", "subscriptions", "usage", "profile"] } ] ``` The response is a flat JSON array. There is no wrapper object and no `hasMore` field. *** ### Get one portal session ``` GET /v1/portal-sessions/{sessionId} ``` Returns one session's details: customer, expiry, whether it's been used yet. The `token` and `url` are NOT included. You only get those at creation time. Returns `404 Not Found` if the session doesn't exist or belongs to another organization. **Response (`200 OK`):** ```json theme={null} { "id": "ps_a1b2c3d4...", "object": "portal_session", "customerId": "550e8400-e29b-41d4-a716-446655440000", "customerName": "Acme Corp", "customerEmail": "billing@acme.com", "expiresAt": "2026-05-13T17:30:00.000Z", "isExpired": false, "isUsed": true, "usedAt": "2026-05-13T16:45:12.000Z", "createdAt": "2026-05-13T16:30:00.000Z", "features": ["invoices", "subscriptions", "usage", "profile"] } ``` *** ### Revoke a portal session ``` DELETE /v1/portal-sessions/{sessionId} ``` Immediately invalidates the link. Use this if you sent a link to the wrong customer or your support team needs to cut access early. Returns `204 No Content` on success, `404 Not Found` if the session doesn't exist. *** ## Security notes * **Always mint links on your backend.** Portal sessions require a secret key. Treat that key like a database password. * **One link, one click.** The link is consumed on first open. Anyone (including the original customer) who tries it a second time gets an error. * **Tokens are only shown once.** The `token` and `url` fields are returned only by `create`. Lost URLs cannot be retrieved; revoke and mint a new session. * **Need to cut access early?** Use the `revoke` endpoint. It works immediately. * **Revoking a secret key ends every live portal link your org has issued.** When you revoke a secret API key (for rotation or after a leak), MarginFront expires all of your organization's live portal sessions and one-time links at the same moment — not only the ones created with that key. Any customer holding an open link lands on the "link expired, request a new one" page, so mint them a fresh link. Revoking a publishable key changes nothing here, since publishable keys can't mint portal links. # Pricing Plans Source: https://docs.marginfront.com/api-reference/pricing-plans Set up how much you charge per unit of each signal # Pricing Plans A **pricing plan** defines how you charge for an agent's work. It's the answer to "how much does this cost?" for everything that agent does. You create a pricing plan once, then attach it to a customer via a subscription. Pricing plans belong to your organization and can be linked to one or more agents. The same "Pro Plan" can be offered by multiple agents — no need to recreate it for each one. > **Next step after creating a plan:** Add pricing strategies — the actual rates, tiers, and charge types that determine what customers pay. See the [Pricing Strategies](/api-reference/pricing-strategies) reference for the full API. *** ## Create a pricing plan **In plain English:** Create a new pricing plan for your organization. Think of it like defining a "Pro Plan" or "Starter Plan" tier. Optionally link it to an agent right away. **Method & URL:** ``` POST /v1/pricing-plans ``` **Required fields:** * `name` *(string, max 255 chars)* — The plan's name. Shows up in the dashboard and on invoices. Examples: `"Starter Plan"`, `"Pro Plan"`, `"Enterprise Custom"`. **Optional fields:** * `agentId` *(UUID string)* — An agent to link this plan to. If provided, the plan is immediately available for that agent. You can link more agents later via `POST /v1/pricing-plans/:id/agents`. * `description` *(string, max 255 chars)* — Longer explanation of what's in this plan. Useful internally. * `featuresList` *(object)* — Structured list of features this plan includes. Free-form, MarginFront stores but doesn't interpret. Example: `{ "features": ["API Access", "Advanced Analytics", "Priority Support"] }`. * `isActive` *(boolean, default `true`)* — Whether this plan is currently offered. Set to `false` to retire a plan without deleting it (keeps historical subscription data intact). **Example curl call:** ```bash theme={null} curl -X POST https://api.marginfront.com/v1/pricing-plans \ -H "x-api-key: mf_sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "name": "Pro Plan", "description": "Professional tier for mid-market customers", "agentId": "b47e12fa-abcd-4567-8901-234567890abc", "featuresList": { "features": ["API Access", "Advanced Analytics", "Priority Support"] } }' ``` **What you get back (`201 Created`):** ```json theme={null} { "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab", "name": "Pro Plan", "description": "Professional tier for mid-market customers", "isActive": true, "agents": [ { "id": "b47e12fa-abcd-4567-8901-234567890abc", "name": "Outreach Writer", "agentCode": "outreach-writer" } ], "createdAt": "2026-04-10T14:30:00.000Z" } ``` **Common errors:** * **`400 Bad Request`** — Missing `name`, or `agentId` isn't a valid UUID. * **`404 Not Found`** — The `agentId` doesn't match any agent in this org. **When to call this:** Once per pricing tier, during product setup. You don't create a new plan per customer — you create the plan once, then subscribe many customers to it. *** ## Link a plan to an agent **In plain English:** Make an existing plan available through a different agent. The plan itself doesn't change — it just shows up in another agent's list of available plans. **Method & URL:** ``` POST /v1/pricing-plans/{planId}/agents ``` **Required fields:** * `agentId` *(UUID string)* — The agent to link this plan to. **Example curl call:** ```bash theme={null} curl -X POST https://api.marginfront.com/v1/pricing-plans/a1b2c3d4-.../agents \ -H "x-api-key: mf_sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "agentId": "c58f23ab-..." }' ``` **Common errors:** * **`404 Not Found`** — Plan or agent not found in this org. * **`409 Conflict`** — Plan is already linked to this agent. *** ## Unlink a plan from an agent **In plain English:** Remove a plan from an agent's available plans. The plan still exists — it's just no longer offered by this agent. Fails if there are active subscriptions using this plan + agent combination. **Method & URL:** ``` DELETE /v1/pricing-plans/{planId}/agents/{agentId} ``` **Common errors:** * **`400 Bad Request`** — Active subscriptions exist for this plan + agent. Cancel them first. * **`404 Not Found`** — Plan not found in this org. *** ## Read a pricing plan **Method & URL:** ``` GET /v1/pricing-plans/{planId} ``` *** ## List pricing plans **Method & URL:** ``` GET /v1/pricing-plans ``` **Useful query parameters** (check the controller for the full list): * `agentId` — filter to plans linked to a specific agent * `isActive` — filter to only active/inactive plans *** ## Update a pricing plan **Method & URL:** ``` PATCH /v1/pricing-plans/{planId} ``` **Fields:** `name`, `description`, `featuresList`, `isActive`. All optional. > **Note:** To change which agents a plan is linked to, use the link/unlink endpoints above — not PATCH. > **Heads up:** Changing pricing on a plan that has active subscriptions usually does NOT retroactively re-price past usage. It only affects billing periods that haven't been invoiced yet. If you need to correct historical billing, that's a separate manual process — talk to the team. *** ## Delete a pricing plan **Method & URL:** ``` DELETE /v1/pricing-plans/{planId} ``` **What it does:** Permanently removes the plan. Any active subscriptions on this plan must be canceled first — otherwise the delete fails. > **Prefer `isActive: false` over delete.** Deactivating a plan stops new subscriptions while keeping all the history intact. Deletion is for cleaning up mistakes. *** ## Copy a pricing plan **Method & URL:** ``` POST /v1/pricing-plans/{planId}/copy ``` **When to use it:** If you have a working plan and want to create a variation (e.g., an Enterprise version of your Pro plan with the same structure but different numbers), copying is faster than rebuilding from scratch. The copy includes all the plan's current pricing rules and agent links. *** ## Using the Node SDK ```typescript theme={null} import { MarginFrontClient } from "@marginfront/sdk"; const mf = new MarginFrontClient("mf_sk_..."); // Create a plan const plan = await mf.pricingPlans.create({ name: "Pro Plan" }); // Link it to an agent await mf.pricingPlans.linkAgent(plan.id, "agent_uuid"); // Add pricing strategies (see Pricing Strategies reference) await mf.pricingStrategies.createBulk(plan.id, [ { name: "API Calls", agentId: "...", chargeType: "usage", pricingModel: "credit_pool", signalId: "...", tiers: [ { lower: 0, upper: 5000, rate: 99 }, { lower: 5000, upper: null, rate: 0.03 }, ], }, { name: "Platform Fee", agentId: "...", chargeType: "recurring", rate: 49 }, ]); // List, update, copy, delete const { results } = await mf.pricingPlans.list({ isActive: true }); await mf.pricingPlans.update(plan.id, { description: "Updated" }); const yearly = await mf.pricingPlans.copy(plan.id, { newName: "Pro Yearly", discount: 20, }); ``` *** ## How pricing plans fit into the whole billing chain ``` Organization | +---- Pricing plan ----+---- Agent A (via agent_pricing_plans link) (how to price) +---- Agent B (same plan, different agent) | Subscription ---- Customer (assigns plan + agent to customer) | Usage event (counted against the subscription, priced by the plan) ``` Quick summary: 1. Create an **agent** (the service/product) 2. Create one or more **pricing plans** for your org (Starter, Pro, Enterprise) 3. **Link** plans to agents — the same plan can be linked to multiple agents 4. When a customer signs up, create a **subscription** tying that customer to an agent + plan 5. As the customer uses the product, log **usage events** — they get priced according to the customer's subscribed plan, and rolled up into an invoice at the end of each billing period The pricing plan is the "rulebook" that sits in the middle. # Pricing Strategies Source: https://docs.marginfront.com/api-reference/pricing-strategies Configure the actual pricing rules inside a plan — charge types, models, tiers, and rates # Pricing Strategies A **pricing strategy** is the actual pricing rule inside a plan. If a pricing plan is the "Pro Plan" container, strategies are the line items: "$0.01 per API call," "$49/month platform fee," "5,000 calls for $99 then $0.03 overage." A plan can have multiple strategies. Most plans combine a few: * A **usage** strategy for per-event billing (tied to a signal) * A **recurring** strategy for a fixed monthly fee * A **onetime** strategy for a setup fee * A **seat\_based** strategy for per-user pricing Each strategy has a **charge type** (what kind of charge) and a **pricing model** (how the math works). *** ## Charge types | Charge type | What it means | Example | | ------------ | --------------------------------------------- | ----------------------- | | `usage` | Pay per event/unit. Requires a linked signal. | \$0.01 per API call | | `recurring` | Fixed fee every billing cycle | \$49/month platform fee | | `onetime` | One-time fee, charged on first invoice only | \$500 setup fee | | `seat_based` | Per-seat/per-user pricing | \$10/seat/month | *** ## Pricing models The pricing model determines how the rate math works for `usage` and `seat_based` strategies. ### Flat Every unit costs the same price. Simple multiplication. ``` quantity × rate = total 1,000 calls × $0.01 = $10 ``` ### Graduated Different rates for different quantity ranges — like tax brackets. Each range is charged at its own rate. ``` Tier 1: 0–1,000 @ $0.010/call = $10.00 Tier 2: 1,001–5,000 @ $0.008/call = $32.00 Tier 3: 5,001+ @ $0.005/call = varies 1,200 calls → (1,000 × $0.01) + (200 × $0.008) = $11.60 ``` ### Volume Total quantity determines ONE rate for ALL units. The more you use, the cheaper each unit gets — but the rate applies to everything, not just the overflow. ``` Tier 1: 0–999 @ $0.010/call Tier 2: 1,000–4,999 @ $0.008/call Tier 3: 5,000+ @ $0.005/call 1,200 calls → 1,200 × $0.008 = $9.60 (all at Tier 2 rate) ``` ### Credit Pool Flat fee for a pool of units, then per-unit overage. You pay the pool price whether you use 1 unit or all of them. Only after exceeding the pool does the overage rate kick in. ``` Pool: 5,000 calls for $99 flat Overage: $0.03/call after that 3,000 calls → $99 (pool covers it) 6,000 calls → $99 + $30 (1,000 overage × $0.03) ``` **Tier layout for credit pool:** ```json theme={null} [ { "lower": 0, "upper": 5000, "rate": 99 }, { "lower": 5000, "upper": null, "rate": 0.03 } ] ``` The first tier's `rate` is the **flat pool price** (not per-unit). The second tier's `rate` is the **per-unit overage rate**. That asymmetry is easy to get backwards, so you don't have to build it. Send a `creditPool` block instead and the server compiles the tiers for you: ```json theme={null} { "creditPool": { "poolSize": 5000, "poolPrice": 99, "overageRate": 0.03 } } ``` `poolSize` is how many units the pool covers each cycle, `poolPrice` is the flat fee charged every cycle whether or not it's used, and `overageRate` is the per-unit price after the pool runs out. Nothing stops when the pool empties; the overage keeps billing. Every strategy you read back also carries a derived `creditPool` field with the same three numbers (`null` on non-pool strategies, and on any pool whose stored tiers aren't a clean two-tier shape). ### Letting several metrics share one pool (`creditRates`) By default a pool counts one signal: the one you priced, at one credit per unit. `creditRates` opens the pool to other signals and says how many credits each one costs: ```json theme={null} { "creditRates": { "SIGNAL_REPORT_ID": 4, "SIGNAL_VIDEO_ID": 10, "SIGNAL_NOTE_ID": 0.5 } } ``` One report takes 4 credits out of the pool, one video takes 10, and two notes take 1. All of them draw the same balance, so the customer still watches one number. The pool bills off the combined credit total, and a member signal never gets its own separate line on the invoice. Rules, each of them a `400` with a message naming the fix: * **Every value must be a number above zero.** To make a metric free, leave it out of the map. A `0` is refused rather than honored, because it usually means a field someone forgot to fill in. * **Every key must be a live signal in your organization.** A mistyped or deleted ID is refused instead of sitting in the map matching nothing. * **A signal can burn pool credits or bill per unit, never both.** If a key names a signal that also has its own active `usage` strategy on the same plan, the write is refused: the customer would pay twice for one unit of work. This is checked against the whole plan, so turning on a competing usage strategy later is refused the same way. * **The pool's own signal is a member whether or not you name it**, at rate 1. Name it only to change that. * **`creditRates` only applies when `pricingModel` is `credit_pool`.** Clear the rates (`creditRates: {}`) in the same update if you're moving the strategy to another model. * **Send `{}` to clear every rate. Omit the field to leave the stored rates alone.** A pool with no `creditRates` behaves exactly as it always has. The balance fields keep their `...Units` names either way; once rates are on, read "unit" as "credit." ### Credit-pool validation rules The server validates every write that touches a credit pool (create, update, and both bulk lanes), evaluated against the merged result of what's stored plus your patch. Each rejection comes back as a `400` with a plain-English message naming the fix: * **`creditPool` needs all three numbers.** A partial block (say, `poolSize` without `overageRate`) is rejected, with the missing fields named. * **The numbers must make sense.** `poolSize` above zero; `poolPrice` and `overageRate` zero or more. * **Send `creditPool` OR `tiers`, never both.** Sending both would mean one of them is silently ignored, so the server refuses instead. * **`creditPool` only applies when `pricingModel` is `credit_pool`.** * **One active credit pool per plan.** A write that would leave a plan with two live pools is rejected. The ledger counts down ONE pool per plan, so a second pool would make the countdown ambiguous. Put it on its own plan. * **No `minimumCommitment` on a credit pool.** The pool fee already is the minimum the customer pays every cycle. * **Hand-built tiers are shape-checked too.** The pool tier must start at 0 with an upper bound above zero; the overage tier must start exactly where the pool ends and have no ceiling (`upper: null`). *** ## Create pricing strategies (bulk) **In plain English:** Add one or more pricing strategies to a plan. You can create all your strategies in one call — send an array. **Method & URL:** ``` POST /v1/pricing-plans/{planId}/pricing-strategies ``` The request body is an **array** of strategies (even if you're only creating one). **Required fields per strategy:** * `name` *(string)* — Strategy name, e.g. "API Call Pool" * `agentId` *(UUID)* — Which agent this strategy belongs to * `chargeType` *(string)* - One of: `usage`, `recurring`, `onetime`, `seat_based` **Optional fields:** * `signalId` *(UUID)* — Required if chargeType is `usage`. The signal this strategy prices. * `pricingModel` *(string)* - One of: `flat`, `graduated`, `volume`, `credit_pool`. Required for `usage` and `seat_based`. * `billingFrequency` *(string)* — `monthly` or `yearly` (default: monthly) * `tiers` *(array)* — Tier configuration. Each tier has `lower` (number), `upper` (number or null), `rate` (number). * `creditPool` *(object)* - The credit-pool shortcut: `{ poolSize, poolPrice, overageRate }`. Only valid when `pricingModel` is `credit_pool`. Compiled into `tiers` server-side; send this OR `tiers`, never both. * `creditRates` *(object)* - Which signals draw from this pool and how many credits one unit of each costs: `{ signalId: creditsPerUnit }`. Only valid when `pricingModel` is `credit_pool`. Omit it for a single-metric pool. See [Letting several metrics share one pool](#letting-several-metrics-share-one-pool-creditrates). * `rate` *(number)* — Flat rate. Used when pricingModel is `flat` or there are no tiers. * `minimumCommitment` *(number)* — Minimum units/seats to bill for, even if actual usage is lower. Not allowed on `credit_pool` strategies (the pool fee already is the minimum). * `active` *(boolean, default true)* — Whether this strategy is active. * `tags` *(string\[])* — Tags for categorization. **Example — create three strategies in one call:** ```bash theme={null} curl -X POST https://api.marginfront.com/v1/pricing-plans/PLAN_ID/pricing-strategies \ -H "x-api-key: mf_sk_test_..." \ -H "Content-Type: application/json" \ -d '[ { "name": "API Call Pool", "agentId": "AGENT_ID", "chargeType": "usage", "pricingModel": "credit_pool", "signalId": "SIGNAL_ID", "creditPool": { "poolSize": 5000, "poolPrice": 99, "overageRate": 0.03 } }, { "name": "Platform Fee", "agentId": "AGENT_ID", "chargeType": "recurring", "rate": 49 }, { "name": "Setup Fee", "agentId": "AGENT_ID", "chargeType": "onetime", "rate": 500 } ]' ``` **What you get back (`201 Created`):** Array of created strategies with IDs. **Using the SDK:** ```typescript theme={null} const strategies = await client.pricingStrategies.createBulk("PLAN_ID", [ { name: "API Call Pool", agentId: "AGENT_ID", chargeType: "usage", pricingModel: "credit_pool", signalId: "SIGNAL_ID", creditPool: { poolSize: 5000, poolPrice: 99, overageRate: 0.03 }, }, { name: "Platform Fee", agentId: "AGENT_ID", chargeType: "recurring", rate: 49, }, ]); ``` For a credit pool on its own there's a one-call shortcut that fills in the charge type and pricing model for you: ```typescript theme={null} const strategy = await client.pricingStrategies.createCreditPool("PLAN_ID", { name: "API Call Pool", agentId: "AGENT_ID", signalId: "SIGNAL_ID", poolSize: 5000, poolPrice: 99, overageRate: 0.03, }); ``` **Common errors:** * **`400 Bad Request`** — Missing required fields, or `signalId` not provided for a `usage` strategy. Also every credit-pool validation rejection (partial `creditPool` block, both `creditPool` and `tiers` sent, a second active pool on the plan, `minimumCommitment` on a pool, a degenerate tier shape, a `creditRates` value at or below zero, a `creditRates` key that isn't a live signal, or a signal that would both burn credits and bill per unit); the message names the exact rule and the fix. * **`404 Not Found`** — Plan, agent, or signal not found in this org. *** ## List pricing strategies **Method & URL:** ``` GET /v1/pricing-plans/{planId}/pricing-strategies ``` **Query parameters:** * `chargeType` - Filter by charge type (`usage`, `recurring`, `onetime`, `seat_based`) * `pricingModel` — Filter by pricing model (`flat`, `graduated`, `volume`, `credit_pool`) * `active` — Filter by active status (`true` or `false`) * `page`, `limit` — Pagination (default: page 1, limit 10) **Using the SDK:** ```typescript theme={null} const { results } = await client.pricingStrategies.list("PLAN_ID", { chargeType: "usage", active: true, }); ``` *** ## Get a pricing strategy **Method & URL:** ``` GET /v1/pricing-plans/{planId}/pricing-strategies/{strategyId} ``` **Using the SDK:** ```typescript theme={null} const strategy = await client.pricingStrategies.get("PLAN_ID", "STRATEGY_ID"); ``` *** ## Update a pricing strategy **Method & URL:** ``` PATCH /v1/pricing-plans/{planId}/pricing-strategies/{strategyId} ``` All fields are optional. Only send what you want to change. **Example — change the overage rate on a credit pool strategy:** ```bash theme={null} curl -X PATCH https://api.marginfront.com/v1/pricing-plans/PLAN_ID/pricing-strategies/STRATEGY_ID \ -H "x-api-key: mf_sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "tiers": [ { "lower": 0, "upper": 5000, "rate": 99 }, { "lower": 5000, "upper": null, "rate": 0.05 } ] }' ``` **Using the SDK:** ```typescript theme={null} const updated = await client.pricingStrategies.update( "PLAN_ID", "STRATEGY_ID", { tiers: [ { lower: 0, upper: 5000, rate: 99 }, { lower: 5000, upper: null, rate: 0.05 }, ], }, ); ``` *** ## Bulk update + create **In plain English:** Update existing strategies and create new ones in a single call. Useful when reconfiguring an entire plan. **Method & URL:** ``` PATCH /v1/pricing-plans/{planId}/pricing-strategies ``` **Request body:** ```json theme={null} { "strategies": { "new": [ { "name": "New Fee", "agentId": "...", "chargeType": "recurring", "rate": 29 } ], "update": [{ "id": "EXISTING_ID", "rate": 59 }] } } ``` *** ## Delete a pricing strategy **Method & URL:** ``` DELETE /v1/pricing-plans/{planId}/pricing-strategies/{strategyId} ``` Soft-delete (sets `deletedAt`, doesn't destroy data). **Using the SDK:** ```typescript theme={null} await client.pricingStrategies.delete("PLAN_ID", "STRATEGY_ID"); ``` *** ## How strategies fit in the billing chain ``` Pricing Plan ("Pro Plan") | +---- Strategy: "API Call Pool" (usage, credit_pool) | └── Signal: "api_calls" +---- Strategy: "Platform Fee" (recurring, $49/mo) +---- Strategy: "Setup Fee" (onetime, $500) +---- Strategy: "Seat License" (seat_based, flat, $10/seat) | Subscription (assigns this plan to a customer + agent) | Invoice (sums all strategy charges for the billing period) ``` # Quickstart Source: https://docs.marginfront.com/api-reference/quickstart Fire your first usage event in under a minute. No dashboard setup required. # Quickstart: fire your first event in under a minute > **You don't need to set anything up first.** When you fire an event with a new `customerExternalId`, `agentCode`, or `signalName`, MarginFront creates the customer, agent, or signal automatically. Your existing user IDs from your own database flow straight through. The dashboard updates the moment the event lands. This guide takes you from zero to a real usage event visible in the dashboard. The first three steps are all you need for cost tracking. Step 4 is optional for revenue tracking and invoicing. We use plain curl so the examples work regardless of language. The Node SDK and MCP versions are at the bottom. Every curl example on this page comes in two versions: **Bash** (macOS, Linux, WSL, Git Bash) and **PowerShell** (Windows). Pick the tab that matches your terminal. The two are not interchangeable, and copying the Bash version into PowerShell is the most common cause of a 401 with a perfectly good key. Commands that are identical in both shells, like `npm install`, appear once. *** ## 1. Get an API key In the MarginFront dashboard, go to **Build → API keys** and create a key pair (or use a secret key you already saved). Copy the secret key (looks like `mf_sk_test_...`) the moment it's shown — you can't see the full value again after the first time. Put it in an environment variable: ```bash Bash theme={null} export MF_API_SECRET_KEY="mf_sk_your_key_here" ``` ```powershell PowerShell theme={null} $env:MF_API_SECRET_KEY = "mf_sk_your_key_here" ``` > **Windows: the `$env:` prefix is not optional.** PowerShell reads `$MF_API_SECRET_KEY` as a variable it has never heard of, substitutes an empty string, and sends a request with no key. The API answers `401`, which reads like a bad key even though the key is fine. Write `$env:MF_API_SECRET_KEY` every time you reference it. The variable lives only in the terminal window you set it in. Open a new window and you set it again. To make it permanent on Windows, use **System Properties → Environment Variables**, or run `setx MF_API_SECRET_KEY "mf_sk_your_key_here"` once and open a new terminal. The API key alone identifies your organization. You don't need to pass an org ID anywhere. The production API base URL is `https://api.marginfront.com`. All examples below use it. *** ## 2. Fire your first usage event This is the only call you need to integrate MarginFront. Three of the fields use IDs from your own system. MarginFront creates the customer, agent, and signal on the spot if they don't exist yet. > **You invent these three IDs. Nothing has to exist in MarginFront first.** `test_user_001`, `report_writer`, and `report_generated` below are values made up for this example. Send whatever strings you like, as long as you keep using the same ones for the same customer, agent, and signal. The records get created on the first event that mentions them. ```bash Bash theme={null} curl -X POST https://api.marginfront.com/v1/usage/record \ -H "x-api-key: $MF_API_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "records": [ { "customerExternalId": "test_user_001", "agentCode": "report_writer", "signalName": "report_generated", "model": "gpt-4o", "modelProvider": "openai", "inputTokens": 523, "outputTokens": 117 } ] }' ``` ```powershell PowerShell theme={null} $body = @' { "records": [ { "customerExternalId": "test_user_001", "agentCode": "report_writer", "signalName": "report_generated", "model": "gpt-4o", "modelProvider": "openai", "inputTokens": 523, "outputTokens": 117 } ] } '@ $body | curl.exe -X POST https://api.marginfront.com/v1/usage/record ` -H "x-api-key: $env:MF_API_SECRET_KEY" ` -H "Content-Type: application/json" ` -d '@-' ``` > **Windows: three more things that bite.** > > * **Use `curl.exe`, not `curl`.** In Windows PowerShell, `curl` is an alias for `Invoke-WebRequest`, a different command whose parameters do not match curl's. It fails with a parameter error. Spelling out `curl.exe` runs the real curl that ships with Windows 10 and later. > * **Put the JSON in a here-string.** The `@'` and `'@` markers quote the block literally. The closing `'@` has to sit at the very start of its own line or PowerShell will not end the string. > * **Pipe the body in rather than passing it as an argument.** `$body | ... -d '@-'` hands the JSON to curl on standard input, and `'@-'` is curl's name for standard input. Writing `-d $body` instead breaks on Windows PowerShell 5.1 and on PowerShell 7.0 through 7.2. Those versions strip the double quotes out of any argument containing spaces, so curl receives `{records: [...]}` and the API rejects it. Piping avoids that entirely and works on every version. Field semantics: * `customerExternalId` is your own ID for this user. In real code that's the ID your database already uses (e.g. `usr_abc123`). For a first test, any string works. * `agentCode` is a stable name for the agent doing the work (e.g. `report_writer`). * `signalName` is what the agent did (e.g. `report_generated`). * `model` and `modelProvider` are required so MarginFront can look up that model's input and output token rates. > **What models does MarginFront recognize?** The catalog has 1100+ entries across LLM and non-LLM services (Cloud Run, Twilio, Google Places, etc.). To discover the canonical name for your service, list the catalog: `GET /v1/services?provider=`. See the [Services Catalog reference](./services) for the full endpoint and the [Supported Services overview](./supported-services) for what's covered. If your service is in the catalog, fire events with the canonical name and cost auto-resolves on ingest. If not, the event still lands but cost stays null until the catalog is updated. The endpoint references the customer and agent by their string IDs, not their UUIDs. **What you should see:** ```json theme={null} { "processed": 1, "successful": 1, "failed": 0, "results": { "success": [ { "customerExternalId": "test_user_001", "agentCode": "report_writer", "signalName": "report_generated", "model": "gpt-4o", "modelProvider": "openai", "totalCostUsd": "0.0024780000", "eventId": "8a7b6c5d-...", "rawEventId": "f1e2d3c4-...", "timestamp": "2026-04-27T..." } ], "failed": [] } } ``` If the response is `successful: 1`, you just logged a real event and three records were created behind the scenes for you. *** ## 3. Look at the dashboard Log into [app.marginfront.com](https://app.marginfront.com) and open the Home page. You should see one event with the customer, agent, signal, model, and calculated cost. The customer, agent, and signal you sent are now real records in your dashboard. You can also fetch the same data via the API: ```bash Bash theme={null} curl "https://api.marginfront.com/v1/analytics/usage?startDate=2026-04-01&endDate=2026-04-30" \ -H "x-api-key: $MF_API_SECRET_KEY" ``` ```powershell PowerShell theme={null} curl.exe "https://api.marginfront.com/v1/analytics/usage?startDate=2026-04-01&endDate=2026-04-30" ` -H "x-api-key: $env:MF_API_SECRET_KEY" ``` Keep the quotes around the URL in PowerShell. `&` is reserved there. Unquoted, Windows PowerShell 5.1 refuses the line outright ("The ampersand (&) character is not allowed"), while PowerShell 7 cuts the command at the `&`, runs the truncated URL as a background job, and errors on the leftover text. *** ## What just got created When the event landed, MarginFront did three things automatically: * **Customer.** Created from the `customerExternalId` you sent. The display `name` defaults to the same string, so you may see `test_user_001` in the customer column at first. Rename it later in the dashboard or via the [customers API](./customers). * **Agent.** Created from the `agentCode`. Display `name` defaults to the same string. * **Signal.** Created from the `signalName`. Display `name` defaults to the same string. That is the full setup for cost tracking. Every future event with the same `customerExternalId` rolls up under that customer. Same for agents and signals. What does **not** auto-create: * Pricing plans and subscriptions (Step 4 below, optional). * Invoices. * Team members. *** ## 4. Add revenue tracking (optional) Skip this step if you only need cost tracking. Without a pricing plan, MarginFront still tracks costs. It just won't generate revenue numbers or invoices. To track revenue, you need two things: 1. A pricing plan that defines per-unit charges. 2. A subscription that ties one of your customers to that plan. Both can be created in the dashboard (easier) or via the API. Full reference: [pricing plans](./pricing-plans), [pricing strategies](./pricing-strategies), [subscriptions](./subscriptions). Once a customer has an active subscription, every event fires both a cost calculation and a revenue calculation. Invoices follow on the billing cycle you configure. *** ## Same thing, using the Node SDK If your app is Node.js, install the SDK: ```bash theme={null} npm install @marginfront/sdk ``` ```typescript theme={null} import { MarginFrontClient } from "@marginfront/sdk"; const mf = new MarginFrontClient(process.env.MF_API_SECRET_KEY); await mf.usage.record({ customerExternalId: "test_user_001", // your own ID for this user. any stable string works agentCode: "report_writer", // a stable name for this agent signalName: "report_generated", // what the agent did model: "gpt-4o", modelProvider: "openai", inputTokens: 523, outputTokens: 117, }); ``` Same auto-provision behavior. By default the SDK runs in fire-and-forget mode: if MarginFront is unreachable, events buffer locally and retry. Your agent never stalls. *** ## Same thing, using MCP MCP lets you ask Claude, Gemini, or ChatGPT to operate on your MarginFront account in plain English. Use it for backfills, batch operations, queries, and admin actions. **MCP is not how you instrument your product.** For per-event tracking from your own code, use the curl or SDK examples above. To connect MCP, add this to `.mcp.json` in your project root: ```json theme={null} { "mcpServers": { "marginfront": { "command": "npx", "args": ["-y", "@marginfront/mcp"], "env": { "MF_API_SECRET_KEY": "mf_sk_your_key_here" } } } } ``` Then ask your AI: `"Show me MarginFront usage analytics for today"` or `"Find the customer with external ID test_user_001"`. See [MCP Setup Guide](/mcp/setup) for the full tool list. *** ## You're done You just shipped a working integration. Suggested next steps: * **Wire this into real code.** Call `/v1/usage/record` (or `mf.usage.record(...)`) from your product code every time work happens. * **Batch high-volume traffic.** The endpoint accepts up to 100 records per call. * **Track multi-service events.** When one outcome uses several services (search + LLM + email, for example), pass a `services[]` array on the request body. See the [SDK tracking-events guide, Example 5](/sdk/tracking-events#example-5-multi-service-event-one-outcome-multiple-services). * **Add revenue tracking.** When you're ready to bill, follow Step 4 above. * **Read the [usage-events reference](./usage-events).** Full field list and what happens when a model isn't recognized. *** ## Help, something didn't work **Got a 401 on Windows with a key you know is good?** Check these three in order. Each one sends an empty or malformed key and produces the same 401. 1. You wrote `$MF_API_SECRET_KEY` instead of `$env:MF_API_SECRET_KEY`. 2. You ran `curl` instead of `curl.exe`, so PowerShell used `Invoke-WebRequest`. 3. You set the variable in a different terminal window than the one you are calling from. To confirm the variable is set in this window without printing the key itself, run `$env:MF_API_SECRET_KEY.Length`. A number means it's set. A blank line or an error means it isn't, and the request went out with no key. Cross-reference the response body with [errors.md](./errors). Still stuck? Email `team@marginfront.com` with: * The curl command (API key REDACTED, never share it). * The full response body you got back. We'll get you unstuck. *** ## Where else to look * Discover canonical model names: [Services Catalog](./services) | [Supported Services overview](./supported-services) * Full page index: [https://docs.marginfront.com/sitemap.xml](https://docs.marginfront.com/sitemap.xml) * LLM-friendly reference: [https://docs.marginfront.com/llms.txt](https://docs.marginfront.com/llms.txt) * MCP-specific reference (for AI assistants connecting via MCP): [https://marginfront.com/llms-mcp.txt](https://marginfront.com/llms-mcp.txt) # SDK vs REST Migration Source: https://docs.marginfront.com/api-reference/sdk-vs-rest Old URL shapes mapped to the new unified API # The MarginFront API (one surface, unified) > **Heads up:** this doc used to describe two parallel API surfaces (the SDK API and the admin REST API) and tell you which to pick. Those two surfaces have been **unified**. There is now **one public API** under `/v1/`. Every endpoint, including the seven endpoints that used to be dashboard-only, now runs through the same canonical library. This doc is kept around as a migration reference for anyone who was using the old routes. *** ## The unified API (what to use today) All public endpoints live under `/v1/`: ``` /v1/verify /v1/customers /v1/customers/:id /v1/customers/:id/revenue /v1/agents /v1/agents/:id /v1/signals /v1/signals/:id /v1/pricing-plans /v1/pricing-plans/:id /v1/subscriptions /v1/subscriptions/:id /v1/subscriptions/:id/revenue /v1/usage/record /v1/analytics/usage /v1/analytics/revenue /v1/analytics/cost /v1/analytics/mrr /v1/invoices /v1/invoices/:id /v1/invoices/generate /v1/invoices/:id/send /v1/portal-sessions /v1/portal-sessions/:id ``` **Canonical analytics endpoints** (new): [`/v1/analytics/revenue`](./analytics-revenue), [`/v1/analytics/cost`](./analytics-cost), and [`/v1/analytics/mrr`](./analytics-mrr). These three return the same canonical revenue, cost, and MRR shapes the dashboard uses. Per-entity revenue is available at [`/v1/customers/:id/revenue`](./customers#read-a-customer-with-revenue) and [`/v1/subscriptions/:id/revenue`](./subscriptions#read-a-subscription-with-revenue). **Key points:** * **No `org/:orgId/` in the URL.** The API key alone identifies your organization. You never need to pass an org ID in a URL anywhere. * **No `/sdk/` prefix either.** What used to be "SDK-only" endpoints now live at the same top level as everything else. * **One credential per request.** Pass your API key via the `x-api-key` header. See [authentication.md](./authentication) for the full details. * **Every endpoint is the same shape whether you call it from curl, the Node SDK, a Python backend, or anything else.** The SDK is a thin wrapper over the exact URLs documented in these files. * **One canonical library underneath.** Revenue, cost, margin, and MRR math runs through one shared library. The same numbers come out whether you hit an SDK method, a REST endpoint, or view the dashboard. There is no longer a "dashboard API vs public API" distinction: every consumer runs through the same canonical code path. Example: ```bash Bash theme={null} curl https://api.marginfront.com/v1/customers \ -H "x-api-key: $MF_API_SECRET_KEY" ``` ```powershell PowerShell theme={null} curl.exe https://api.marginfront.com/v1/customers ` -H "x-api-key: $env:MF_API_SECRET_KEY" ``` *** ## Legacy paths (still working during the deprecation window) Two legacy URL shapes still resolve, backed by the same handlers as the new URLs: * `/v1/org/:orgId/`: the old admin REST shape * `/v1/sdk/`: the old SDK-surface shape **Both still work.** The `@marginfront/sdk@0.5.0` npm package continues to function without any changes on your side. Existing integrations that hardcoded either URL shape will keep working. **But:** * The legacy paths are **not documented** in the per-resource doc files. Those files only describe the new canonical paths. * The legacy paths will be **removed** after the deprecation window ends. When we pick a removal date, we'll announce it with enough runway for you to migrate. * **New integrations should always use `/v1/`.** It's shorter, cleaner, and will outlive the legacy paths. *** ## Migrating existing integrations If you're still on the old URL shapes, migration is a find-and-replace: | Before | After | | --------------------------------- | -------------------------- | | `POST /v1/org/{orgId}/customers` | `POST /v1/customers` | | `GET /v1/sdk/customers` | `GET /v1/customers` | | `POST /v1/sdk/usage/record` | `POST /v1/usage/record` | | `GET /v1/sdk/analytics/usage` | `GET /v1/analytics/usage` | | `GET /v1/sdk/invoices` | `GET /v1/invoices` | | `GET /v1/sdk/verify` | `GET /v1/verify` | | `POST /v1/sdk/portal-sessions` | `POST /v1/portal-sessions` | | `Authorization: Bearer mf_sk_...` | `x-api-key: mf_sk_...` | Also: **drop any `MF_ORG_ID` env var you were passing into URLs.** You don't need it anymore. *** ## Why the unification happened Historical reasons. Originally we had: 1. An **admin REST API** (`/v1/org/:orgId/*`) that the dashboard used internally. It required the URL to include the org ID because the dashboard knew which org the logged-in user was viewing. 2. An **SDK API** (`/v1/sdk/*`) that the `@marginfront/sdk` npm package called. It didn't need the org ID in the URL because the API key already identified the org. When we opened the admin endpoints to API key auth (so customers could use them directly from their own code), customers ended up with two parallel surfaces to choose from. Having two was confusing, shipped too much surface area to document, and made the SDK harder to build and maintain. The unify decision (2026-04-11) collapsed both into a single public API under `/v1/`, matching how Stripe, Anthropic, OpenAI, Resend, and every modern API-first company does it: * **URL stability.** The URL shape doesn't change based on account state. * **Less to remember.** One credential, one URL shape. * **Fewer mistakes.** You can't accidentally hit one org's URL with another org's key. * **Cleaner SDKs.** No `orgId` parameter cluttering every method signature. *** ## Questions? Email `team@marginfront.com`. If something in the old docs still works but isn't documented in the new ones, that's a migration gap we want to know about. # Services Catalog Source: https://docs.marginfront.com/api-reference/services List the canonical model + provider names MarginFront recognizes for cost calculation # Services Catalog The **services catalog** is MarginFront's record of every model and non-LLM service it can calculate cost for. When you fire a usage event with `model` and `modelProvider`, MarginFront looks up the catalog entry that matches and uses its rate to compute cost. > **Use this endpoint to discover canonical names BEFORE firing events.** If your service is in the catalog, send `model: ''` and `modelProvider: ''` and cost auto-resolves on ingest. If not, the event still lands but cost stays null (`NEEDS_COST_BACKFILL`). The [`POST /v1/events/map-model`](./usage-events) endpoint can then redirect an unknown name to an existing catalog entry. It cannot create new rates. The catalog is global. It is not org-scoped. Every authenticated caller sees the same entries. The catalog is read-only via the API. Entries are managed by an internal sync script that pulls from OpenRouter, LiteLLM, and a hand-curated list of non-LLM services. *** ## List services Returns paginated catalog entries. Filter by provider, service type, LLM-vs-non-LLM, or full-text search. **Method & URL:** ``` GET /v1/services ``` **Headers:** ``` x-api-key: mf_sk_... ``` **Query parameters:** | Field | Type | Description | | ------------- | ------- | ----------------------------------------------------------------------------------------------------- | | `provider` | string | Filter by lowercase provider name (e.g. `openai`, `google`, `anthropic`, `twilio`). Case-insensitive. | | `serviceType` | string | Filter by category (e.g. `LLM`, `Geocoding`, `Compute`, `Web Search`, `Vector Database`, `SMS`). | | `isApi` | boolean | Filter to non-LLM API entries only (`true`) or LLM-only (`false`). Omit for both. | | `search` | string | Case-insensitive search across `canonicalName` and `displayName`. | | `page` | integer | 1-based page number. Defaults to `1`. | | `limit` | integer | Page size, 1 to 100. Defaults to `50`. | **Example: list every Google service** ```bash Bash theme={null} curl "https://api.marginfront.com/v1/services?provider=google" \ -H "x-api-key: $MF_API_SECRET_KEY" ``` ```powershell PowerShell theme={null} curl.exe "https://api.marginfront.com/v1/services?provider=google" ` -H "x-api-key: $env:MF_API_SECRET_KEY" ``` **Example: find every non-LLM API in the Compute category** ```bash Bash theme={null} curl "https://api.marginfront.com/v1/services?serviceType=Compute&isApi=true" \ -H "x-api-key: $MF_API_SECRET_KEY" ``` ```powershell PowerShell theme={null} curl.exe "https://api.marginfront.com/v1/services?serviceType=Compute&isApi=true" ` -H "x-api-key: $env:MF_API_SECRET_KEY" ``` **Example: search by name** ```bash Bash theme={null} curl "https://api.marginfront.com/v1/services?search=cloud-run" \ -H "x-api-key: $MF_API_SECRET_KEY" ``` ```powershell PowerShell theme={null} curl.exe "https://api.marginfront.com/v1/services?search=cloud-run" ` -H "x-api-key: $env:MF_API_SECRET_KEY" ``` **Response:** ```json theme={null} { "data": [ { "id": "df3c1acd-fdc4-4f6c-9d84-99697ffb76a2", "externalId": "api/cloud-run-cpu-second", "canonicalName": "cloud-run-cpu-second", "displayName": "Google Cloud Run vCPU", "provider": "google", "serviceType": "Compute", "inputCost": "0.024", "outputCost": null, "costUnit": "1K vCPU-seconds", "contextWindow": null, "source": "curated", "isApi": true, "isActive": true } ], "pagination": { "page": 1, "limit": 50, "total": 9, "totalPages": 1 } } ``` **Field reference:** | Field | Description | | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | UUID of the catalog entry. Pass to `GET /v1/services/:id` for the full record. | | `externalId` | Source-specific identifier (e.g. `openai/gpt-4o`, `api/cloud-run-cpu-second`, `litellm/anthropic/claude-sonnet-4-20250514`). Useful for tracing where the entry came from. | | `canonicalName` | The lowercase name to send as `model` in your usage events. This is what the cost lookup matches against. | | `displayName` | Human-readable name for UI surfaces. Not used for matching. | | `provider` | Lowercase provider name to send as `modelProvider`. | | `serviceType` | Category label (e.g. `LLM`, `Geocoding`, `Compute`). | | `inputCost` | Per-unit cost on the input side. Returned as a string-formatted decimal so JS Number precision can't truncate fractional cents. `null` when the service does not publish an input-side rate. | | `outputCost` | Per-unit cost on the output side (LLM output tokens). `null` for non-LLM services. | | `costUnit` | Unit the rates are denominated in (e.g. `1M tokens`, `1K requests`, `1K vCPU-seconds`). | | `contextWindow` | LLM context window size in tokens. `null` for non-LLM. | | `source` | Where the entry came from: `openrouter`, `litellm`, or `curated`. | | `isApi` | `true` for non-LLM API entries (Cloud Run, Twilio, Google Places, etc.). `false` for LLM models. | | `isActive` | `false` if the entry has been deactivated. The list endpoint filters these out by default. | *** ## Get a single service ``` GET /v1/services/:serviceId ``` Returns the full record for one catalog entry by its UUID. Same shape as one row in the list response. **Example:** ```bash Bash theme={null} curl "https://api.marginfront.com/v1/services/df3c1acd-fdc4-4f6c-9d84-99697ffb76a2" \ -H "x-api-key: $MF_API_SECRET_KEY" ``` ```powershell PowerShell theme={null} curl.exe "https://api.marginfront.com/v1/services/df3c1acd-fdc4-4f6c-9d84-99697ffb76a2" ` -H "x-api-key: $env:MF_API_SECRET_KEY" ``` **Errors:** | Code | Cause | | ----- | --------------------------------------------------------- | | `404` | The catalog entry does not exist or has been deactivated. | | `401` | Missing or invalid API key. | *** ## Using the catalog in your integration Pattern that avoids the `NEEDS_COST_BACKFILL` cycle: 1. Before adding a new service to your code, call `GET /v1/services?search=` (or `?provider=`). 2. If the response has a matching entry, use its `canonicalName` as `model` and its `provider` as `modelProvider`. 3. Fire usage events. Cost auto-resolves on ingest. If no matching entry exists, two paths: * **Map an existing entry.** If your service is similar to one in the catalog (e.g. a Google Places sub-endpoint mapping to the bundled `google-places` entry), use `POST /v1/events/map-model` to redirect your unknown name to the existing entry. Cost backfills retroactively for events already saved with `cost = null`. * **Catalog gap.** If nothing in the catalog represents your service accurately, fire events anyway. Events are saved with `cost = null` and `eventStatus = NEEDS_COST_BACKFILL`. Email `team@marginfront.com` with the service name and your provider's pricing page; we'll add it to the catalog. *** ## Programmatic access The same catalog is available via: * **Node SDK**: `client.services.list(...)` and `client.services.get(id)`. Documented in the [SDK reference](/sdk/types-reference). * **MCP**: the `list_catalog_services` tool. Documented in the [MCP tools reference](/mcp/tools). # Signals Source: https://docs.marginfront.com/api-reference/signals Define what you measure and bill on # Signals (Metrics) A **signal**, also called a **metric**, is what you measure and bill on. It's the unit of work you charge for. Examples: * A chatbot agent might have a signal called `messages` (charge per message) * A document analyzer might have `pages_processed` (charge per page) * An image generator might have `images_generated` (charge per image) * A video service might have `minutes_transcoded` (charge per minute) > **You don't have to call this endpoint to create a signal.** When you fire a usage event with a `signalName` MarginFront has not seen before, the signal record is created automatically. Use this endpoint when you want to set the signal `type`, description, or other fields up front, or to update an existing signal. You attach a signal to an agent and log usage events against it. MarginFront keeps count, applies your pricing rules, and rolls it up into an invoice. > **Pick the signal name to match how you bill.** The signal name IS the unit your customer pays for. If you bill per page, name it `pages` and fire ONE event per report with `quantity: pageCount`. If you bill per report, name it `reports` and fire ONE event with `quantity: 1`. Same underlying work, different invoice line. See [Choosing your signal name and quantity](/concepts#choosing-your-signal-name-and-quantity) for the full guide with examples. *** ## Signal types: `usage` vs `volume` Every signal has a `type` that controls how it's billed: | Type | Also known as | Bills based on | Example | | ------------ | ------------- | -------------------------------- | ----------------------------------------- | | **`usage`** | OUTCOME | Results actually delivered | Charge per successfully analyzed document | | **`volume`** | ACTIVITY | Attempts made, successful or not | Charge per API call regardless of outcome | Pick `usage` when you want to align pricing with customer value (they only pay when they get something). Pick `volume` when you want to charge for the resources consumed regardless of result (they pay for every attempt because every attempt costs you money). Most usage-based pricing for AI products uses `usage`. When in doubt, start there. *** ## Create a signal **In plain English:** Define a new billable metric for an agent. You do this once per metric, up front — not every time the agent works. **Method & URL:** ``` POST /v1/signals ``` **Required fields:** * `name` *(string, max 255 chars)* — Human-readable name. Shows up in reports and on invoices. Examples: `"Messages Processed"`, `"Documents Analyzed"`, `"Images Generated"`. * `agentId` *(UUID string)* — The internal ID of the agent this signal belongs to. Get it from the agent's create response. (Note: this is the agent's `id`, not its `agentCode`.) **Optional fields:** * `shortName` *(string, max 255 chars)* — A machine-friendly version of the name. Used as the key in some API responses. Example: `"messages"`, `"docs_analyzed"`. If you don't provide one, it's derived from `name`. * `type` *(`"usage"` | `"volume"`, default `"usage"`)* — See the explanation above. **Example curl call:** ```bash theme={null} curl -X POST https://api.marginfront.com/v1/signals \ -H "x-api-key: mf_sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "name": "Messages Processed", "shortName": "messages", "agentId": "b47e12fa-abcd-4567-8901-234567890abc", "type": "usage" }' ``` **What you get back (`201 Created`):** ```json theme={null} { "id": "f12d34ab-cdef-0123-4567-89abcdef0123", "name": "Messages Processed", "shortName": "messages", "type": "usage", "agentId": "b47e12fa-abcd-4567-8901-234567890abc", "createdAt": "2026-04-10T14:30:00.000Z" } ``` **Common errors:** * **`400 Bad Request`** — Missing `name` or `agentId`, or `agentId` isn't a valid UUID format, or `type` isn't `"usage"` or `"volume"`. * **`404 Not Found`** — The `agentId` you provided doesn't match any agent in this org. * **`409 Conflict`** — A signal with that name or shortName already exists for this agent. **When to call this:** Once, during setup, for each metric you want to bill on. Don't call this every time a usage event happens — that's what the usage/record endpoint is for. *** ## Read a signal **Method & URL:** ``` GET /v1/signals/{signalId} ``` *** ## List signals **Method & URL:** ``` GET /v1/signals ``` **Useful query parameters** (check the controller for the full list): * `agentId` — filter to signals for a specific agent *** ## Update a signal **Method & URL:** ``` PATCH /v1/signals/{signalId} ``` **Fields:** Any field from create except `agentId` (you can't move a signal to a different agent — create a new one instead). > **Careful with renaming.** If you change a signal's `shortName` after you've started logging usage events against it, any reports that grouped by shortName will split across the old and new names. Rename sparingly. *** ## Delete a signal **Method & URL:** ``` DELETE /v1/signals/{signalId} ``` **What it does:** Permanently removes the signal. All historical usage events tied to this signal become orphaned — they still exist, but they'll stop showing up in most reports. > **Usually you don't want to delete.** For ongoing operations, prefer to just stop logging events against an old signal. Deletion is mostly for cleaning up test data or correcting mistakes during setup. *** ## Bulk create signals **Method & URL:** ``` POST /v1/signals/bulk ``` **When to use it:** If you're setting up many signals at once (e.g., spinning up a new agent with a dozen metrics), the bulk endpoint is faster than calling create in a loop. Check the controller for the exact request shape. *** ## Using the Node SDK Signals aren't exposed as a separate resource in the `@marginfront/sdk` npm package. The SDK references signals by name (`signalName`) when you log usage events: ```js theme={null} await mf.usage.record({ customerExternalId: "acme-001", agentCode: "cs-bot-v2", signalName: "messages", // ← matches the signal's shortName model: "gpt-4o", inputTokens: 523, outputTokens: 117, }); ``` **To create signals, use the HTTP API above.** If you log a usage event with a `signalName` MarginFront has never seen, it auto-creates a minimal signal for you — convenient for prototyping, but you'll want to go back and set the right `name` and `type` afterwards. *** ## How signals fit into the whole billing chain ``` Customer ─┐ │ Agent ─────┼──── Signal ──── Pricing plan ──── Subscription │ (metric) (how to price) (per customer) │ Usage event (logged per signal, per customer) ``` * **Customer:** who's being billed * **Agent:** what's doing the work * **Signal:** what you're measuring * **Pricing plan:** how much each unit of that signal costs * **Subscription:** ties a customer to a pricing plan * **Usage event:** a single measurement (customer X used signal Y, quantity Z) When an invoice runs, MarginFront totals the usage events for each customer, applies the pricing plan from their subscription, and produces line items per signal. That's the whole pipeline. # Spend Controls Source: https://docs.marginfront.com/api-reference/spend-controls Cap your own team's AI coding spend and read it back from code # Spend Controls ## What is a spend cap? A **spend cap** is a limit you set on how much your own team spends on AI coding tools. For example: "Stop AI spend at $5,000 across the whole team per month," or "$200 per week for one developer." These caps govern your company's **internal** coding-agent spend: the usage you meter with the [Claude Code & Codex spend tool](/tools/code-cost-clarity). They have nothing to do with your customers. Events you record for customer billing never count toward a cap, and a cap never changes what any customer pays. You can manage caps on the dashboard's Internal AI Spend page. These endpoints are the same controls, callable from your own code. The [Node SDK](/sdk/spend-controls) and the [MCP tools](/mcp/tools) call these exact endpoints too, so every path sees the same caps and the same numbers. *** ## The two ideas to know first **Scope: who the cap governs.** * `org`: one ceiling for the whole team. * `dev`: one developer, named by their `customerExternalId` (their email). A developer cap can never be set higher than the whole-team ceiling. The API rejects the attempt with a plain message. **Mode: what the cap does when spend reaches it.** * `track`: watches and sends alert emails. Blocks nothing. * `enforce`: a developer's machine stops the Claude Code or Codex tool call when the cap is reached. Enforcement only happens on machines where the team turned on the [Spend Control device setup](/tools/code-cost-clarity#spend-control-opt-in-caps). The API itself never blocks anything; it stores the caps and reports the spend. Every cap covers **all AI tools**. You can't yet cap one provider (say, only Claude) on its own; if you send a different `providerScope`, the API answers with `"Caps cover all AI tools for now."` That's what counts toward a cap; what gets paused on an armed machine is Claude Code and Codex tool calls. *** ## Who can change caps Reading caps, spend, and coverage works with any secret key for your organization. **Creating, updating, or deleting a cap needs a key that belongs to an owner or finance user.** Any other key gets a `403 Forbidden` with a message naming the required role. This is the same rule the dashboard enforces: spend caps sit with the people who own the budget. *** ## The endpoints ### List caps ``` GET /v1/control/caps ``` Returns every cap policy for your organization. Each cap includes a `sentence` field: the cap written out as one plain sentence, built by the server. **Response (`200 OK`):** ```json theme={null} [ { "id": "b7f3c9a1-...", "scope": "org", "scopeValue": null, "providerScope": "all", "amountUsd": 5000, "period": "month", "mode": "enforce", "coolOffHours": null, "alertThresholds": [50, 80, 100], "createdByUserId": "user-...", "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "sentence": "Stop AI spend at $5,000 across the whole team per month" } ] ``` *** ### Create a cap ``` POST /v1/control/caps ``` Owner or finance key required. **Request body:** ```json theme={null} { "scope": "dev", "scopeValue": "alice@acme.com", "providerScope": "all", "amountUsd": 200, "period": "week", "mode": "enforce" } ``` | Field | Type | Required | Notes | | ----------------- | --------- | -------- | ------------------------------------------------------------------------------------------------------------------------ | | `scope` | string | Yes | `org` (whole-team ceiling) or `dev` (one developer). | | `scopeValue` | string | For dev | The developer's `customerExternalId` (their email). Omit for `org`. | | `providerScope` | string | Yes | Must be `"all"`. Every cap covers all AI tools. | | `amountUsd` | number | Yes | The cap amount in US dollars. | | `period` | string | Yes | `day`, `week`, or `month`. The window resets on UTC boundaries. | | `mode` | string | Yes | `track` (watch and alert, block nothing) or `enforce` (armed machines stop Claude Code and Codex tool calls at the cap). | | `coolOffHours` | number | No | Hours to wait after a cap trips before the device re-checks. Whole number. | | `alertThresholds` | number\[] | No | Alert ladder as whole percents of the cap, up to 5 values, each higher than the last. Default is `[50, 80, 100]`. | **Response (`201 Created`):** the full cap, same shape as the list response, including its `sentence`. **curl example:** ```bash theme={null} curl -X POST https://api.marginfront.com/v1/control/caps \ -H "x-api-key: mf_sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "scope": "org", "providerScope": "all", "amountUsd": 5000, "period": "month", "mode": "track" }' ``` **Common errors:** * `400 Bad Request`: a developer cap higher than the whole-team ceiling, a `scopeValue` that doesn't match the scope, or a `providerScope` other than `"all"`. * `403 Forbidden`: your key doesn't belong to an owner or finance user. * `409 Conflict`: a cap with this scope and target already exists. Update it instead of creating a second one. *** ### Update a cap ``` PATCH /v1/control/caps/{id} ``` Owner or finance key required. You can change `amountUsd`, `period`, `mode`, `coolOffHours`, and `alertThresholds`. A cap's identity (its scope and target) can't be changed: to point a cap at someone else, delete it and create a new one. **Response (`200 OK`):** the updated cap, including its refreshed `sentence`. **Common errors:** * `400 Bad Request`: the new amount would put a developer cap above the whole-team ceiling. * `403 Forbidden`: your key doesn't belong to an owner or finance user. * `404 Not Found`: no cap with that ID in your organization. *** ### Delete a cap ``` DELETE /v1/control/caps/{id} ``` Owner or finance key required. The response echoes the removed cap's sentence so you can confirm what was deleted. **Response (`200 OK`):** ```json theme={null} { "deleted": true, "sentence": "Stop AI spend at $200 for alice@acme.com per week" } ``` **Common errors:** * `400 Bad Request`: deleting this whole-team ceiling would leave a developer cap with nothing above it. * `403 Forbidden`: your key doesn't belong to an owner or finance user. * `404 Not Found`: no cap with that ID in your organization. *** ### Read spend against the caps ``` GET /v1/control/spend?period=week ``` Returns your team's internal coding-agent spend for the current period window (the server computes the window in UTC). Add `customerExternalId` to also get one developer's number: ``` GET /v1/control/spend?period=week&customerExternalId=alice@acme.com ``` **Response (`200 OK`):** ```json theme={null} { "period": "week", "window": { "start": "2026-07-20T00:00:00.000Z", "end": "2026-07-26T23:59:59.999Z" }, "asOf": "2026-07-23T14:05:00.000Z", "org": { "spentUsd": 42.5, "eventCount": 310, "unpricedEventCount": 0 }, "dev": { "customerExternalId": "alice@acme.com", "spentUsd": 12.75, "eventCount": 88, "unpricedEventCount": 0 } } ``` Three honesty rules to know: * `spentUsd` is `null` when there's no priced usage in the window. A `null` means "nothing to total yet," never a fake `$0`. * `unpricedEventCount` tells you how many events exist that couldn't be priced yet (their model isn't in the pricing table). If it's above zero, the true spend is higher than `spentUsd` shows. * Only internal coding-agent usage counts. Events you record for customer billing are never in these totals. *** ### Read coverage ``` GET /v1/control/coverage ``` Answers "how many of my developers are actually protected?" over the trailing 7 days. Works with any secret key. **Response (`200 OK`):** ```json theme={null} { "armed": 3, "total": 5, "asOf": "2026-07-23T14:05:00.000Z" } ``` * `armed`: developers whose machines have enforcement turned on. * `total`: developers seen at all in the window. * `asOf` is `null` when there's been no recent activity. The server reports honest absence rather than making up a timestamp. These are the same numbers the dashboard's Internal AI Spend page shows, computed by the same math. *** ## How enforcement actually works The API stores caps and reports spend. The stopping happens on each developer's machine, through the [Claude Code & Codex spend tool](/tools/code-cost-clarity) with its Spend Control option turned on. Two consequences worth knowing: * **Enforcement fails open.** If a machine can't read fresh cap data (network down, stale file), it doesn't block. A broken cap should never brick a developer's day. * **A cap in `track` mode never blocks anywhere.** It watches and sends the alert-ladder emails, nothing more. * **Gemini and xAI spend counts, but isn't paused.** Every cap's total includes them; the device check stops Claude Code and Codex tool calls only. # Subscriptions Source: https://docs.marginfront.com/api-reference/subscriptions Tie customers to pricing plans # Subscriptions A **subscription** ties a customer to a pricing plan. It's the thing that says "starting on this date, bill this customer according to this plan's rules." Until a customer has a subscription, MarginFront doesn't know how to charge them. Usage events can still be logged, but there's no pricing rule attached. Creating a subscription is the most complex create in the whole API because it brings together four other objects: a customer, an agent, a pricing plan, and a start date. All four have to already exist before you can create the subscription. *** ## The whole integration chain Before you can create a subscription, you need (in this order): 1. A **customer**. See [customers](./customers). 2. An **agent**. See [agents](./agents). 3. One or more **signals** attached to that agent. See [signals](./signals). 4. A **pricing plan** attached to that agent. See [pricing-plans](./pricing-plans). Once those exist, you create the subscription that glues customer ↔ plan together. After that, every usage event you log gets priced according to the subscribed plan and rolled up into an invoice at the end of the billing period. *** ## Create a subscription **In plain English:** Start billing a customer according to a pricing plan. You're saying "from this date forward, charge this customer for this agent's usage according to this plan." **Method & URL:** ``` POST /v1/subscriptions ``` **Required fields:** * `name` *(string)*: a human-readable name for the subscription. Useful for identifying it in the dashboard. * `planId` *(UUID string)*: the pricing plan's internal ID. From the pricing plan's create response. * `agentId` *(UUID string)*: the agent's internal ID. Must match the agent that the plan belongs to. * `customerId` *(UUID string)*: the customer's internal ID. From the customer's create response. * `startDate` *(ISO 8601 date string)*: when this subscription starts billing. Can be in the past (for backdating) or the future (for scheduled activation). Example: `"2026-04-10T00:00:00.000Z"`. **Optional fields (most common):** * `status` *(`"upcoming"` | `"active"` | `"ended"`, default `"upcoming"`)*: current state. Use `"active"` if you want billing to start immediately. * `billingCycle` *(`"monthly"` | `"yearly"` | `"custom"`, default `"monthly"`)*: how often invoices are generated. * `billingModel` *(`"subscription"` | `"usage"` | `"hybrid"`, default `"subscription"`)*: how billing is calculated. * `"subscription"` = flat recurring fee * `"usage"` = pay only for what you use * `"hybrid"` = base fee + usage * `netTerms` *(number, default `30`)*: payment terms in days after invoice issued. **Other optional fields:** * `endDate` *(ISO date)*: fixed end date if this is a time-limited subscription * `isForever` *(boolean, default `false`)*: set true for subscriptions with no end date (ignores `endDate`) * `subscriptionDetails` *(object)*: free-form metadata specific to this subscription * `seatsCount` *(number, default `0`)*: for per-seat pricing * `customCycleDays` *(number)*: only when `billingCycle` is `"custom"`; how many days between invoices * `proratedAmount` *(number)*: proration adjustment for mid-cycle starts * `timezone` *(string)*: override for billing period calculations * `billingDay` *(1-31)*: day of the month invoices are generated * `invoiceMemo` *(string)*: a note that appears on every invoice for this subscription * `metadata` *(object)*: custom key/value pairs; stored but not interpreted **Example curl call (the minimum to get a working subscription):** ```bash theme={null} curl -X POST https://api.marginfront.com/v1/subscriptions \ -H "x-api-key: mf_sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "name": "Acme Pro Subscription", "planId": "a1b2c3d4-5678-90ab-cdef-1234567890ab", "agentId": "b47e12fa-abcd-4567-8901-234567890abc", "customerId": "5e7f8a3d-1234-5678-9abc-def012345678", "startDate": "2026-04-10T00:00:00.000Z", "status": "active", "billingCycle": "monthly" }' ``` **What you get back (`201 Created`):** ```json theme={null} { "id": "sub_9a8b7c6d-...", "name": "Acme Pro Subscription", "planId": "a1b2c3d4-...", "agentId": "b47e12fa-...", "customerId": "5e7f8a3d-...", "startDate": "2026-04-10T00:00:00.000Z", "status": "active", "billingCycle": "monthly", "createdAt": "2026-04-10T14:30:00.000Z" } ``` **Common errors:** * **`400 Bad Request`**: missing one of the required fields, or a UUID is the wrong format, or `startDate` isn't a valid ISO date. * **`404 Not Found`**: one of the referenced objects (customer, agent, plan) doesn't exist in this org. * **`409 Conflict`**: this customer already has an overlapping active subscription for this plan, depending on your org's rules. **When to call this:** When a customer upgrades, signs up for a paid plan, or activates a trial. This is the "billing starts now" moment. *** ## Read a subscription **Method & URL:** ``` GET /v1/subscriptions/{subscriptionId} ``` *** ## Read a subscription with revenue **In plain English:** fetch a subscription together with its revenue, cost, and margin for a time window. Use this to power subscription detail pages without two round trips. **Method & URL:** ``` GET /v1/subscriptions/{subscriptionId}/revenue ``` **Query parameters** (all optional): * `startDate` *(ISO date)*: start of the window, inclusive * `endDate` *(ISO date)*: end of the window, inclusive If you omit both, MarginFront defaults the window to the last 30 days (UTC). **Example:** ```bash theme={null} curl "https://api.marginfront.com/v1/subscriptions/9a8b7c6d-.../revenue?startDate=2026-04-01&endDate=2026-04-30" \ -H "x-api-key: $MF_API_SECRET_KEY" ``` **What it returns:** a canonical `RevenueMetrics` object scoped to this subscription. Same shape as [`GET /v1/analytics/revenue`](./analytics-revenue), same math. Revenue, cost, margin, margin percent, the usage/recurring/seat/onetime breakdown, per-subscription breakdown (one row for this subscription), per-strategy breakdown. See [the SDK types reference](/sdk/types-reference) for the full `RevenueMetrics` shape. **Using the SDK:** `client.subscriptions.getWithRevenue(id, window)` wraps this endpoint and returns `{ subscription, revenue }` in a single call (it fires the subscription fetch and this revenue fetch in parallel). See [SDK subscriptions](/sdk/subscriptions). **Common errors:** * **`404 Not Found`**: no subscription with that ID exists in this org. * **`401 Unauthorized`**: API key missing or wrong. *** ## List subscriptions **Method & URL:** ``` GET /v1/subscriptions ``` **Useful query parameters:** `customerId`, `status`, `agentId` (check the controller for the full list). *** ## Update a subscription **Method & URL:** ``` PATCH /v1/subscriptions/{subscriptionId} ``` **Fields:** most fields from create are updatable. The structural fields (`planId`, `agentId`, `customerId`) generally should NOT be changed. If a customer switches plans, it's usually cleaner to end the current subscription and create a new one rather than mutating the existing subscription, because it preserves a clean audit trail for accounting. **The billing dates can't be changed after a subscription is created.** The `startDate` and the current billing period's start and end dates decide which usage each invoice picks up, so they're locked once the subscription exists. If you include any of them in a PATCH, the request comes back `400 Bad Request` with a message naming the fields you tried to move. To bill on a different schedule, end this subscription and create a new one that starts on the dates you want. *** ## Update invoice dates **Method & URL:** ``` PATCH /v1/subscriptions/{subscriptionId}/invoice-dates ``` **What it does:** Adjusts when future invoices will be generated for this subscription. Use this for things like "customer wants to move their billing day from the 1st to the 15th." *** ## Read the subscription changelog **Method & URL:** ``` GET /v1/subscriptions/{subscriptionId}/changelog ``` **What it returns:** a history of all changes made to this subscription: plan changes, price updates, date adjustments, status transitions. Useful for audit trails and for customer support when someone asks "when did that price change?" *** ## Using the Node SDK ```js theme={null} // List all subscriptions const { data } = await mf.subscriptions.list(); // Read a specific subscription const sub = await mf.subscriptions.get("sub_9a8b7c6d-..."); ``` **Subscription CREATE is not yet wrapped by the `@marginfront/sdk` npm package.** For now, create subscriptions through the HTTP API using curl or your language of choice. See the "Create a subscription" section above. # Supported Services Source: https://docs.marginfront.com/api-reference/supported-services What's in MarginFront's pricing catalog right now # Supported Services MarginFront's pricing catalog covers 1100+ services across LLM and non-LLM categories. The catalog is the source of truth for what we can calculate cost for. If your service is in the catalog, fire events with the canonical name and cost auto-resolves. > **The live list is the API.** This page summarizes what's covered. For the actual current entries, use [`GET /v1/services`](./services), [`client.services.list()`](/sdk/types-reference) in the Node SDK, or `list_catalog_services` in the MCP server. The list updates as we sync new entries. *** ## Categories at a glance The catalog is organized by `serviceType`. Approximate counts as of this writing: | `serviceType` | Approx count | Examples | | --------------------- | ------------ | --------------------------------------------------------------------------------------------- | | `LLM` | 990+ | OpenAI GPT family, Anthropic Claude, Google Gemini, Mistral, DeepSeek, Llama variants, Cohere | | `Embeddings` | 70+ | OpenAI text-embedding-*, Voyage, Cohere embed-*, Mistral embed | | `Speech-to-Text` | 47+ | OpenAI Whisper, Deepgram, AssemblyAI, Google STT, Azure STT | | `Image Generation` | 18+ | DALL-E, Stable Diffusion, Midjourney, Flux | | `Text-to-Speech` | 18+ | OpenAI TTS, ElevenLabs, Google TTS, Cartesia | | `Reranking` | 7+ | Cohere rerank, Voyage rerank, Jina rerank | | `Web Search` | 6+ | Serper, Tavily, SerpAPI, Brave Search, Exa, Google Custom Search | | `Geocoding` | 5+ | Google Places sub-endpoints (textsearch, details, nearby, autocomplete, photo) | | `Compute` | 4+ | Google Cloud Run (CPU-second, memory-GiB-second, instance-second bundle, request count) | | `Web Scraping` | 4+ | Firecrawl, ScrapingBee, Jina Reader | | `Email` | 4+ | Resend, SendGrid, Postmark, Amazon SES | | `Maps` | 3+ | Google Maps, Mapbox, HERE | | `Document Processing` | 3+ | AWS Textract, Google Document AI, Azure Form Recognizer | | `Data Enrichment` | 3+ | Hunter.io, Exa Email Enrichment, Exa Phone Enrichment | | `SMS` | 1+ | Twilio SMS | | `Voice` | 1+ | Twilio Voice | | `Code Execution` | 1+ | E2B | | `Vector Database` | 1+ | Pinecone Serverless | *** ## How the catalog gets populated Three sources feed the `service_pricing` table: 1. **OpenRouter** — pulled from their live API. Covers most public LLM models with current rates. 2. **LiteLLM** — pulled from the LiteLLM project's GitHub JSON. Coverage overlap with OpenRouter; we deduplicate by canonical name. 3. **Curated** — hand-maintained list of non-LLM services (Twilio, Google Places, Cloud Run, web search APIs, etc.). Each entry is one block in `packages/db/scripts/sync-service-pricing.ts`. Curated entries are the most likely to drift from upstream pricing. If you spot a stale rate, email `team@marginfront.com` with the service name and a link to the provider's current pricing page. *** ## What's NOT in the catalog If your agent uses a service we don't cover yet, two options: * **Map to a similar entry** with [`POST /v1/events/map-model`](./usage-events). Best for cases where one of our existing entries is "close enough" (e.g. mapping a custom OpenAI fine-tune to the base model's rate). * **Email us.** Send the service name and a link to the provider's pricing page to `team@marginfront.com`. We'll add it to the next catalog sync. In either case, events still land. Cost stays `null` (`NEEDS_COST_BACKFILL`) until the catalog catches up. The `map_model` endpoint backfills retroactively once the mapping is in place. *** ## Discovering canonical names programmatically Three ways to query the catalog from your tooling: **Raw HTTP:** ```bash Bash theme={null} curl "https://api.marginfront.com/v1/services?provider=google" \ -H "x-api-key: $MF_API_SECRET_KEY" ``` ```powershell PowerShell theme={null} curl.exe "https://api.marginfront.com/v1/services?provider=google" ` -H "x-api-key: $env:MF_API_SECRET_KEY" ``` **Node SDK:** ```typescript theme={null} const { data } = await client.services.list({ provider: "google" }); data.forEach((svc) => console.log(svc.canonicalName, svc.costUnit)); ``` **MCP** (Claude / Cursor / VS Code): > "List every Google service in the MarginFront catalog." The AI assistant calls `list_catalog_services` and renders the results. See the [Services Catalog API reference](./services) for the full endpoint documentation. # Usage Events Source: https://docs.marginfront.com/api-reference/usage-events The core endpoint — log what your agents do # Usage Events A **usage event** is a single measurement: at this time, this customer used this agent to do this much of this thing. Logging usage events is how MarginFront learns what to bill for. Every time your agent does work, you log an event. At the end of the billing period, MarginFront rolls them up, applies the customer's pricing plan, and generates an invoice. This is **the most important endpoint** in the whole API. Everything else (agents, signals, plans, subscriptions) is setup. This is the ongoing, every-day traffic. > **You don't need to set anything up first.** When you fire an event with a new `customerExternalId`, `agentCode`, or `signalName`, MarginFront creates the customer, agent, or signal automatically. Your existing user IDs from your own database flow straight through. The dashboard updates the moment the event lands. *** ## The endpoint ``` POST /v1/usage/record ``` **Authentication:** API key in the `X-API-Key` header (secret key required — `mf_sk_*`). **Batch:** Even for a single event, the body wraps records in an array. You can send 1-100 records per request. ```json theme={null} { "records": [ { ...one usage event... }, { ...another usage event... } ] } ``` *** ## Fields per record Each record uses one of two shapes: * **Single-service shape**: one event, one underlying service. The 90% case. Required fields: top-level `customerExternalId`, `agentCode`, `signalName`, `model`, `modelProvider` (plus volume). * **Multi-service shape**: one event, multiple underlying services contributing to one business outcome (e.g., one report that called Claude AND queried Google Maps). Required fields: `customerExternalId`, `agentCode`, `signalName`, `services[]`. Top-level `model`/`modelProvider`/volume are omitted; each service entry carries its own. The two shapes are mutually exclusive. Send single-service fields OR `services[]`, never both, never neither. Mixing shapes returns a `400 Bad Request` with a clear English message. ### Always required (both shapes) | Field | Type | Description | | -------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `customerExternalId` | string | Your customer's ID in your system (not the MarginFront internal UUID). If MarginFront has not seen this ID before, the customer record is created automatically. | | `agentCode` | string | A stable identifier for the agent or product that did the work. If MarginFront has not seen this code before, the agent record is created automatically. | | `signalName` | string | The billing unit being tracked (e.g. `messages`, `report-pages`). Matches the signal's `shortName`. If MarginFront has not seen this name before, the signal record is created automatically. | ### Required for single-service shape (or use `services[]` instead) | Field | Type | Description | | --------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `model` | string | The model identifier from your provider. Pass whatever your provider SDK returned: `response.model` from OpenAI/Anthropic, the service SKU for non-LLM tools. Case-insensitive, whitespace trimmed. Examples: `"gpt-4o"`, `"claude-sonnet-4-6"`, `"twilio-sms"`, `"textract-standard"`. | | `modelProvider` | string | The provider name, **lowercase**. This tells MarginFront which pricing table to look in. Required because different providers can have models with the same name — without this field, MarginFront can't tell if `"gpt-4o"` means OpenAI's or a fine-tune on another platform. Examples: `"openai"`, `"anthropic"`, `"google"`, `"twilio"`, `"aws"`. | ### Required for multi-service shape (or use single-service fields instead) | Field | Type | Description | | ---------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `services` | array (≥ 1 entry) | One entry per underlying service that contributed to this business outcome. Each entry has its own `model`, `modelProvider`, and volume fields (same shape as the top-level single-service fields). The parent event represents the customer-facing thing; each entry is one cost line under it. | Each entry in `services[]` accepts: | Field | Type | Required | Description | | ------------------ | ------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `model` | string | Yes | Same shape as the top-level `model` for this service. | | `modelProvider` | string | Yes | Same shape as the top-level `modelProvider` (lowercase). | | `inputTokens` | integer (≥ 0) | LLM only | Input tokens for this LLM service. Must be a whole integer. | | `outputTokens` | integer (≥ 0) | LLM only | Output tokens for this LLM service. Must be a whole integer. | | `cacheReadTokens` | integer (≥ 0) | LLM, optional | Prompt-cache read tokens (cache hits) for this LLM service. Priced at the cheaper cache-read rate instead of the full input rate. Pull from your provider response (Anthropic `cache_read_input_tokens`, OpenAI `prompt_tokens_details.cached_tokens`). Omit if unknown. | | `cacheWriteTokens` | integer (≥ 0) | LLM, optional | Prompt-cache write tokens (cache creation) for this LLM service. Priced at the cache-write rate. Pull from your provider response (Anthropic `cache_creation_input_tokens`). Omit if unknown. | | `quantity` | number (≥ 0) | non-LLM only | Billing units for this non-LLM service. Whole numbers or fractions both work — send a fractional amount (e.g. 14.137 seconds of compute) exactly as it is, no rounding needed. | ### Optional fields | Field | Type | Default | Description | | ------------------ | -------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `inputTokens` | integer (≥ 0) | — | Single-service shape only. Number of input (prompt) tokens. Must be a whole integer. Required for LLM cost calculation. Ignored for non-LLM services. | | `outputTokens` | integer (≥ 0) | — | Single-service shape only. Number of output (completion) tokens. Must be a whole integer. Required for LLM cost calculation. Ignored for non-LLM services. | | `cacheReadTokens` | integer (≥ 0) | — | Single-service shape only, LLM-only. Prompt-cache read tokens (cache hits). Priced at the cheaper cache-read rate instead of the full input rate. Pull from your provider response (Anthropic `cache_read_input_tokens`, OpenAI `prompt_tokens_details.cached_tokens`). Omit it and cache contributes \$0, exactly as before. | | `cacheWriteTokens` | integer (≥ 0) | — | Single-service shape only, LLM-only. Prompt-cache write tokens (cache creation). Priced at the cache-write rate. Pull from your provider response (Anthropic `cache_creation_input_tokens`). Omit if unknown. | | `quantity` | number (≥ 0) | `1` | Single-service: billing units for non-LLM services. Multi-service: signal-level count (e.g. 1 for one report). Per-service volume goes inside each `services[]` entry. Whole numbers or fractions both work — send fractional units exactly as they are, no rounding needed. | | `usageDate` | ISO 8601 string | now | When the usage actually happened. Use this for back-filling historical events. | | `metadata` | object | `{}` | Custom key-value pairs. Stored but not interpreted. | | `environment` | string | — | Where the event happened: `production`, `staging`, `development`, or `testing`. Lets MarginFront split your AI spend into Cost of Goods Sold vs Research & Development. See [Tagging events for cost classification](#tagging-events-for-cost-classification). Optional — leave it off and nothing about your events changes. | | `idempotencyKey` | string (≤ 255 chars) | — | A stable ID you pick for this event so a retry can't double-count it. Send the same key twice for the same account and the second request is treated as a replay: no new event is created and the response hands back the original event's `eventId` and cost. Leave it off and every request is recorded on its own (the default). See [Recording each event exactly once](#recording-each-event-exactly-once). | *** ## Tagging events for cost classification Finance teams often need to split AI spend into **Cost of Goods Sold** (what it costs to serve paying customers) and **Research & Development** (internal experiments and test runs). To do that, add an `environment` to each record: ```json theme={null} { "records": [ { "customerExternalId": "acme-001", "agentCode": "cs-bot-v2", "signalName": "messages", "model": "gpt-4o", "modelProvider": "openai", "inputTokens": 523, "outputTokens": 117, "environment": "production" } ] } ``` The first time a signal sees an event that carries an `environment`, MarginFront sorts that signal into a cost category for you: | `environment` you send | Cost category it becomes | | ---------------------- | ----------------------------------------------------------- | | `production` | Production — Cost of Goods Sold | | `development` | Development — Research & Development | | `testing` | Development — Research & Development | | `staging` | Left unset on purpose — choose it yourself in the dashboard | This automatic sorting only happens while the signal is still unclassified. Once you set a category by hand in the dashboard, your choice sticks — later events still record their `environment`, but they no longer change the category. `environment` is optional. Leave it off and your events flow through exactly as before. You can see the breakdown on your dashboard's Cost Management page. *** ## Recording each event exactly once Sometimes the same event gets sent twice. Your code retries after a timeout, a worker restarts and replays its queue, or a network blip leaves you unsure the first request landed. Without protection, each resend creates a second event — and your customer's bill reads too high. To prevent that, give the event an `idempotencyKey`: a short, stable ID you pick that stays the same across retries of that one event. Send it on the record and MarginFront counts the event once, no matter how many times the request arrives. ```json theme={null} { "records": [ { "customerExternalId": "acme-001", "agentCode": "cs-bot-v2", "signalName": "messages", "model": "gpt-4o", "modelProvider": "openai", "inputTokens": 523, "outputTokens": 117, "idempotencyKey": "chat-turn-9f8c2a" } ] } ``` What happens: * **The first time MarginFront sees the key:** the event is recorded normally. * **Any later request with the same key:** it's treated as a replay. No second event is created, and the response returns the **original** event — same `eventId`, same `totalCostUsd` — so a retry looks identical to the call that already worked. A few things to know: * **Pick a key that's the same for the event but different across events.** A good key is something you can recompute for the same piece of work: an order ID, a message ID, or a hash of the request. Two different events must not share a key, or the second one is dropped as a duplicate. * **Keys belong to your account.** The same key value used by a different account never collides, so you don't have to coordinate keys across customers. * **Retrying after a failure still works.** Dedup only kicks in once an event has actually been recorded. If your first request failed before the event was saved, sending it again with the same key records it — you won't lose an event just because you reused the key. * **It's optional.** Leave `idempotencyKey` off and every request is recorded on its own — the same behavior as before. A missing key never deduplicates. * **Max length is 255 characters.** *** ## LLM vs non-LLM events vs multi-service events All three patterns use the same endpoint. The difference is which fields carry the "what happened" information. **Single-service LLM event** (OpenAI, Anthropic, Google, etc.) — cost is based on tokens: ```json theme={null} { "customerExternalId": "acme-001", "agentCode": "cs-bot-v2", "signalName": "messages", "model": "gpt-4o", "modelProvider": "openai", "inputTokens": 523, "outputTokens": 117 } ``` **Single-service non-LLM event** (Twilio, AWS Textract, DALL-E, etc.) — cost is based on quantity: ```json theme={null} { "customerExternalId": "acme-001", "agentCode": "notification-agent", "signalName": "sms_sent", "model": "twilio-sms", "modelProvider": "twilio", "quantity": 3 } ``` **Multi-service event**: one business outcome backed by multiple underlying services. Example: a place-report agent searches Google for context, asks Gemini to analyze + write the report, then calls the Places API to attach map metadata. Three services, one report. ```json theme={null} { "customerExternalId": "acme-001", "agentCode": "place-report-bot", "signalName": "place-reports", "quantity": 1, "services": [ { "model": "google-search", "modelProvider": "google", "quantity": 1 }, { "model": "gemini-2.5-pro", "modelProvider": "google", "inputTokens": 4200, "outputTokens": 1500, "quantity": 1 }, { "model": "google-maps-places", "modelProvider": "google", "quantity": 3 } ] } ``` The parent event represents the customer-facing thing (one place report). Each entry in `services[]` becomes one cost line under it. The dashboard shows ONE event in the live feed; the "Cost by service" chart on the Cost tab shows where the rolled-up total split. The Gemini entry carries `inputTokens`, `outputTokens`, AND `quantity: 1` because LLM `services[]` entries can track all three at once: tokens for cost, quantity for call-count analytics. You can mix all three shapes in a single batch. *** ## Example curl calls **Single LLM event (OpenAI):** ```bash theme={null} curl -X POST https://api.marginfront.com/v1/usage/record \ -H "X-API-Key: mf_sk_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "records": [ { "customerExternalId": "acme-001", "agentCode": "cs-bot-v2", "signalName": "messages", "model": "gpt-4o", "modelProvider": "openai", "inputTokens": 523, "outputTokens": 117 } ] }' ``` **Single LLM event (Anthropic):** ```bash theme={null} curl -X POST https://api.marginfront.com/v1/usage/record \ -H "X-API-Key: mf_sk_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "records": [ { "customerExternalId": "acme-001", "agentCode": "research-agent", "signalName": "research_queries", "model": "claude-sonnet-4-6", "modelProvider": "anthropic", "inputTokens": 1024, "outputTokens": 512 } ] }' ``` **Non-LLM event (Twilio SMS):** ```bash theme={null} curl -X POST https://api.marginfront.com/v1/usage/record \ -H "X-API-Key: mf_sk_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "records": [ { "customerExternalId": "acme-001", "agentCode": "notification-agent", "signalName": "sms_sent", "model": "twilio-sms", "modelProvider": "twilio", "quantity": 5 } ] }' ``` **Multi-service event (place-report agent: search + LLM + map enrichment):** ```bash theme={null} curl -X POST https://api.marginfront.com/v1/usage/record \ -H "X-API-Key: mf_sk_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "records": [ { "customerExternalId": "acme-001", "agentCode": "place-report-bot", "signalName": "place-reports", "quantity": 1, "services": [ { "model": "google-search", "modelProvider": "google", "quantity": 1 }, { "model": "gemini-2.5-pro", "modelProvider": "google", "inputTokens": 4200, "outputTokens": 1500, "quantity": 1 }, { "model": "google-maps-places", "modelProvider": "google", "quantity": 3 } ] } ] }' ``` **Mixed batch (single + multi-service in one call):** ```bash theme={null} curl -X POST https://api.marginfront.com/v1/usage/record \ -H "X-API-Key: mf_sk_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "records": [ {"customerExternalId": "acme-001", "agentCode": "cs-bot-v2", "signalName": "messages", "model": "gpt-4o", "modelProvider": "openai", "inputTokens": 100, "outputTokens": 50}, {"customerExternalId": "acme-001", "agentCode": "cs-bot-v2", "signalName": "messages", "model": "claude-sonnet-4-6", "modelProvider": "anthropic", "inputTokens": 200, "outputTokens": 75}, {"customerExternalId": "beta-corp", "agentCode": "doc-analyzer", "signalName": "pages_processed", "model": "textract-standard", "modelProvider": "aws", "quantity": 15}, {"customerExternalId": "acme-001", "agentCode": "place-report-bot", "signalName": "place-reports", "quantity": 1, "services": [{"model": "google-search", "modelProvider": "google", "quantity": 1}, {"model": "gemini-2.5-pro", "modelProvider": "google", "inputTokens": 4200, "outputTokens": 1500, "quantity": 1}, {"model": "google-maps-places", "modelProvider": "google", "quantity": 3}]} ] }' ``` *** ## Response format (`200 OK`) The endpoint always returns `200 OK` — even if some records failed. **You must check the response body** to know what actually happened. ```json theme={null} { "processed": 3, "successful": 2, "failed": 1, "results": { "success": [ { "customerExternalId": "acme-001", "agentCode": "cs-bot-v2", "signalName": "messages", "model": "gpt-4o", "modelProvider": "openai", "inputTokens": 523, "outputTokens": 117, "quantity": 1, "totalCostUsd": "0.0024780000", "eventId": "8a7b6c5d-...", "rawEventId": "f1e2d3c4-...", "timestamp": "2026-04-12T20:10:54.218Z" }, { "customerExternalId": "acme-001", "agentCode": "place-report-bot", "signalName": "place-reports", "services": [ { "model": "google-search", "modelProvider": "google", "inputTokens": null, "outputTokens": null, "quantity": 1, "usageCost": "0.0050000000", "eventStatus": "PROCESSED" }, { "model": "gemini-2.5-pro", "modelProvider": "google", "inputTokens": 4200, "outputTokens": 1500, "quantity": 1, "usageCost": "0.0123000000", "eventStatus": "PROCESSED" }, { "model": "google-maps-places", "modelProvider": "google", "inputTokens": null, "outputTokens": null, "quantity": 3, "usageCost": "0.0510000000", "eventStatus": "PROCESSED" } ], "totalCostUsd": "0.0683000000", "eventId": "9b8c7d6e-...", "rawEventId": "g2h3i4j5-...", "timestamp": "2026-04-26T15:22:01.118Z" } ], "failed": [ { "record": { "customerExternalId": "acme-001", "model": "my-custom-llm", "modelProvider": "custom", "...": "..." }, "code": "NEEDS_COST_BACKFILL", "stored": true, "eventId": "a1b2c3d4-...", "rawEventId": "e5f6g7h8-...", "error": "Model \"my-custom-llm\" (provider \"custom\") not found in service_pricing. Event stored with usageCost: null. Map this model in the MarginFront dashboard under \"Needs attention\" to calculate cost and backfill this event." } ] } } ``` ### Success entry fields (single-service) | Field | Description | | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | | `customerExternalId` | Echoed from your request | | `agentCode` | Echoed from your request | | `signalName` | Echoed from your request | | `model` / `modelProvider` | Echoed from your request | | `inputTokens` / `outputTokens` | Echoed from your request | | `quantity` | Echoed (or `1` if you didn't send it) | | `totalCostUsd` | Calculated cost in USD (string with 10 decimal places) | | `eventId` | UUID of the `signal_events` row — use this for lookups | | `rawEventId` | UUID of the `raw_ingest_events` audit row | | `timestamp` | When the event was processed | | `created` | `{customer, agent, signal}` booleans saying which records this event created. See [Knowing what an event created](#knowing-what-an-event-created). | ### Success entry fields (multi-service) | Field | Description | | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `customerExternalId` | Echoed | | `agentCode` | Echoed | | `signalName` | Echoed | | `services` | Array. One entry per service that contributed. Each entry has `model`, `modelProvider`, volume fields, `usageCost`, `eventStatus`. | | `totalCostUsd` | Rolled-up parent cost (sum of resolved `services[].usageCost`). String with 10 decimal places. | | `eventId` | UUID of the parent `signal_events` row | | `rawEventId` | UUID of the audit row | | `timestamp` | When the event was processed | The legacy top-level `model` / `modelProvider` / `inputTokens` / `outputTokens` fields are NOT present on multi-service success entries (they live inside each `services[]` entry instead). ### Failed entry fields | Field | Description | | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `record` | The original record you sent (echoed back so you can identify it). For multi-service, this includes the `services[]` array you sent. | | `code` | Why it failed: `NEEDS_COST_BACKFILL`, `MISSING_VOLUME_DATA`, `INTERNAL_ERROR`, or `VALIDATION_ERROR`. For multi-service, this is the parent's worst-case status (NEEDS\_COST\_BACKFILL > MISSING\_VOLUME\_DATA > PROCESSED). | | `stored` | `true` = event is saved in the system (don't retry). `false` = event was NOT saved (safe to retry). | | `eventId` | UUID of the parent signal\_events row (only present when `stored: true`) | | `rawEventId` | UUID of the raw audit row (present for most failures) | | `error` | Human-readable description of what happened. For multi-service with multiple failing services, messages are joined with " \| ". | | `created` | Same `{customer, agent, signal}` booleans as a success entry. A stored failure still created its records. Absent when the record never got that far, and on generic `INTERNAL_ERROR` failures. | | `servicesStatus` | Multi-service only. Array of `{model, modelProvider, eventStatus}` per service so you can see which specific services need fixing. | ### Error codes explained | Code | Stored? | What happened | What to do | | --------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `NEEDS_COST_BACKFILL` | Yes | The event was saved with `cost: null` for one of two reasons: (1) the model+provider combination isn't in the pricing table at all, or (2) the model+provider IS in the table but has no rate for cache tokens, and your event included cache tokens (`cacheReadTokens` or `cacheWriteTokens`) — MarginFront flags the event instead of quietly pricing that cache at \$0. The `error` message tells you which case you hit. | **Do NOT retry.** Both cases show up in the MarginFront dashboard → Usage Events → "Needs attention." If the model is unknown, map it to a known one. If the model is known but missing cache-token pricing, email `team@marginfront.com` to have cache pricing added for that model. Once resolved, cost is backfilled automatically and future events auto-resolve. | | `INTERNAL_ERROR` | No | Something broke on our side during processing. The event was not saved to signal\_events. | **Safe to retry.** The raw audit row may exist (check `rawEventId`). | | `VALIDATION_ERROR` | No | Bad input — a required field is missing or has the wrong type. | **Fix the request and resend.** Check the `error` message for which field. | *** ## What happens when the model isn't recognized MarginFront **never drops events**. If you send a `model` + `modelProvider` combination that isn't in the pricing table: 1. The event is **stored** in `signal_events` with `usageCost: null` (not zero — null preserves the ambiguity for backfill). 2. The response includes the event in `results.failed[]` with `code: "NEEDS_COST_BACKFILL"` and `stored: true`. 3. The "Needs attention" tile on the dashboard `/metrics-events` page shows a count of these events. 4. Click through to see the events grouped by model+provider, with context (which agent, customer, signal sent them). 5. Pick a known model from the dropdown, click "Map & backfill" — MarginFront creates a permanent mapping and retroactively calculates cost for every affected event. 6. Future events with that model+provider auto-resolve — no manual step needed again. **Do NOT retry events with `stored: true`.** They're already in the system. Retrying would create duplicates. *** ## Mapping unknown models (API endpoints) These endpoints power the dashboard drill-down page. You can also call them directly. ### List unknown-cost event groups ``` GET /v1/events/needs-cost-backfill ``` **Query parameters (all optional):** | Param | Type | Default | Description | | ----------- | -------- | ----------- | ------------------------------ | | `startDate` | ISO 8601 | 30 days ago | Filter events after this date | | `endDate` | ISO 8601 | now | Filter events before this date | **Response:** ```json theme={null} { "groups": [ { "model": "my-custom-llm", "provider": "custom", "count": 3, "oldestEventDate": "2026-04-11T22:12:14.000Z" } ], "totalEvents": 3 } ``` ### Map an unknown model to a known one ``` POST /v1/events/map-model ``` **Request body:** ```json theme={null} { "sourceModel": "my-custom-llm", "sourceProvider": "custom", "targetPricingId": "550e8400-e29b-41d4-a716-446655440000" } ``` Or use model+provider to identify the target instead of the pricing row ID: ```json theme={null} { "sourceModel": "my-custom-llm", "sourceProvider": "custom", "targetModel": "gpt-4o", "targetProvider": "openai" } ``` **Response:** ```json theme={null} { "backfilled": 3, "mappingId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" } ``` This creates an org-scoped mapping for the source model (so future events auto-resolve for this org) and retroactively calculates cost for all matching events in a single transaction. *** ## Listing events Query your recorded usage events. Supports filtering and pagination. Use this when you need to see individual events — per-event drill-downs, audit trails, debugging a specific customer's bill, or feeding a BI tool. ``` GET /v1/events ``` **Authentication:** `x-api-key` header. Either a secret key (`mf_sk_*`) or a publishable key (`mf_pk_*`) works — this is a read-only endpoint. One exception: an **ingest-only** secret key gets a clean 403 here, because it can only send usage and read your spend controls. ### Query parameters All optional. Without any, you get the most recent 20 events for your org. | Param | Type | Default | Description | | ------------ | --------------- | ------- | ---------------------------------------------------------------------------------------------------- | | `page` | integer (≥ 1) | `1` | Page number. | | `limit` | integer (1-100) | `20` | Results per page. Capped at 100. | | `customerId` | UUID | — | Filter to events for one customer. Use MarginFront's internal customer UUID (not your `externalId`). | | `agentId` | UUID | — | Filter to events for one agent. | | `signalId` | UUID | — | Filter to events for one signal. | | `startDate` | ISO 8601 string | — | Only events on or after this timestamp. | | `endDate` | ISO 8601 string | — | Only events on or before this timestamp. | ### Example ```bash theme={null} curl "https://api.marginfront.com/v1/events?customerId=bc8eceda-50e4-4138-b2a2-47e92d344540&limit=20" \ -H "x-api-key: mf_sk_test_YOUR_KEY" ``` ### Response (`200 OK`) ```json theme={null} { "results": [ { "id": "b137e553-d67e-4a85-8e34-d67522c02752", "customerExternalId": "acme-001", "customerId": "bc8eceda-50e4-4138-b2a2-47e92d344540", "subscriptionId": null, "organizationId": "d2c03528-67db-4e2f-9986-88c11998b46f", "agentId": "3a1948ea-a701-4752-8c3d-df6c2f5833cf", "signalId": "9ab845c3-0d35-42ca-86cf-58c886af883c", "rawIngestEventId": "2e038dba-7875-4c67-8f32-f89d3b3d7c9f", "usageDate": "2026-04-14T18:44:53.075Z", "quantity": "1", "metadata": {}, "usageCost": "0.00225", "usageCostData": { "gpt-4o/input": { "cost": 0.00125, "units": 500, "costPerUnit": 0.0000025 }, "gpt-4o/output": { "cost": 0.001, "units": 100, "costPerUnit": 0.00001 } }, "eventProcessed": "PROCESSED", "eventProcessedAt": "2026-04-14T18:44:54.263Z", "createdAt": "2026-04-14T18:44:53.075Z", "updatedAt": "2026-04-14T18:44:53.075Z", "signal": { "id": "9ab845c3-0d35-42ca-86cf-58c886af883c", "name": "Messages Processed", "shortName": "messages" } } ], "page": 1, "limit": 20, "totalPages": 42, "totalResults": 837 } ``` ### Understanding the event payload Most fields are self-explanatory. A few are easy to trip over: * **`usageCost` is a string**, not a number (e.g. `"0.00225"`). We use strings to preserve decimal precision — prices can have many significant digits and JSON numbers would round. Convert with `parseFloat()` or `Number()` before doing math. * **`usageCostData` is the itemized cost breakdown** — this is where per-model and per-dimension details live. Each key is `"/"` (e.g. `"gpt-4o/input"`, `"gpt-4o/output"`). Each value has: * `cost` — dollar amount for this line item (number) * `units` — tokens for LLMs, or whatever quantity dimension was billed (number) * `costPerUnit` — the rate applied (number) The top-level `usageCost` equals the sum of every `cost` inside `usageCostData`. If you need to ask "how many input tokens did this event use?", read `usageCostData["/input"].units`. If the event used multiple models or mixed LLM+non-LLM services, there will be multiple keys. * **`quantity` is a string** (same precision reason as `usageCost`). * **`signal`** is the nested signal object (id, name, shortName). Handy for display without a second lookup. * **`subscriptionId`** is `null` when the customer had no active subscription at the time of the event. ### Event processing states The `eventProcessed` field tells you where an event is in its lifecycle: | Value | Meaning | | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `"PROCESSED"` | Cost calculated and stored. Ready to be included in invoices. | | `"NEEDS_COST_BACKFILL"` | Event stored but cost is `null`. Either the model+provider wasn't found in the pricing table, or it was found but has no rate for the cache tokens your event reported (so MarginFront won't quietly price that cache at \$0). Use `GET /v1/events/needs-cost-backfill` to find these. For an unknown model, `POST /v1/events/map-model` resolves it; for a known model missing cache pricing, email `team@marginfront.com` to have cache pricing added. | | `"PENDING"` | Still being processed. Should transition within seconds. | | `"ERROR"` | Cost calculation failed for a reason other than an unknown model. Rare — inspect the event in the dashboard. | When `eventProcessed` is `"NEEDS_COST_BACKFILL"`, `usageCost` is `null` and `usageCostData` is empty. See [Mapping unknown models](#mapping-unknown-models-api-endpoints) above for the resolution flow. *** ## Auto-provisioning If you log a usage event for a `customerExternalId` or `agentCode` that MarginFront has never seen, it will **auto-create** a minimal customer and/or agent on the spot. Convenient for prototyping — but means you won't get an error for typos. Double-check in the dashboard if things "work" but show up with a name you don't recognize. ### Knowing what an event created Every result entry carries a `created` object saying which records that event brought into being: ```json theme={null} "created": { "customer": true, "agent": false, "signal": true } ``` That reads as: this was the first event for that `customerExternalId` and that `signalName`, so MarginFront made both. The agent already existed. Four things to know before you branch on it: * **Failed-but-stored records have it too.** Records are created before pricing runs, so an event that lands in `results.failed[]` with `NEEDS_COST_BACKFILL` still created its customer, agent, and signal, and its entry says so. * **A missing `created` isn't the same as `false`.** A replay caught by `idempotencyKey` returns no `created` field at all, because a replay creates nothing and knows nothing about the original. Older servers leave it out too. Treat a missing field as "no information," and treat `false` as a real answer meaning "that one already existed." * **Reviving a deleted record reports `false`.** Send an event for a customer, agent, or signal you deleted in the dashboard and MarginFront brings the original back, keeping its ID and its billing history, instead of making a duplicate. Because the record already existed, the flag is `false`. * **A subscription deleted along with a customer stays deleted.** Reviving the customer doesn't revive it. Recreate the subscription in the dashboard if you want billing to pick up again. These flags catch typos early. A `customerExternalId` you expected to already exist coming back with `"customer": true` usually means you misspelled it. *** ## Common HTTP errors | Status | Cause | | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400 Bad Request` | The batch is empty, has more than 100 records, or a record is missing a required field (`customerExternalId`, `agentCode`, `signalName`, `model`, or `modelProvider`). The response body tells you which field. | | `401 Unauthorized` | API key is missing or invalid. | | `403 Forbidden` | You used a publishable key (`mf_pk_*`). Usage recording requires a secret key (`mf_sk_*`). | *** ## Using the Node SDK The `@marginfront/sdk` package wraps this endpoint. See the [SDK README](../../packages/sdk/README.md) for full documentation. Quick example: ```typescript theme={null} import { MarginFrontClient } from "@marginfront/sdk"; const client = new MarginFrontClient("mf_sk_test_YOUR_KEY"); // Single LLM event await client.usage.record({ customerExternalId: "acme-001", agentCode: "cs-bot-v2", signalName: "messages", model: "gpt-4o", modelProvider: "openai", inputTokens: 523, outputTokens: 117, }); // Batch await client.usage.recordBatch([ { customerExternalId: "acme-001", agentCode: "cs-bot-v2", signalName: "messages", model: "gpt-4o", modelProvider: "openai", inputTokens: 100, outputTokens: 50, }, { customerExternalId: "beta-corp", agentCode: "doc-analyzer", signalName: "pages_processed", model: "textract-standard", modelProvider: "aws", quantity: 15, }, ]); ``` With the default `fireAndForget: true` setting, `usage.record()` never throws — network errors retry automatically via a local buffer. See the SDK docs for details. *** ## When to log events Log an event **right after** the work is done. The sooner MarginFront sees it, the sooner it shows up in analytics and cost projections. For high-volume scenarios, the SDK handles batching and retries automatically with its built-in buffer. If you're calling the REST API directly, batch up to 100 records per request and send them every few seconds. # Core Concepts Source: https://docs.marginfront.com/concepts The building blocks of MarginFront: agents, customers, signals, events, and how they connect # Core Concepts **MarginFront watches what your AI agents do, tracks what it costs you, and bills your customers.** The built-in pricing catalog covers 300+ LLM models and a curated set of non-LLM services (search, geocoding, SMS, voice, email), so cost calculation works out of the box for most agents. Anything not in the catalog still records the event with `cost: null` and is mappable from the dashboard's **Needs Attention** flow. That's it. Everything below is just naming the moving parts. *** ## The building blocks ### 1. Agent Your AI product. The thing that does work for your customers. A customer support bot is one agent. A research assistant is another. A document analyzer is a third. If you only sell one product, you have one agent. If you sell three products that price differently, you have three agents. Each agent has a **code** (a short handle you pick, like `cs-bot` or `research-v2`). You use this code when logging usage events -- it tells MarginFront which product did the work. You create agents in the dashboard. ### 2. Customer Who you're billing. One of YOUR end users or companies. Each customer has an **externalId** -- the user ID from your own system (like `acme-001` or `user_847`). This is how MarginFront connects usage events to the right customer without you having to memorize MarginFront's internal UUIDs. You can create customers through the dashboard, the API, the SDK, or the MCP tool. ### 3. Signal What you measure. A deliverable or outcome your agent produces. Good signals (things your customer cares about): * `messages` -- messages sent by a chatbot * `reports-generated` -- full reports produced * `pages-processed` -- document pages analyzed Bad signals (internal details your customer doesn't care about): * `gpt-4o-call` -- that's an internal cost, not a deliverable * `api-requests` -- too technical, doesn't map to value Signals are what customers see on their invoices. They're the answer to "what am I paying for?" You create signals in the dashboard, and each signal belongs to one agent. ### 4. Event A record that something happened. This is the core data type -- everything else is built from events. An event says: "Agent `cs-bot` sent 1 message for customer `acme-001` using `gpt-4o` from `openai`, consuming 523 input tokens and 117 output tokens." You log events by calling `POST /v1/usage/record` (or `mf.usage.record()` with the SDK). This is the endpoint your code calls all day, every day once you're live. Everything else -- costs, revenue, invoices, margins -- is calculated from these events. ### 5. Service Costs Calculated automatically. You don't set these. When you log an event with a model and provider (like `gpt-4o` from `openai`), MarginFront looks up the token pricing in its built-in table of 1100+ services (LLM models plus non-LLM APIs like Cloud Run, Twilio, Google Places). It multiplies your input tokens by the input price, your output tokens by the output price (or applies the per-call rate for non-LLM services), and stores the result as the service cost for that event. Your customers never see service costs. They're for YOUR margin dashboard -- so you can answer "how much did it actually cost me to serve this customer this month?" If MarginFront doesn't recognize the model you sent, the event still saves -- but with `cost = null` (not zero, not dropped). You can map the unknown model to a known one in the dashboard, and MarginFront backfills the cost retroactively. Events are never thrown away. > **Discover what's in the catalog before sending events.** To avoid the `NEEDS_COST_BACKFILL` cycle, list the catalog with `GET /v1/services?provider=` (or `client.services.list({ provider: 'google' })` from the SDK, or `list_catalog_services` from the MCP server) and pick the canonical name your code should send. See the [Services Catalog reference](/api-reference/services) and the [Supported Services overview](/api-reference/supported-services). ### 6. Pricing Plan Turns your costs into prices. This is where you decide what to charge. A pricing plan sets the rate per unit of each signal. "I charge $0.10 per message" or "$2.00 per report." Think of it like the "Starter Plan" or "Pro Plan" you'd show on your pricing page. Created in the dashboard, tied to an agent. ### 7. Subscription Ties a customer to an agent through a pricing plan. "Starting April 1, bill Acme Inc for the `cs-bot` using the Starter Plan, on a monthly billing cycle." Without a subscription, MarginFront still tracks costs (what things cost you), but it can't calculate revenue (what you charge the customer) or generate invoices. ### 8. Invoice Auto-generated at the end of each billing cycle from a subscription's usage. Shows line items per signal with quantity and price. Example: "Messages: 150 x $0.10 = $15.00." The customer sees what they used and what they owe. You see what it cost you, what you charged, and the margin. ### 9. API Key Your credential for talking to MarginFront's API. Starts with `mf_sk_` (secret key -- server-side only, full access). The API key identifies your organization -- you never need to pass an org ID in your requests. Created in the dashboard under **Build > API keys**. Never put your secret key in frontend code or commit it to git. *** ## The metric vs service cost mental model > **Signals are what your CUSTOMER sees. Service costs are what YOU see.** > > A customer sees their invoice: > `Messages: 150 x $0.10 = $15.00` > > You see your margin dashboard: > `Messages: 150 events, $2.34 LLM cost, $15.00 revenue, 84% margin` > > Same events, two perspectives. Signals face outward (billing). Service costs face inward (profitability). *** ## Pricing Strategy Types A pricing strategy is one specific rule inside a pricing plan. Every strategy has a **charge type** that decides WHEN the customer is billed and a **pricing model** that decides HOW the number is calculated. ### Charge types (when to bill) There are four charge types. Each answers a different question. | Charge type | When it fires | Example | | ------------ | ----------------------------------------------------------- | ----------------------------------------------------------------- | | `usage` | Every time an event comes in. Priced per unit of a signal. | "Charge \$0.10 per message, after it's sent." | | `recurring` | Once per billing cycle, whether the customer used anything. | "Charge \$99 on the first of every month." | | `seat_based` | Once per billing cycle, priced by `seatsCount` on the sub. | "Charge $10 per seat per month. Customer has 5 seats: $50/month." | | `onetime` | Once, on the subscription start date. Never again. | "Charge \$500 setup fee when the customer signs up." | ### Pricing models (how to calculate) Usage strategies route through one of four pricing models. * **`flat`** Simple rate per unit. `quantity × rate`. * **`graduated`** Tiered with progressive rates. Units 1 to 10 at one rate, 11 to 50 at another, 51+ at a third. Each unit pays the rate for ITS tier. * **`volume`** Tiered with one rate for all units. All units pay the rate of the tier the TOTAL quantity landed in. * **`credit_pool`** Flat fee for a pool of units. Per-unit overage after the pool is used up. Often called "commitment + burst." ### Credit pools are a real countdown A credit pool isn't just invoice math. From the moment a credit-pool subscription has a billing period, MarginFront keeps a live countdown: every usage event draws its quantity from the pool as it lands, and you can read the remaining balance at any moment from the dashboard, the API (`GET /v1/credit-balances`), the SDK (`client.creditBalances`), or the MCP server (`get_credit_balances`). Each new billing period refills the pool; unused units don't roll over. Three things to know about the countdown: 1. **Nothing stops at zero.** The pool is a meter, never a breaker. A customer past their pool keeps working, and every unit past the pool bills at the plan's overage rate. If your product should stop at zero, your code makes that call by reading the balance first. See the [Credit Pools recipe](/recipes/credit-pools) for the pattern. 2. **Your team gets emails as the pool drains.** Four stages: 50% used, 80% used, 95% used, and empty. One email per stage per billing period, and only the highest newly-crossed stage sends. They go to your organization's billing email if one is set, otherwise to every owner and finance member. Emails for a single subscription can be paused (the countdown keeps counting; only the emails stop) and resumed anytime. 3. **You can add units mid-period.** A top-up raises the customer's remaining units right away, never expires, and shows up in the same countdown everywhere. See the [Credit Balances API reference](/api-reference/credit-balances) for the write endpoints. ### Per-window-aggregate billing Usage strategies bill on the TOTAL quantity for the window, not per event. That means: * One event with `quantity: 1500` bills identically to three events with `quantity: 500` each. * MarginFront adds up all the events in the billing period, feeds the total through the pricing model, and produces one invoice line. You never have to worry about "did I send too many events" from a billing math standpoint. More events just means more detail in the history. The total is what lands on the invoice. ### Cost-only metrics You can skip pricing entirely. If a signal has no pricing strategy linked to it, events still come in and cost still tracks, but the revenue contribution for that signal is zero (not unknown, not missing: explicitly zero). Useful for: * Tracking infrastructure costs you don't pass through to customers. * Shadow-logging new signals you haven't decided how to price yet. * Internal tool usage that's part of your operating cost, not a line item. The cost side still flows to your margin dashboard. The customer never sees the metric on their invoice. *** ## The four labels of revenue MarginFront reports four different dollar amounts for "revenue," each answering a different question. They are NOT the same number. Know which one you're looking at. ### Agent-Earned **What your agents produced this period, at the pricing strategy rates. No fees, no proration.** Formula: every usage event in the window, multiplied by the rate for its pricing strategy, summed up. Use it to spot activity trends early. Agent-Earned goes up the moment an event fires. No need to wait for invoices to finalize. ### Revenue **The full formula. What the customer owed you for the period.** Agent-Earned plus recurring fees plus seat fees plus onetime fees (all prorated if the subscription only partly overlapped the window). Use it for "what did we earn this month, invoiced or not." ### Billed **What you actually invoiced. Issued invoices, waiting on payment.** Sum of every invoice with status `issued` or `overdue` that was dated in the window. Draft invoices don't count here. Paid invoices don't count here (those move to Collected). Use it for A/R tracking. ### Collected **Cash landed. Payments that actually went through.** Sum of every payment that succeeded in the window, counted by the date the cash landed, minus anything refunded. A payment counts in the window the money arrived, not the window the invoice was dated. Use it for cashflow. ### Why four? Because they answer different questions and they come out in that order: 1. Agent does work. **Agent-Earned** goes up. 2. Billing cycle ends. An invoice finalizes. **Billed** goes up. 3. Customer pays. **Collected** goes up. If Agent-Earned is strong but Billed is weak, something is wrong with invoice generation. If Billed is strong but Collected is weak, something is wrong with collections. Separating the four makes the hole in your pipe obvious. *** ## Choosing your signal name and quantity Before you write a single line of code, you have one decision to make: **what unit do you want your customer to see on their invoice?** That answer becomes your signal name. Everything else falls out of it. Four rules cover the whole thing: 1. **Fire one event per business outcome.** When a report finishes, that's ONE event. When a call ends, that's ONE event. Not one per page, not one per minute, not one per token. MarginFront is not a streaming meter -- it's a "something happened" logger. 2. **The signal name IS the billing unit.** Bill per page? Name it `pages`. Bill per report? Name it `reports`. Bill per minute? Name it `minutes`. Whatever word shows up on your invoice is what you type into `signalName`. 3. **`quantity` is the count of that billing unit for this one event.** A 50-page report fired as `pages` has `quantity: 50`. The same report fired as `reports` has `quantity: 1`. Same LLM call underneath, same cost, different invoice line. 4. **Cost and revenue are decoupled.** MarginFront calculates your cost from `model` + `modelProvider` + tokens automatically. Your revenue is `quantity × your pricing plan rate`. The gap is your margin. Here's the same Claude call -- a 50-page market research report -- billed three different ways depending on what you named the signal: | Signal name | Quantity | Rate per unit | What the customer sees on the invoice | | ------------------- | -------- | ------------------ | ------------------------------------- | | `reports_generated` | 1 | \$50.00 per report | "1 report × $50.00 = $50.00" | | `report_pages` | 50 | \$2.00 per page | "50 pages × $2.00 = $100.00" | | `tokens_used` | 15,000 | \$0.01 per 1K | "15,000 tokens × $0.01 = $0.15" | Same Claude call. Same underlying token cost. Three completely different invoices. The choice of signal name is the contract between "the agent did work" and "the customer gets billed this much for that work." > **Fire one event per outcome. Not one per page, not one per minute.** > > This is the single easiest thing to get wrong. If your agent writes a 50-page report, send MarginFront ONE event with `quantity: 50` -- not 50 events with `quantity: 1`. The point of the `quantity` field is that you don't have to loop. Looping would multiply your bill, flood your analytics, and burn API calls for no reason. When in doubt, pick the signal name your customer would say out loud if you asked them "what am I paying for?" That word is the name. See [Example 3 in Tracking Events](/sdk/tracking-events#example-3-variable-quantity-event-quantity-n) for the exact code pattern, and the [Signals API reference](/api-reference/signals) for the full signal API. *** ## One event, multiple services The "fire one event per business outcome" rule runs into a wrinkle the moment your agent does anything realistic: most agent workflows use **several services** to produce one outcome. A cold-outreach agent finds a person via Exa, enriches them via Hunter.io, writes the message via Claude Opus, and sends it via Pipedream. A place-report agent searches via Google, analyzes with Gemini, and maps via the Places API. A document-processing agent might call Textract, then GPT-4o, then a translation model. From the customer's perspective each of these is ONE outcome (one outreach sent, one report generated, one document processed). From your cost perspective the outcome was backed by N underlying services. Send this as ONE event with a `services[]` array. Each entry is one underlying service. The parent event represents the customer-facing thing; each service entry becomes a per-service cost line under it. ```typescript theme={null} // One cold outreach to one prospect: Exa search + Hunter enrichment + // Claude Opus writing + Pipedream sending. Four services, one outcome. await mf.usage.record({ customerExternalId: "acme-001", agentCode: "outreach-bot", signalName: "outreaches-sent", quantity: 1, // ONE outreach (signal-level count) services: [ { // Exa people-search API to find the prospect model: "exa-search", modelProvider: "exa", quantity: 1, // 1 search call }, { // Hunter.io to enrich with verified email + role model: "hunter-enrich", modelProvider: "hunter", quantity: 1, // 1 enrichment call }, { // Claude Opus writes the personalized message model: "claude-opus-4-1", modelProvider: "anthropic", inputTokens: 4500, outputTokens: 1200, quantity: 1, // 1 LLM call (tracking the call count alongside tokens) }, { // Pipedream workflow sends the email model: "pipedream-workflow", modelProvider: "pipedream", quantity: 1, // 1 send }, ], }); ``` **Two shapes, one rule:** | Shape | When to use | Top-level fields | `services[]` | | -------------- | -------------------------------------------------------------------- | --------------------------------------------- | -------------------------------- | | Single-service | One event, one underlying service. The 90% case for chatbots. | `model` + `modelProvider` + volume (required) | omit | | Multi-service | One event, multiple underlying services contributing to one outcome. | `quantity` is signal-level (default 1) | one entry per service (required) | The two shapes are mutually exclusive. Send `model` + `modelProvider` OR send `services[]`, never both, never neither. If you mix shapes, MarginFront rejects the request with a clear English error pointing at the fix. **What it looks like on the dashboard:** ONE event in the live event feed. The Cost tab's "Cost by service" chart shows the rolled-up total split across the four services. The customer's invoice still bills per signal (one outreach = one charge at your pricing plan rate), regardless of how many services contributed. **Top-level `quantity` stays signal-level.** When you send `services[]`, the parent event's `quantity` is the count of the signal (one outreach = `quantity: 1`). Per-service volume lives inside each `services[]` entry: `inputTokens`/`outputTokens` for LLM, `quantity` for non-LLM, and both can coexist on an LLM entry when you want to track tokens AND the call count (e.g., when the same Claude call was retried or batched). This keeps the customer-facing billing math (`outreaches × rate`) clean and consistent regardless of how many services contributed. See the [SDK tracking-events guide](/sdk/tracking-events#example-5-multi-service-event-one-outcome-multiple-services) for the full developer walkthrough, the [non-LLM recipe](/recipes/non-llm#example-5-mixing-llm-and-non-llm-in-one-workflow) for a place-report variant, and the [Usage Events API reference](/api-reference/usage-events) for the full schema. *** ## Setup sequence For cost tracking only (the recommended starting point): ``` 1. Get a secret API key (mf_sk_*) from Build > API keys in the dashboard | v 2. Fire one usage event with customerExternalId, agentCode, signalName, model, and modelProvider. The customer, agent, and signal are auto-provisioned on this call. No prior setup required. | v 3. See it on the Dashboard (costs, breakdowns, what was auto-created) ``` That's it for cost tracking. The customer, agent, and signal records are created automatically the first time MarginFront sees their string IDs. You can rename and enrich them in the dashboard whenever you're ready. For revenue tracking and invoicing, add these explicit steps after cost tracking is working: ``` 4. Create a Pricing Plan | v 5. Add Pricing Strategies inside the plan | v 6. Link the Plan to the Agent | v 7. Create a Subscription tying the customer to the plan | v 8. Subsequent events fire both cost AND revenue calculations. Invoices follow on the billing cycle. ``` Steps 4 through 7 are explicit (no auto-provisioning). Until you create them, MarginFront tracks cost but cannot calculate revenue or generate invoices. *** ## Three integration paths You can send events to MarginFront three different ways. Pick whichever fits your workflow. ### SDK (recommended for Node.js apps) ```bash theme={null} npm install @marginfront/sdk ``` Lives in your agent's code. Sends usage events with automatic batching and retries. If MarginFront is down, events queue locally and retry -- your agent never stalls. See the [OpenAI recipe](/recipes/openai) for a complete example. ### REST API (any language) Same event endpoint (`POST /v1/usage/record`), raw HTTP. Works from any language or platform -- Python, Go, Ruby, curl, whatever. Pass your API key in the `x-api-key` header. See the [Usage Events reference](/api-reference/usage-events) for the full field reference. ### MCP (for human-to-LLM ops on a MarginFront account) Lets you ask Claude, Cursor, or any MCP-compatible AI assistant to operate on your MarginFront account in plain English: backfill historical events, query revenue or cost, fix unrecognized models, manage pricing plans. **MCP is not the integration path for instrumenting your product**; for per-event tracking from your own code, use the SDK or REST API above. See the [MCP Setup guide](/mcp/setup) for details. *** ## Next steps * **[Quickstart](/api-reference/quickstart)** -- Fire your first event in under a minute * **[OpenAI recipe](/recipes/openai)** -- Wire MarginFront into an OpenAI-powered app * **[Anthropic recipe](/recipes/anthropic)** -- Wire MarginFront into an Anthropic-powered app * **[Non-LLM recipe](/recipes/non-llm)** -- Track SMS, web scraping, PDF generation, and other non-LLM tools * **[Usage Events reference](/api-reference/usage-events)** -- The full field reference for the event endpoint ## Full page index For the complete list of pages on this docs site (handy when an LLM agent needs to know what exists), see the sitemap at [https://docs.marginfront.com/sitemap.xml](https://docs.marginfront.com/sitemap.xml). The LLM-friendly summary lives at [https://docs.marginfront.com/llms.txt](https://docs.marginfront.com/llms.txt). # MCP Setup Source: https://docs.marginfront.com/mcp/setup Connect your AI coding assistant to MarginFront in 2 minutes # MarginFront MCP Setup Guide ## What is MCP? MCP (Model Context Protocol) lets your AI coding assistant talk to MarginFront directly. Instead of writing curl commands or switching to the dashboard, you can say "show me my customers" and your AI tool fetches them. Think of it like giving your AI assistant a phone line to MarginFront -- it can look things up, record usage events, and answer billing questions without you leaving your editor. ## Before You Start You need three things: 1. **A MarginFront API key** that starts with `mf_sk_`. You can find it (or create one) in the MarginFront dashboard under **Build > API keys**. 2. **Node.js 18 or newer** installed on your machine. Check by running `node --version` in your terminal -- the number should be 18 or higher. 3. **One of these AI tools** installed: Claude Code, Claude Desktop, Cursor, VS Code, Windsurf, or Codex. *** ## Setup by Tool Pick the tool you use and follow only that section. Each one takes about 60 seconds. ### Claude Code 1. Open your project folder in your terminal. 2. Create a file called `.mcp.json` in the project root (the top-level folder). 3. Paste this into the file: ```json theme={null} { "mcpServers": { "marginfront": { "command": "npx", "args": ["-y", "@marginfront/mcp"], "env": { "MF_API_SECRET_KEY": "mf_sk_your_key_here" } } } } ``` 4. Replace `mf_sk_your_key_here` with your real API key. 5. Save the file. 6. Restart Claude Code so it picks up the new config. *** ### Claude Desktop 1. Open Claude Desktop. 2. Go to **Settings > Developer > Edit Config** to open `claude_desktop_config.json`. 3. Paste this into the file (or merge it with any existing config): ```json theme={null} { "mcpServers": { "marginfront": { "command": "npx", "args": ["-y", "@marginfront/mcp"], "env": { "MF_API_SECRET_KEY": "mf_sk_your_key_here" } } } } ``` 4. Replace `mf_sk_your_key_here` with your real API key. 5. Save the file. 6. Restart Claude Desktop. *** ### Cursor 1. Open your project in Cursor. 2. Create a folder called `.cursor` in your project root (if it doesn't exist already). 3. Inside `.cursor`, create a file called `mcp.json`. 4. Paste this into the file: ```json theme={null} { "mcpServers": { "marginfront": { "command": "npx", "args": ["-y", "@marginfront/mcp"], "env": { "MF_API_SECRET_KEY": "mf_sk_your_key_here" } } } } ``` 5. Replace `mf_sk_your_key_here` with your real API key. 6. Save the file. 7. Restart Cursor. *** ### VS Code / Windsurf / Codex 1. Open your project in VS Code (or Windsurf or Codex). 2. Create a folder called `.vscode` in your project root (if it doesn't exist already). 3. Inside `.vscode`, create a file called `mcp.json`. 4. Paste this into the file: > **Important:** VS Code uses `"servers"` instead of `"mcpServers"`. This is the only difference from the other tools. ```json theme={null} { "servers": { "marginfront": { "command": "npx", "args": ["-y", "@marginfront/mcp"], "env": { "MF_API_SECRET_KEY": "mf_sk_your_key_here" } } } } ``` 5. Replace `mf_sk_your_key_here` with your real API key. 6. Save the file. 7. Restart VS Code. *** ### Optional: Custom API URL By default, the MCP server talks to `https://api.marginfront.com/v1`. If you're running MarginFront locally or using a staging server, add `MF_API_BASE_URL` to the `env` block: ```json theme={null} "env": { "MF_API_SECRET_KEY": "mf_sk_your_key_here", "MF_API_BASE_URL": "http://localhost:3001/v1" } ``` Most people do not need this. *** ## Verify the Connection Once you've set up the config and restarted your tool: 1. Open a new chat in your AI tool. 2. Type: **"Verify my MarginFront connection"** 3. Your AI tool should respond with your organization name and a "verified" status. If it works, you're done with setup. If not, scroll down to Troubleshooting. *** ## Example Prompts to Try Once connected, try any of these to see MCP in action: * **"Show me my MarginFront customers"** -- lists all your customers with names and IDs. * **"What events were logged today?"** -- shows usage events from today. * **"How much did customer acme-001 cost this month?"** -- pulls analytics for a specific customer. * **"Record a usage event for customer acme-001 on agent cs-bot, signal messages, model gpt-4o from openai, 500 input tokens, 120 output tokens"** -- records a single-service LLM usage event. * **"Record one cold outreach for customer acme-001 on the outreach-bot. The outreach used Exa search (1 call), Hunter enrichment (1 call), Claude Opus to write the message (4500 input + 1200 output tokens, 1 call), and Pipedream to send (1 call). Track it as ONE event."** -- records a multi-service event where one business outcome (the outreach) was backed by four underlying services. The dashboard shows one event with all four costs rolled up; the Cost-by-service chart splits the total across the search API, the enrichment API, the LLM, and the delivery API. * **"Are there any events with unknown models?"** -- checks if any usage events have unrecognized models (meaning cost couldn't be calculated). * **"Create a customer called Beta Corp with external ID beta-001"** -- creates a new customer. *** ## What MCP Can and Can't Do ### Things you can do through MCP * Record usage events (single or batch, up to 100 at a time) * Create agents (the AI products you're billing for) and look up their IDs * Create signals (the metrics you're tracking, like "messages" or "pages") and look up their IDs * Create pricing plans, pricing strategies, and subscriptions * Look up customers, invoices, subscriptions, and usage events * Pull usage analytics, revenue, cost, and MRR for any date range * Create new customers * Generate draft invoices from a subscription's tracked usage * Email an invoice to the customer with a Stripe Checkout pay-now button (the same Send action the dashboard uses) * Find events where the model wasn't recognized (cost = unknown) * Map unknown models to known ones so costs get calculated * Send customers one-time portal links so they can see their own billing * Read your company's internal AI coding spend and manage its caps (cap changes need an owner or finance key) * Read customer credit-pool balances ### Things you should do in the dashboard instead * Invite and manage team members * Create and manage API keys * Add credit-pool top-ups or pause a customer's credit alert emails **The short version:** MCP covers the whole setup path in plain English: create an agent, add its signals, build a pricing plan, and subscribe a customer. The dashboard is where you manage your team and API keys, and it's the easiest place to review what got created. ### Tracking from your live product (use the SDK or REST instead) MCP runs your requests through an AI assistant, so it shines at plain-English, one-at-a-time work: trying things out, looking things up, backfilling old events, and fixing things by hand. It is **not** built to record an event on every request in your live product. When you're ready to track usage from your running product -- an event each time your agent finishes something you bill for -- put that call in your own code. Use the MarginFront SDK or a direct call to the REST API at `https://api.marginfront.com/v1`. A good way to start: ask MCP to fire one test event so you can watch the connection work, then have your AI assistant write the permanent SDK (or REST) call into your codebase, right at the spot where your agent completes that billable action. See [Tracking Usage Events](/sdk/tracking-events) for where the SDK code goes, and [SDK vs REST](/api-reference/sdk-vs-rest) if you're deciding between the two. *** ## Troubleshooting ### "Tool not found" or MCP tools don't appear **What happened:** Your AI tool can't find the `@marginfront/mcp` package. **Why:** The package isn't available through npx, or there's a network issue. **Fix:** 1. Open a terminal and run: `npx -y @marginfront/mcp --help` 2. If that fails, check your internet connection and make sure npm can reach the registry. 3. If you're behind a corporate proxy, you may need to configure npm's proxy settings. ### "Authentication failed" **What happened:** The API key was rejected. **Why:** The key is missing, expired, or not a secret key. **Fix:** 1. Open your MCP config file and check the `MF_API_SECRET_KEY` value. 2. Make sure it starts with `mf_sk_` (not `mf_pk_` -- that's a publishable key, which doesn't have permission for most operations). 3. Make sure there are no extra spaces or quotes around the key. 4. If you're not sure the key is valid, go to **Build > API keys** in the dashboard and create a new one. ### "Connection refused" or "Could not reach the MarginFront API" **What happened:** The MCP server can't connect to the MarginFront API. **Why:** Either the API is down, or you're pointing to the wrong URL. **Fix:** 1. If you set `MF_API_BASE_URL`, make sure that server is actually running at that address. 2. If you're running locally, make sure the API server is started (`cd apps/api-nest && npm run dev`). 3. If you didn't set `MF_API_BASE_URL`, the default is `https://api.marginfront.com/v1` -- check that you can reach it from your network. # MCP Tools Reference Source: https://docs.marginfront.com/mcp/tools All 58 MCP tools with parameters, return values, and example prompts # MarginFront MCP Tools Reference This is a complete list of the core MarginFront MCP tools. There are 58 tools total, organized into groups: read-only, write, diagnostic, destructive, canonical analytics, pricing setup, portal sessions, catalog discovery, spend controls, credit pools, customer alerts, and matters. Your AI assistant calls these tools automatically when you ask it questions about your MarginFront data. You don't need to memorize tool names. Just ask in plain English and the AI picks the right tool. Detailed parameters and examples for every tool live in the machine-readable [llms-mcp.txt](https://marginfront.com/llms-mcp.txt), which is the canonical source the MCP server and AI clients both read. *** ## Read-Only Tools (10) These tools look things up without changing anything. *** ### 1. verify **What it does:** Checks that your API key is valid and shows which organization it belongs to. This is the "hello world" of MCP -- call it first to make sure everything is wired up. **Parameters:** None. **What it returns:** Your organization name and a verified status. **Example prompt:** "Verify my MarginFront connection" *** ### 2. list\_customers **What it does:** Lists your customers with optional search and pagination. Good for browsing your customer list or finding a specific customer by name. **Parameters:** | Name | Type | Required | Default | Description | | ------ | ------ | -------- | ------- | -------------------------------------- | | page | number | No | 1 | Which page of results to show | | limit | number | No | 20 | How many results per page (1-100) | | search | string | No | -- | Search by customer name or external ID | **What it returns:** A list of customers with their names, MarginFront UUIDs, external IDs, and status. **Example prompt:** "Show me my MarginFront customers" *** ### 3. get\_customer **What it does:** Gets detailed information about one specific customer, including their subscriptions. **Parameters:** | Name | Type | Required | Description | | ---------- | ------ | -------- | ------------------------------------------------------- | | customerId | string | Yes | The customer's MarginFront UUID (not their external ID) | **What it returns:** Full customer details -- name, email, phone, external ID, status, and all their subscriptions. **Example prompt:** "Tell me about customer 7a2b3c4d-5e6f-7890-abcd-ef1234567890" > **Note:** This tool needs the MarginFront UUID, not the external ID you use in your own system. Use `list_customers` first to find the UUID. *** ### 4. list\_invoices **What it does:** Lists invoices with optional filters by status or customer. **Parameters:** | Name | Type | Required | Default | Description | | ---------- | ------ | -------- | ------- | ---------------------------------------------------------------------------- | | status | string | No | -- | Filter by status: `"draft"`, `"pending"`, `"paid"`, `"overdue"`, or `"void"` | | customerId | string | No | -- | Filter by customer UUID | | page | number | No | 1 | Which page of results | | limit | number | No | 20 | Results per page (1-100) | **What it returns:** A list of invoices showing status, amounts, customer name, and dates. **Example prompt:** "Show me all pending invoices" *** ### 5. get\_invoice **What it does:** Gets full details about one invoice, including every line item and payment history. **Parameters:** | Name | Type | Required | Description | | --------- | ------ | -------- | ------------------ | | invoiceId | string | Yes | The invoice's UUID | **What it returns:** The complete invoice -- line items (what was billed), amounts, payment status, customer info, and dates. **Example prompt:** "Show me the details on invoice 1a2b3c4d-5e6f-7890-abcd-ef1234567890" *** ### 6. list\_events **What it does:** Lists usage events (the raw records of what your customers actually used) with optional filters. **Parameters:** | Name | Type | Required | Default | Description | | ------------------ | ------ | -------- | ------- | -------------------------------------------------------------------------------------------------------- | | page | number | No | 1 | Which page of results | | limit | number | No | 20 | Results per page (1-100) | | customerExternalId | string | No | -- | Filter by customer external ID (the ID in your own system, e.g. `"acme-001"`). NOT the MarginFront UUID. | | agentId | string | No | -- | Filter by agent UUID | | signalId | string | No | -- | Filter by signal UUID | | startDate | string | No | -- | Only show events after this date (ISO 8601 format, e.g. `"2026-04-01"`) | | endDate | string | No | -- | Only show events before this date (ISO 8601 format) | **What it returns:** A list of events, each showing the model used, token counts, calculated cost, customer, and timestamp. **Example prompt:** "What events were logged today?" or "Show me events for customer acme-001 this week." *** ### 7. get\_usage\_analytics **What it does:** Gets aggregated usage analytics (totals and trends) for a date range. Unlike `list_events` which shows individual events, this gives you the big picture -- totals, breakdowns, and time-series data. **Parameters:** | Name | Type | Required | Default | Description | | --------- | ------ | -------- | ------- | -------------------------------------------------------------------------------------- | | startDate | string | Yes | -- | Start of the date range (ISO 8601, e.g. `"2026-04-01"`) | | endDate | string | Yes | -- | End of the date range (ISO 8601, e.g. `"2026-04-12"`) | | groupBy | string | No | -- | How to break down the data: `"agent"`, `"customer"`, `"signal"`, `"model"`, or `"day"` | **What it returns:** Summary totals (total cost, total events, total tokens) plus a breakdown grouped however you specified. **Example prompt:** "Show me usage analytics for April grouped by customer" *** ### 8. list\_subscriptions **What it does:** Lists customer subscriptions (which customer is on which pricing plan). **Parameters:** | Name | Type | Required | Default | Description | | ---------- | ------ | -------- | ------- | ---------------------------------------------------------------------- | | status | string | No | -- | Filter by status: `"active"`, `"canceled"`, `"past_due"`, `"trialing"` | | customerId | string | No | -- | Filter by customer UUID | | page | number | No | 1 | Which page of results | | limit | number | No | 20 | Results per page (1-100) | **What it returns:** A list of subscriptions showing status, plan name, billing dates, and customer info. **Example prompt:** "Show me all active subscriptions" *** ### 9. list\_agents **What it does:** Lists your agents with optional search and filters. Each row's `id` is the agent UUID that `create_signal`, `create_pricing_strategy`, `link_plan_to_agent`, and `create_subscription` ask for. Recording usage auto-creates agents on the first event, so this is also how you find an agent that was created that way (its `agentCode` is the code the first event used). **Parameters:** | Name | Type | Required | Default | Description | | --------- | ------- | -------- | ------- | ----------------------------------------------------- | | search | string | No | -- | Search across name, description, and agent code | | agentCode | string | No | -- | Filter by agent code (case-insensitive partial match) | | isActive | boolean | No | -- | Filter by active status | | page | number | No | 1 | Which page of results | | limit | number | No | 10 | Results per page (1-100) | **What it returns:** A paginated list of agents, each with its `id`, name, `agentCode`, active status, and counts of signals, linked plans, and subscriptions. **Example prompt:** "Show me my agents" or "Find the agent with code cs-bot" *** ### 10. list\_signals **What it does:** Lists signals with optional filters. Each row's `id` is the signal UUID that `create_pricing_strategy` needs for usage charge types and credit pools. Recording usage auto-creates signals on the first event, so this is also how you find a signal that was created that way (its name is the `signalName` the first event used). **Parameters:** | Name | Type | Required | Default | Description | | ------- | ------ | -------- | ------- | ------------------------------------------------------- | | agentId | string | No | -- | Filter to one agent's signals (UUID from `list_agents`) | | name | string | No | -- | Filter by signal name (case-insensitive partial match) | | type | string | No | -- | `"usage"` or `"volume"` | | page | number | No | 1 | Which page of results | | limit | number | No | 10 | Results per page (1-100) | **What it returns:** A paginated list of signals, each with its `id`, name, short name, agent UUID, and type. **Example prompt:** "List the signals on the cs-bot agent" *** ## Write Tools (7) These tools create or modify data. They change things in MarginFront, so the AI will usually confirm before calling them. *** ### 11. record\_usage **What it does:** Records a single usage event -- one measurement of a customer using your AI agent. This is how MarginFront learns what to bill for. There are three patterns the same tool accepts: * **Single-service LLM event** (chatbots, summarizers, etc.): pass top-level `model` + `modelProvider` + `inputTokens` + `outputTokens`. * **Single-service non-LLM event** (SMS, web scraping, API calls, etc.): pass top-level `model` + `modelProvider` + `quantity`. * **Multi-service event** (one outcome backed by multiple underlying services, e.g. one report that called Claude AND queried Google Maps): pass a `services[]` array, one entry per underlying service. Top-level `quantity` stays signal-level (default 1). Top-level `model` / `modelProvider` are omitted. The single-service shape and multi-service shape are mutually exclusive. Send top-level `model` + `modelProvider` OR send `services[]`, never both, never neither. The MCP tool rejects with a clear English error before the request leaves the agent if you mix shapes. **Parameters:** | Name | Type | Required | Default | Description | | ------------------ | ------ | ----------------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | customerExternalId | string | Yes | -- | Your customer's ID in your system, e.g. `"acme-001"` | | agentCode | string | Yes | -- | The agent/product code from your dashboard, e.g. `"cs-bot-v2"` | | signalName | string | Yes | -- | The metric being tracked, e.g. `"messages"` or `"pages_processed"` | | model | string | Single-service shape (when no `services`) | -- | The model identifier, e.g. `"gpt-4o"`, `"claude-sonnet-4-6"`, `"twilio-sms"`. Omit when sending `services[]`. | | modelProvider | string | Single-service shape (when no `services`) | -- | The provider name in lowercase, e.g. `"openai"`, `"anthropic"`, `"twilio"`. Omit when sending `services[]`. | | inputTokens | number | No | -- | Single-service: input tokens for an LLM call. Omit when sending `services[]`. | | outputTokens | number | No | -- | Single-service: output tokens for an LLM call. Omit when sending `services[]`. | | cacheReadTokens | number | No | -- | Single-service: prompt-cache read tokens (cache hits) for an LLM call. Priced at the cheaper cache-read rate instead of the full input rate. Omit when sending `services[]`. | | cacheWriteTokens | number | No | -- | Single-service: prompt-cache write tokens (cache creation) for an LLM call. Priced at the cache-write rate. Omit when sending `services[]`. | | quantity | number | No | 1 | Single-service: billing units for non-LLM. Multi-service: signal-level count (one report = 1). | | services | array | Multi-service shape (when no top-level `model`) | -- | One entry per underlying service contributing to this outcome. Each entry has its own `model`, `modelProvider`, and volume fields (`inputTokens`/`outputTokens`, plus optional `cacheReadTokens`/`cacheWriteTokens`, for LLM; `quantity` for non-LLM). Minimum 1 entry. | | usageDate | string | No | now | ISO 8601 date for backdating, e.g. `"2026-04-01T00:00:00Z"` | | metadata | object | No | -- | Custom key-value pairs stored with the event (not used for billing) | **What it returns:** * For single-service: the event ID, calculated cost (in USD), and timestamp. * For multi-service: the parent event ID, rolled-up `totalCostUsd` (sum across services), a `services[]` array showing per-service cost + status, and timestamp. If any service in a multi-service event isn't in the pricing table, the parent's `totalCostUsd` stays null until ALL services are mapped (the cost rule). The `services[]` response array shows exactly which entries are unresolved so you can call `map_model` on the right one. Use `get_needs_attention` to see all unmapped models across all events. **Example prompts:** * Single-service: `"Record a usage event: customer acme-001 used cs-bot, signal messages, gpt-4o from openai, 500 input tokens, 120 output tokens"` * Multi-service: `"Record one cold outreach for customer acme-001 on the outreach-bot. The outreach used Exa search (1 call), Hunter enrichment (1 call), Claude Opus to write the message (4500 input + 1200 output tokens, 1 call), and Pipedream to send (1 call). Track it as ONE event."` > **Important:** If you get a "NEEDS\_COST\_BACKFILL" response, do NOT re-send the event. The event was saved. The model (or one of the services) just isn't in the pricing table yet. Use `get_needs_attention` to see which models need mapping, then `map_model` to fix them. *** ### 12. record\_usage\_batch **What it does:** Records multiple usage events at once (1 to 100 per request). Each record has the same fields as `record_usage` -- including the choice between single-service shape (top-level `model` + `modelProvider`) and multi-service shape (`services[]`). You can mix all three patterns (single-service LLM, single-service non-LLM, multi-service) in the same batch. **Parameters:** | Name | Type | Required | Description | | ------- | ----- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | records | array | Yes | An array of 1-100 usage records. Each record has the same fields as `record_usage` (see above). Each record independently picks single-service or multi-service shape. Mixing shapes WITHIN one record is still rejected. | **What it returns:** A summary showing how many succeeded, how many failed, and per-record details for each. Successes include the same shape as `record_usage`. Failed records that were still saved (like NEEDS\_COST\_BACKFILL or MISSING\_VOLUME\_DATA) are flagged -- do NOT retry those. For multi-service failed records, the response includes a `servicesStatus[]` array so you see which specific service in the batch entry needs fixing. **Example prompt:** "Record these for customer acme-001: 3 messages on cs-bot using gpt-4o from openai (200 input / 50 output each), and one cold outreach on outreach-bot that used Exa (1 call), Hunter (1 call), Claude Opus (4500/1200 tokens, 1 call), and Pipedream (1 call)." *** ### 13. create\_customer **What it does:** Creates a new customer in MarginFront. **Parameters:** | Name | Type | Required | Default | Description | | ---------- | ------ | -------- | ------- | ----------------------------------------------------- | | name | string | Yes | -- | Customer's display name, e.g. `"Acme Corp"` | | externalId | string | No | -- | Your system's ID for this customer, e.g. `"acme-001"` | | email | string | No | -- | Customer's email address | | phone | string | No | -- | Customer's phone number | **What it returns:** The newly created customer with their MarginFront UUID. **Example prompt:** "Create a customer called Beta Corp with external ID beta-001" > **Tip:** Always set `externalId` when creating a customer. That's the ID you'll use when recording usage events later, so it should match whatever ID you use for this customer in your own system. *** ### 14. create\_agent **What it does:** Creates an agent -- the billable thing you meter (a chatbot, a report generator, a pipeline). Pricing plans link to agents and subscriptions bill per agent, so this comes first when setting up from zero. **Requires:** a key with the Developer role (Admin and Owner keys also work). **Parameters:** | Name | Type | Required | Default | Description | | ----------- | ------- | -------- | ------- | ------------------------------------------------------------------------------------------------------------------- | | name | string | Yes | -- | Agent display name, e.g. `"Customer Support Agent"` | | agentCode | string | Yes | -- | Unique code for this agent, e.g. `"cs-bot"`. The same value your product sends as `agentCode` when recording usage. | | description | string | No | -- | What this agent does | | isActive | boolean | No | true | Whether the agent is active | | context | object | No | -- | Custom key-value pairs stored with the agent | **What it returns:** The created agent with its MarginFront UUID -- the agent ID that `create_signal`, pricing, and subscriptions need. **Example prompt:** "Create an agent called Customer Support Agent with code cs-bot" > **If the code already exists:** the API answers with a conflict. That's common, because recording usage auto-creates agents. Don't retry -- call `list_agents` to fetch the existing agent's UUID and keep going. *** ### 15. create\_signal **What it does:** Creates a signal -- the billing unit an agent tracks, like `"messages"` or `"reports-generated"`. The signal name is the line item your customer reads on their invoice, so match the language they bill by ("per page" means the signal is `"pages"`). Create the signal before pricing: usage and credit-pool strategies need its UUID up front, and this tool returns it. **Requires:** a key with the Developer role (Admin and Owner keys also work). **Parameters:** | Name | Type | Required | Default | Description | | --------- | ------ | -------- | ------- | --------------------------------------------------------------------------------------------------------------------------- | | name | string | Yes | -- | The billing unit on the customer's invoice. Match the customer's language; never internal-sounding names like `"llm_call"`. | | agentId | string | Yes | -- | The agent's UUID (from `create_agent` or `list_agents`) | | shortName | string | No | -- | Short machine-friendly name, e.g. `"api_calls"` | | type | string | No | -- | `"usage"` = billed per result delivered, `"volume"` = billed per attempt | **What it returns:** The created signal with its MarginFront UUID -- the signal ID that `create_pricing_strategy` needs. **Example prompt:** "Create a signal called messages on the cs-bot agent" > **If the name already exists on that agent:** the API answers with a conflict (recording usage auto-creates signals). Call `list_signals` to fetch the existing signal's UUID instead of retrying. *** ### 16. generate\_invoice **What it does:** Builds a draft invoice from a subscription's tracked usage. Reads the period's usage events, applies the subscription's pricing strategies, and writes a draft invoice with line items and totals — ready to preview, edit, or send. This is the right tool when you want to "bill now" instead of waiting for the end of the billing period. The draft starts in `draft` status; nothing is sent to the customer until you (or the auto-finalize step) move it to `issued`. **Parameters:** | Name | Type | Required | Description | | ------------------ | ------ | -------- | --------------------------------------------------------------------------- | | customerId | string | Yes | Customer UUID (the MarginFront UUID, not the external ID from your system) | | subscriptionId | string | Yes | Subscription UUID to bill for | | billingPeriodStart | string | No | ISO 8601 date. Defaults to the subscription's current billing period start. | | billingPeriodEnd | string | No | ISO 8601 date. Defaults to the subscription's current billing period end. | **What it returns:** The full draft invoice, including line items, totals, and the invoice UUID. **Example prompt:** "Generate a draft invoice for Acme Corp's Growth Plan subscription using this period's usage" *** ### 17. send\_invoice **What it does:** Emails an invoice to the customer with a "Pay Now" button that opens Stripe Checkout pre-filled with the invoice details. Use this after `generate_invoice` to actually deliver a draft, or to re-send an invoice that has already been issued. The customer's stored email address is used by default — pass `recipientEmail` to override (for example, to route the invoice to a different billing contact). **Parameters:** | Name | Type | Required | Default | Description | | -------------- | ------ | -------- | -------------------------------------------- | ------------------------------------------------------------------------------------------- | | invoiceId | string | Yes | -- | The invoice's MarginFront UUID (from `list_invoices`, `get_invoice`, or `generate_invoice`) | | recipientEmail | string | No | customer's stored email | Override the destination address | | subject | string | No | `Invoice {number} from {your business name}` | Custom subject line | | message | string | No | -- | Optional note shown in a callout above the invoice details | **What it returns:** A confirmation including the email provider's message ID (for delivery tracking) and the address the email was actually sent to (after applying any override). **Example prompts:** * "Email the latest draft invoice to Acme Corp" * "Send invoice inv\_abc to [billing@customer.com](mailto:billing@customer.com) with the subject 'May invoice — auto-charge in 5 days'" > **Side effect:** if the invoice is still a `draft` when you call this, sending it auto-finalizes the status to `issued`. This matches the dashboard Send button and the end-of-period auto-finalize step. Once the customer pays via the Stripe Checkout link, the invoice flips to `paid` automatically — no follow-up call needed. *** ## Diagnostic Tool (1) This tool helps you find and fix data issues. *** ### 18. get\_needs\_attention **What it does:** Finds usage events where the model+provider combination isn't in the pricing table. These events were saved (the data isn't lost), but their cost is null because MarginFront doesn't know how much that model costs. **Parameters:** | Name | Type | Required | Default | Description | | --------- | ------ | -------- | ----------- | ----------------------------------------------- | | startDate | string | No | 30 days ago | Only look at events after this date (ISO 8601) | | endDate | string | No | now | Only look at events before this date (ISO 8601) | **What it returns:** Groups of unrecognized model+provider combinations, each with a count of how many events are affected. **Example prompt:** "Are there any MarginFront events with unknown models?" > **Important:** These events ARE saved -- they just have cost=null. Do NOT re-send them. Use `map_model` (below) to tell MarginFront what pricing to use, and it will backfill the costs automatically. *** ## Destructive Tool (1) This tool modifies existing data. "Destructive" sounds scary, but it's actually a fix-it tool -- and it's safe to run more than once (it's idempotent, meaning running it twice produces the same result as running it once). *** ### 19. map\_model **What it does:** Maps an unknown model to a known one in the pricing table, then backfills costs for all affected events. This creates a permanent, organization-scoped mapping -- once you map it, future events with the same model+provider will have their costs calculated automatically. You tell it the "source" (the unknown model) and the "target" (the known model to price it as). You can identify the target two ways: * By name: pass `targetModel` + `targetProvider` (e.g., map "gpt-4o-2024-08-06" to "gpt-4o" from "openai"). * By ID: pass `targetPricingId` (the UUID of the specific pricing table row). **Parameters:** | Name | Type | Required | Description | | --------------- | ------ | -------- | -------------------------------------------------------------- | | sourceModel | string | Yes | The unknown model name to map FROM, e.g. `"gpt-4o-2024-08-06"` | | sourceProvider | string | Yes | The unknown provider to map FROM, e.g. `"openai"` | | targetPricingId | string | No\* | UUID of the target pricing row to map TO | | targetModel | string | No\* | Known model name to map TO, e.g. `"gpt-4o"` | | targetProvider | string | No\* | Known provider to map TO, e.g. `"openai"` | > \*You must provide either `targetPricingId` OR both `targetModel` + `targetProvider`. One or the other, not both. **What it returns:** Confirmation of the mapping, how many events had their costs backfilled, and the mapping ID. **Example prompt:** "Map model gpt-4o-2024-08-06 from openai to gpt-4o from openai" > **Safe to run again:** If you accidentally run this twice with the same inputs, nothing bad happens. It's idempotent. *** ## Diagnostic Tools (2) ### 20. get\_missing\_volume **What it does:** Lists usage events that landed in the `MISSING_VOLUME_DATA` state: events where the agent didn't send tokens (for LLM calls) or quantity (for non-LLM). The platform stored them anyway, waiting for the volume data to arrive. **Parameters:** | Name | Type | Required | Description | | --------- | ------ | -------- | --------------------------------------------------------- | | startDate | string | No | Only include events with usageDate after this (ISO 8601) | | endDate | string | No | Only include events with usageDate before this (ISO 8601) | **What it returns:** Groups by model and provider, each with an event count and the `costUnit`, so you know whether that group needs tokens or quantity when you fix it with `fill_volume`. **Example prompt:** "Show me any LLM events still missing tokens" or "Which events landed without quantity this month?" *** ### 21. fill\_volume **What it does:** Supplies the missing volume data for `MISSING_VOLUME_DATA` events, in bulk. You name the model + provider (the same grouping `get_missing_volume` returns), give the volume to apply, and every matching incomplete event is updated in one operation: cost recalculates and the events flip to `PROCESSED`. **Parameters:** | Name | Type | Required | Description | | -------------- | ------ | -------- | --------------------------------------------------------------- | | sourceModel | string | Yes | The model name whose events are missing volume, e.g. `"gpt-4o"` | | sourceProvider | string | Yes | The provider, e.g. `"openai"` | | inputTokens | number | No | Input (prompt) tokens to apply. Required for LLM models. | | outputTokens | number | No | Output (completion) tokens to apply. Required for LLM models. | | quantity | number | No | Billing units to apply. Required for non-LLM services. | **What it returns:** A summary: the model and provider, the volume applied, and how many events were backfilled with calculated costs. **Example prompt:** "Fill in 1500 input and 400 output tokens for the gpt-4o events that are missing volume" > **Heads up:** the same volume numbers are applied to EVERY matching incomplete event for that model + provider. Future events still need correct volume fields in the request itself. *** ## Canonical Analytics Tools (3) These tools expose MarginFront's single source of truth for revenue, cost, and MRR. They return the same numbers the dashboard KPI tiles display. ### 22. get\_customer\_revenue **What it does:** Returns revenue, cost, and margin for a single customer over a date range. Use this when an AI agent needs to answer "how much has this customer paid us?" or "what's our margin on this account?" without loading the full analytics view. **Parameters:** | Name | Type | Required | Description | | ---------- | ------ | -------- | -------------------------------------------------------------------- | | customerId | string | Yes | Customer UUID (not the external ID. use the MarginFront internal ID) | | startDate | string | Yes | Window start (ISO 8601) | | endDate | string | Yes | Window end (ISO 8601) | **What it returns:** Revenue (billed invoiced amount over the window), cost (sum of usageCost for attributed events), and margin = revenue − cost. Also breaks down revenue by type (usage, recurring, seat, onetime). **Example prompt:** "What's our revenue and margin for Acme Corp this quarter?" *** ### 23. get\_cost\_metrics **What it does:** Returns the full cost breakdown across the organization (or filtered to a single customer / agent). Includes per-day, per-agent, per-customer, per-signal, per-plan, per-model splits. Optional prior-window trend comparison. **Parameters:** | Name | Type | Required | Description | | ------------------ | ------- | -------- | ------------------------------------------------------------------ | | startDate | string | Yes | Window start (ISO 8601) | | endDate | string | Yes | Window end (ISO 8601) | | customerId | string | No | Filter to a single customer UUID | | agentId | string | No | Filter to a single agent UUID | | includePriorWindow | boolean | No | Compute prior-period cost + trend delta for the same window length | **What it returns:** Total cost, event counts, and breakdown arrays (`byAgent`, `byCustomer`, `bySignal`, `byDay`, `byPlan`, `byModel`). When `includePriorWindow` is true, also returns `prior` with the same shape for the preceding equivalent period. **Example prompt:** "Break down our AI costs by model for last month" or "Show cost trend for Deal Ops agent week over week." *** ### 24. get\_mrr **What it does:** Returns Monthly Recurring Revenue using one of three canonical variants. MarginFront tracks three distinct MRR computations because "what's our MRR?" has three different right answers depending on what question you're asking. **Parameters:** | Name | Type | Required | Description | | -------------- | ------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | variant | string | No | One of: `canonical` (default; last completed month invoice total), `runRate` (last 30 days of activity projected forward), `committed` (contractual floor only) | | customerId | string | No | Filter to a single customer UUID | | subscriptionId | string | No | Filter to a single subscription UUID | **What it returns:** The MRR amount for the requested variant + breakdown by revenue type (usage, recurring, seat, onetime). **Example prompt:** "What was our MRR last month?" (defaults to `canonical`) or "What's our run-rate MRR if usage keeps trending?" (`runRate`) or "What's our committed MRR floor for forecasting?" (`committed`). *** ## Pricing Setup Tools (7) ### 25. create\_pricing\_plan **What it does:** Creates a new pricing plan for an agent. A pricing plan is a container for pricing strategies (which are the per-signal billing rules). **Parameters:** | Name | Type | Required | Description | | ----------- | ------ | -------- | ------------------------------------------------------------------- | | name | string | Yes | Display name, e.g. `"Growth Plan"` | | description | string | No | Human-readable description of what the plan covers | | agentId | string | No | UUID of the agent this plan is scoped to (leave empty for org-wide) | **What it returns:** The new plan with its UUID. **Example prompt:** "Create a Growth plan for our Outreach Writer agent" *** ### 26. list\_pricing\_plans **What it does:** Lists all pricing plans for the organization, with optional filtering by agent. **Parameters:** | Name | Type | Required | Description | | ------- | ------ | -------- | ----------------------------------------------- | | agentId | string | No | Filter to plans scoped to a specific agent UUID | **What it returns:** Array of plans with their UUID, name, description, agent binding, and an embedded list of pricing strategies attached. *** ### 27. get\_pricing\_plan **What it does:** Returns full details on a single pricing plan, including all its pricing strategies and their rates. **Parameters:** | Name | Type | Required | Description | | ------ | ------ | -------- | --------------------- | | planId | string | Yes | Plan UUID to retrieve | **What it returns:** Plan with name, description, agent, and every pricing strategy (chargeType, pricingModel, rates, tiers, minimum commitments). *** ### 28. create\_pricing\_strategy **What it does:** Creates a pricing strategy on an existing plan. A strategy is the per-signal rule: "for this metric, charge this way." **Parameters:** | Name | Type | Required | Description | | ----------------- | ------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | planId | string | Yes | Parent plan UUID | | signalId | string | Yes | Signal (metric) UUID this strategy applies to | | chargeType | string | Yes | One of: `usage`, `recurring`, `seat_based`, `onetime` | | pricingModel | string | Yes | One of: `flat`, `graduated`, `volume`, `credit_pool` (only applies to usage) | | rate | number | Yes | Per-unit rate in dollars (for flat + recurring + seat\_based + onetime) | | tiers | array | No | Tier config for graduated/volume (array of `{lower, upper, rate}` objects; `upper: null` = unbounded) | | creditPool | object | No | The credit-pool shortcut: `{poolSize, poolPrice, overageRate}`. Only valid when pricingModel is `credit_pool`. Compiled into tiers server-side; send this OR tiers, never both. Nothing stops when the pool empties; the overage keeps billing. | | minimumCommitment | number | No | Minimum number of units/seats committed (floor). Not allowed on `credit_pool` strategies. | **What it returns:** The new strategy's UUID and validated config. *** ### 29. list\_pricing\_strategies **What it does:** Lists all pricing strategies on a plan. **Parameters:** | Name | Type | Required | Description | | ------ | ------ | -------- | -------------------------------- | | planId | string | Yes | Plan UUID to list strategies for | **What it returns:** Array of strategies with their config. *** ### 30. link\_plan\_to\_agent **What it does:** Attaches an existing pricing plan to an agent. An agent can be attached to multiple plans (each pricing different customer tiers). **Parameters:** | Name | Type | Required | Description | | ------- | ------ | -------- | ----------------------- | | planId | string | Yes | Plan UUID to attach | | agentId | string | Yes | Agent UUID to attach to | **What it returns:** The junction row confirming the link. *** ### 31. create\_subscription **What it does:** Creates a subscription tying a customer to an agent + plan. Once created, events fired for this (customer, agent, signal) combination bill against this subscription's pricing strategies. **Parameters:** | Name | Type | Required | Description | | ------------ | ------ | -------- | ------------------------------------------------------ | | name | string | Yes | Display name (e.g. `"Acme Corp: Growth Plan"`) | | customerId | string | Yes | Customer UUID | | agentId | string | Yes | Agent UUID | | planId | string | Yes | Pricing plan UUID | | billingCycle | string | Yes | One of: `monthly`, `yearly` | | billingModel | string | Yes | One of: `usage`, `recurring`, `seat_based`, `hybrid` | | startDate | string | No | When the subscription becomes active (defaults to now) | **What it returns:** New subscription with its UUID, scoped to the (customer, agent, plan) triple. **Example prompt:** "Create a Growth Plan subscription for Acme Corp on the Outreach Writer agent, billed monthly, usage model." *** ## Portal Sessions (4) These four tools let an AI assistant mint and manage one-time portal links — the URLs you send to your customers so they can see their own billing on a MarginFront-hosted page. See the [Portal Sessions API reference](/api-reference/portal-sessions) for the full plain-English explanation. *** ### 32. create\_portal\_session **What it does:** Mints a one-time portal link for a customer. The URL is good for one hour and stops working the moment the customer opens it. **Parameters:** | Name | Type | Required | Description | | ------------------ | --------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------ | | customerId | string | One of these two | MarginFront's internal customer UUID | | customerExternalId | string | One of these two | Your system's customer ID | | returnUrl | string | No | Stored on the session for your records (the portal does NOT auto-redirect; this is informational) | | features | string\[] | No | Subset of `["invoices", "subscriptions", "usage", "profile"]`. v1 always renders all four, so this is informational too. | **What it returns:** The session ID, the URL to send your customer, the token, customer details, and the expiry timestamp. **Example prompt:** "Send acme-001 a portal link" *** ### 33. get\_portal\_session **What it does:** Looks up one portal session by ID. Use this to check whether a link has been opened or has expired. Does NOT return the `token` or `url` — those are only shown at creation. **Parameters:** | Name | Type | Required | Description | | --------- | ------ | -------- | ------------------------------------------------------------ | | sessionId | string | Yes | The portal session ID returned from `create_portal_session`. | **What it returns:** Session metadata — customer, expiry, redeem status. **Example prompt:** "Has Acme opened the portal link I sent yesterday?" *** ### 34. list\_portal\_sessions **What it does:** Lists portal sessions your organization has created. Useful for audit or support. Tokens are intentionally omitted from the response. **Parameters:** | Name | Type | Required | Default | Description | | -------------- | ------- | -------- | ------- | ------------------------------------------------------- | | customerId | string | No | -- | Filter to one customer's sessions (internal UUID). | | limit | number | No | 10 | Max results (1-100). | | includeExpired | boolean | No | false | Set `true` to include expired or already-used sessions. | **What it returns:** A list of session metadata records. **Example prompt:** "Show me all portal links we sent Acme this month" *** ### 35. revoke\_portal\_session **What it does:** Immediately invalidates a portal session. Use this if you sent a link to the wrong customer or need to cut access early. Hard delete — the session row is removed. **Parameters:** | Name | Type | Required | Description | | --------- | ------ | -------- | -------------------------------- | | sessionId | string | Yes | The portal session ID to revoke. | **What it returns:** A confirmation that the session was revoked. **Example prompt:** "Cancel the portal link I sent Acme yesterday" *** ## Catalog Discovery (1) This tool lets the AI look up canonical model and provider names from MarginFront's global service pricing catalog before firing usage events. Use it to avoid the "guess and check until cost resolves" cycle. *** ### 36. list\_catalog\_services **What it does:** Browses the global service catalog — every model and non-LLM service MarginFront can calculate cost for. Filter by provider, service type, or free-text search to find the canonical `model` + `modelProvider` names to send with `record_usage` so cost auto-resolves on ingest. The catalog is read-only and global (not org-scoped). **Parameters:** | Name | Type | Required | Default | Description | | ----------- | ------- | -------- | ------- | -------------------------------------------------------------------------------------------------- | | provider | string | No | -- | Lowercase provider name to filter by, e.g. `"openai"`, `"anthropic"`, `"google"`, `"twilio"`. | | serviceType | string | No | -- | Category, e.g. `"LLM"`, `"Embeddings"`, `"Compute"`, `"Web Search"`, `"SMS"`, `"Vector Database"`. | | isApi | boolean | No | -- | `true` to return only non-LLM API entries, `false` for LLM-only. Omit to return both. | | search | string | No | -- | Case-insensitive search across `canonicalName` and `displayName`. | | page | number | No | 1 | 1-based page number. | | limit | number | No | 50 | Results per page (1-100). | **What it returns:** Paginated catalog entries. Each entry includes `canonicalName` (what to send as `model`), `provider` (what to send as `modelProvider`), `serviceType`, per-unit `inputCost` / `outputCost`, `costUnit`, and `contextWindow` for LLMs. **Example prompts:** * "What canonical name should I use for GPT-4o when recording usage?" * "List every Google service in the MarginFront catalog." * "Find the catalog entry for Twilio SMS so I can record an event." > **Tip:** Pair this with `record_usage` — look up the canonical name first, then fire the event using that exact name and provider. Cost resolves automatically without a `NEEDS_COST_BACKFILL` round trip. See the [Services Catalog reference](/api-reference/services) for the full field list. *** ## Spend Controls (5) These tools read and manage **spend caps**: the limits your company sets on its own AI coding spend. They're the same caps the dashboard's Internal AI Spend page shows. See the [Spend Controls API reference](/api-reference/spend-controls) for what scopes, modes, and the ceiling rule mean. Reading the caps, current spend, and coverage (`get_spend_controls`) works with any secret key for your organization; a publishable key (`mf_pk_...`) is refused. The internal spend breakdown (`get_internal_spend_breakdown`) needs a little more: a secret key that belongs to an **owner, admin, or finance** user, because it exposes per-repo spend and teammate-level detail. The three write tools need an **owner or finance** user's key. Any other key gets a clear 403 explaining which role is required. *** ### 37. get\_spend\_controls **What it does:** Reads everything about your spend controls in one call: the cap policies (each with its plain sentence), spend-so-far for a period, and coverage ("N of M developers armed"). **Parameters:** | Name | Type | Required | Default | Description | | ------------------ | ------ | -------- | ------- | ----------------------------------------------------------------------------------- | | period | string | No | month | Which window the spend total covers: `"day"`, `"week"`, or `"month"` | | customerExternalId | string | No | -- | A developer's `customerExternalId` (their email) to also get that developer's spend | **What it returns:** The caps (each with its `sentence`), the spend read-back for the period, and the coverage counts. `spentUsd` is `null` when there's no priced usage yet: that's honest absence, not \$0. **Example prompt:** "What are our spend caps and how much have we used this week?" *** ### 38. get\_internal\_spend\_breakdown **What it does:** Breaks down internal coding-agent spend (Claude Code and Codex events only, never customer billing) for the current period, grouped by git repo or branch. Branch spend is the closest stand-in for cost per pull request. Events outside a tracked repo appear in the no-repo bucket (`noMetadata`), and `coveragePercent` says how much of the activity carries repo/branch metadata. `spentUsd` is `null` when there's no priced usage: honest absence, not \$0. **Parameters:** | Name | Type | Required | Description | | --------- | ------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------- | | period | string | Yes | Which period to sum: `"day"`, `"week"`, or `"month"`. The server computes the current window in UTC. | | dimension | string | Yes | Group by `"repo"` (one row per repository) or `"branch"` (one row per repo#branch, so a branch named main in two repos stays two rows). | **What it returns:** Totals plus one row per repo (or repo#branch), each with `spentUsd`, event counts, and unpriced-event counts, alongside the `noMetadata` bucket and `coveragePercent`. **Example prompt:** "Break down our Claude Code spend by repo this month" or "Which branch cost the most this week?" > **Who can call this:** a secret key that belongs to an **owner, admin, or finance** user. Unlike the caps, spend, and coverage reads, the per-repo breakdown exposes teammate-level detail, so a developer-role key gets a `403`. > **Note:** there's no `pr` grouping on purpose. The pipeline records repo, branch, and commit, not PR numbers, and offering a PR grain that silently means "branch" would be a lie. *** ### 39. create\_spend\_cap **What it does:** Creates a spend cap covering all AI tools. A developer cap can never be set higher than the whole-team ceiling; the server rejects the attempt with a plain message. **Requires:** an owner or finance key. **Parameters:** | Name | Type | Required | Description | | --------------- | --------- | -------- | ----------------------------------------------------------------------------------------------------- | | scope | string | Yes | `"org"` (a whole-team ceiling) or `"dev"` (one developer) | | scopeValue | string | For dev | The developer's `customerExternalId` (their email). Omit for `"org"`. | | amountUsd | number | Yes | Cap amount in US dollars, e.g. `200` | | period | string | Yes | Reset cadence: `"day"`, `"week"`, or `"month"`. Windows roll at UTC. | | mode | string | Yes | `"track"` (watch and alert, block nothing) or `"enforce"` (armed machines stop tool calls at the cap) | | coolOffHours | number | No | Hours to wait after a cap trips before the device re-checks | | alertThresholds | number\[] | No | Alert ladder as whole percents of the cap. Default `[50, 80, 100]`. | **What it returns:** The response leads with the new cap's plain sentence (e.g. `Cap created: Stop AI spend at $200 for alice@acme.com per week`), followed by the full cap details. **Example prompt:** "Cap Alice's AI coding spend at \$200 a week and enforce it" *** ### 40. update\_spend\_cap **What it does:** Adjusts an existing cap's amount, period, mode, cool-off, or alert ladder. A cap's identity (who it governs) can't be changed: delete and recreate instead. **Requires:** an owner or finance key. **Parameters:** | Name | Type | Required | Description | | --------------- | --------- | -------- | ---------------------------------------------- | | id | string | Yes | The cap's ID (from `get_spend_controls`) | | amountUsd | number | No | New cap amount in US dollars | | period | string | No | New reset cadence | | mode | string | No | New enforcement posture | | coolOffHours | number | No | New cool-off window in hours. Send 0 to clear. | | alertThresholds | number\[] | No | New alert ladder as whole percents of the cap | **What it returns:** The response leads with the updated cap's plain sentence, followed by the full cap details. **Example prompt:** "Raise the team AI spend cap to \$8,000" *** ### 41. delete\_spend\_cap **What it does:** Deletes a cap. Deleting a whole-team ceiling is refused if it would leave a developer cap with nothing above it (the server says so plainly). **Requires:** an owner or finance key. **Parameters:** | Name | Type | Required | Description | | ---- | ------ | -------- | ---------------------------------------- | | id | string | Yes | The cap's ID (from `get_spend_controls`) | **What it returns:** The response leads with the removed cap's plain sentence so you can confirm what was deleted. **Example prompt:** "Remove the spend cap on [alice@acme.com](mailto:alice@acme.com)" *** ## Credit Pools (1) This tool reads **credit-pool countdowns**: how many prepaid units each customer has left on plans that sell a pool ("5,000 tasks for $99 a month, then $0.03 each"). See the [Credit Balances API reference](/api-reference/credit-balances) for the endpoint-level detail and the [Credit Pools recipe](/recipes/credit-pools) for the full story. *** ### 42. get\_credit\_balances **What it does:** Reads your customers' credit-pool countdowns: pool size, units used this period, units left, and units already billing as overage. Two numbers are deliberately different and both are true: `remainingUnits` counts manual top-ups, while `overageInProgressUnits` is the invoice's own math (usage past the pool size, top-up blind). The invoice always matches `overageInProgressUnits`. Nothing stops at zero: the pool is a meter, not a breaker, so a customer past their pool keeps working and the overage keeps billing. Subscriptions whose plan sells no credit pool are absent, never listed with a zero. Read-only. Works with any secret key except an **ingest-only** key or a **legal** key, both of which get a clean 403; a publishable key (`mf_pk_...`) is refused. **Parameters (all optional):** | Name | Type | Required | Description | | -------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | subscriptionId | string | No | One subscription's balance instead of the whole list. | | customerId | string | No | Only pools belonging to this customer. | | agentId | string | No | Only pools on subscriptions for this agent. | | belowPercent | number | No | Only pools with this percent of the pool (or less) still left. `20` finds the ones worth a heads-up; `0` finds the ones already past zero. | **What it returns:** One row per credit-pool subscription (emptiest first) with the customer, plan, pool size, consumed, remaining, overage-in-progress, period dates, and whether alert emails are paused. Adding units and pausing alert emails happen in the dashboard or via the REST API (owner, admin, or finance); they aren't MCP tools in v1. See the [Credit Balances API reference](/api-reference/credit-balances) for the write endpoints. **Example prompts:** * "Who's running out of credits?" * "How many units does Acme have left?" * "Who's in overage this month?" *** ## Customer Alerts (4) These four tools set and manage **customer watches**: per-customer tripwires that email you when one customer's cost or revenue passes a dollar threshold. See the [Customer Alerts API reference](/api-reference/customer-alerts) for the endpoint-level detail. A watch exposes one customer's money, so all four tools — reads included — need a **secret** key that belongs to an **owner, admin, or finance** user. Any other key gets a clear 403; a publishable key (`mf_pk_...`) is refused. *** ### 43. get\_customer\_alerts **What it does:** Lists your organization's customer watches. Each row shows the watched customer (name and internal ID), the metric (`cost` or `revenue`), the dollar threshold, and the window. **Parameters:** None. **What it returns:** A list of watches, each with the customer, metric, threshold, window, and who the alerts email. **Example prompt:** "Show me all my customer cost alerts" *** ### 44. create\_customer\_alert **What it does:** Creates a watch on one customer: email a named person when that customer's cost or revenue passes a dollar threshold for the window you choose. Creating one sends the recipient a confirmation email. **Parameters:** | Name | Type | Required | Description | | --------------- | ------ | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | customerId | string | Yes | The customer's MarginFront UUID (from `list_customers`), not your external ID. | | metric | string | Yes | `cost` (what this customer costs you) or `revenue` (what they're billed). | | thresholdUsd | number | Yes | The tripwire in US dollars, e.g. `500`. | | windowMode | string | Yes | `month` (current UTC calendar month, re-arms monthly), `rolling` (last 30 days, fires once per crossing), or `custom` (a fixed range, fires once, then expires). | | customStartDate | string | For `custom` | ISO 8601 date. The range start. Custom mode only. | | customEndDate | string | For `custom` | ISO 8601 date. The range end. Custom mode only. | | notifyEmail | string | Yes | Who gets the alerts. Must be a current org member with an owner, admin, or finance role. Required here because an API key has no person behind it. | **What it returns:** The created watch with its ID. **Example prompt:** "Email [finance@yourco.com](mailto:finance@yourco.com) when Acme Corp's cost passes \$500 this month" > **Internal coding-spend accounts can't be watched here.** For your own team's Claude Code and Codex spend, use the spend-cap tools instead. *** ### 45. update\_customer\_alert **What it does:** Changes an existing watch's metric, threshold, or window. The watched customer and the recipient are fixed at create — to change either, delete the watch and create a new one. **Parameters:** | Name | Type | Required | Description | | --------------- | ------ | ------------ | -------------------------------------------------------------------- | | id | string | Yes | The watch's ID (from `get_customer_alerts`). | | metric | string | No | New metric: `cost` or `revenue`. | | thresholdUsd | number | No | New tripwire in US dollars. | | windowMode | string | No | New window mode. Switching to `custom` needs both dates in the call. | | customStartDate | string | For `custom` | ISO 8601 date. Custom mode only. | | customEndDate | string | For `custom` | ISO 8601 date. Custom mode only. | **What it returns:** The updated watch. **Example prompt:** "Raise the cost alert on Acme to \$1,000" > **An edit that collides with an existing watch** gets the same friendly `409` the create path uses. A window that already alerted never re-fires just because you edited it. *** ### 46. delete\_customer\_alert **What it does:** Removes a watch. Its unread in-app notifications go with it. **Parameters:** | Name | Type | Required | Description | | ---- | ------ | -------- | -------------------------------------------- | | id | string | Yes | The watch's ID (from `get_customer_alerts`). | **What it returns:** A confirmation with the removed watch's ID. **Example prompt:** "Delete the revenue alert on Acme Corp" *** ## Matters for Law Firms (12) These twelve tools give an AI assistant everything the dashboard's Law tab does: tie AI usage to the legal matter it belongs to, sweep the rest with rules, and export a per-matter cost record that reconciles to the penny. See the [Matters API reference](/api-reference/matters) for the full plain-English explanation. A **matter** is the case or file a firm bills its work against. A usage event attaches to a matter three ways, strongest first: a **manual** assignment, the event's own `metadata.matterId` **tag** at record time, or a **routing rule**. Anything nothing claims stays in **Unassigned**. Every cost shown is the sum of real event costs, never an estimate. When an event's cost isn't resolved yet, it counts as 0 and is flagged in `eventsMissingCost`, so a nonzero count means the true cost is higher. All twelve tools need a secret key that belongs to an **owner, admin, finance, or legal** user. A **legal** key is a scoped key an owner or admin can mint so a firm can hand it to its own agent: it reaches these matter tools plus the usage-recording surface (`record_usage`, `verify`, its own key info, `get_spend_controls`) and nothing else. Customers, invoices, analytics, and pricing all answer 403 for it. *** ### 47. list\_matters **What it does:** Lists your matters, each with its all-time actual AI cost and event count. **Parameters:** | Name | Type | Required | Default | Description | | ----- | ------ | -------- | ------- | ------------------------ | | page | number | No | 1 | Which page of results | | limit | number | No | 10 | Results per page (1-100) | **What it returns:** A paginated list of matters, each with `id`, `matterNumber`, `name`, `clientName`, `status`, `actualCost`, `eventCount`, and `eventsMissingCost`. **Example prompt:** "Show me our legal matters and what each has cost" *** ### 48. get\_matter **What it does:** Gets one matter with its all-time cost, event count, and its 100 most recent events. The full record lives in `export_matter_audit`; `eventsCap` states the cutoff. **Parameters:** | Name | Type | Required | Description | | -------- | ------ | -------- | ----------------- | | matterId | string | Yes | The matter's UUID | **What it returns:** The matter, its `actualCost`, `eventCount`, `eventsMissingCost`, an `events` array, and `eventsCap`. **Example prompt:** "Show me the details on matter 2026-0142" *** ### 49. create\_matter **What it does:** Creates a matter. The `matterNumber` is the firm's own case number, unique per organization. Events recorded with `metadata.matterId` equal to it (trimmed, exact) attach to this matter automatically. **Parameters:** | Name | Type | Required | Description | | ----------------- | ------ | -------- | --------------------------------------------------- | | matterNumber | string | Yes | The firm's own case number, unique per organization | | name | string | Yes | Matter name, such as the case caption | | clientName | string | Yes | The client this matter is billed to | | status | string | No | `open` (default) or `closed` | | responsibleUserId | string | No | Responsible timekeeper, by user UUID | **What it returns:** The created matter. **Example prompt:** "Create a matter numbered 2026-0142, Acme v. Widgets, for client Acme Corp" > **If the number already exists:** the API answers with a `409` conflict. Matter numbers are unique per organization. *** ### 50. update\_matter **What it does:** Updates a matter. Only the fields you send change. Closing a matter (`status: closed`) is a label only. It doesn't stop anything at record time. **Parameters:** | Name | Type | Required | Description | | ----------------- | ------ | -------- | -------------------------------------------- | | matterId | string | Yes | The matter's UUID | | matterNumber | string | No | New matter number (unique per organization) | | name | string | No | New matter name | | clientName | string | No | New client name | | status | string | No | `open` or `closed` | | responsibleUserId | string | No | New responsible timekeeper; `null` clears it | **What it returns:** The updated matter. **Example prompt:** "Close matter 2026-0142" *** ### 51. delete\_matter **What it does:** Deletes a matter. Its usage events survive and return to Unassigned (the response counts them); the matter's routing rules are deleted with it. **Parameters:** | Name | Type | Required | Description | | -------- | ------ | -------- | ----------------- | | matterId | string | Yes | The matter's UUID | **What it returns:** `deleted`, `eventsReturnedToUnassigned`, and `routingRulesDeleted`. **Example prompt:** "Delete matter 2026-0142" *** ### 52. list\_unassigned\_events **What it does:** Lists the usage events no matter has claimed. `totalResults` is the true all-time count of unassigned events (never limited to the page), and `totalCost` is the cost of the whole set. **Parameters:** | Name | Type | Required | Default | Description | | ----- | ------ | -------- | ------- | ------------------------ | | page | number | No | 1 | Which page of results | | limit | number | No | 10 | Results per page (1-100) | **What it returns:** A page of unassigned event rows (agent, signal, quantity, cost, source), plus `totalResults`, `totalCost`, and `eventsMissingCost`. **Example prompt:** "What legal usage is still unassigned?" *** ### 53. assign\_event\_to\_matter **What it does:** Manually puts one usage event on a matter, or pulls it off (pass `matterId` as null). A manual choice outranks a tag or a rule, and a manual unassignment is never swept back up by `apply_matter_rules`. **Parameters:** | Name | Type | Required | Description | | -------- | ------ | -------- | ----------------------------------------------- | | eventId | string | Yes | The usage event to move, by UUID | | matterId | string | Yes | The target matter's UUID, or `null` to unassign | **What it returns:** `assigned`, `eventId`, `matterId`, and `source` (always `"manual"`). **Example prompt:** "Put event e2000000... on matter 2026-0142" *** ### 54. list\_matter\_rules **What it does:** Lists your routing rules in evaluation order (lowest priority first, older rules before newer on a tie). That's the exact order they run at record time and when you apply them. **Parameters:** | Name | Type | Required | Default | Description | | ----- | ------ | -------- | ------- | ------------------------ | | page | number | No | 1 | Which page of results | | limit | number | No | 10 | Results per page (1-100) | **What it returns:** A page of rules, each with `id`, `matterId`, `matterNumber`, `matterName`, `priority`, `agentCode`, `customerExternalId`, and `signalPattern`. **Example prompt:** "Show me our matter routing rules" *** ### 55. upsert\_matter\_rule **What it does:** Creates a routing rule (omit `ruleId`) or updates one (pass `ruleId`). A rule matches an event when every matcher it sets matches: `agentCode` exact, `customerExternalId` exact, `signalPattern` a case-insensitive substring of the signal name. At least one matcher is required; lower `priority` runs first. On update, sending a matcher as `null` clears it. **Parameters:** | Name | Type | Required | Description | | ------------------ | ------ | ---------------- | -------------------------------------------------------------- | | ruleId | string | No | Omit to create, pass to update | | matterId | string | On create | The matter this rule assigns events to (UUID) | | priority | number | No (default 100) | Lower runs first | | agentCode | string | No | Match this exact agent code | | customerExternalId | string | No | Match this exact customer external ID | | signalPattern | string | No | Match this text anywhere in the signal name (case-insensitive) | **What it returns:** The rule, including its matter's number and name. **Example prompt:** "Route all research-bot events to matter 2026-0142" *** ### 56. delete\_matter\_rule **What it does:** Deletes a routing rule. Events it already assigned keep their matter; only future matching stops. **Parameters:** | Name | Type | Required | Description | | ------ | ------ | -------- | --------------- | | ruleId | string | Yes | The rule's UUID | **What it returns:** `deleted`. **Example prompt:** "Delete that routing rule" *** ### 57. apply\_matter\_rules **What it does:** Sweeps your rules across events that are still Unassigned, in priority order. It touches only events with no matter and no attribution history, so it never overrides a tag, an earlier rule, or a manual choice. **Parameters:** None. **What it returns:** `assigned`, the count of events the sweep attached. **Example prompt:** "Apply the matter rules to catch up the unassigned events" *** ### 58. export\_matter\_audit **What it does:** Returns the record a firm shows a client or a carrier: actual AI cost per matter for a window. The default `summary` view is one row per matter, plus an Unassigned row, plus organization totals, and it reconciles exactly with the dashboard's CSV export for the same window. `detail` adds one row per event. Unresolved costs count as 0 and are flagged, never invented. **Parameters:** | Name | Type | Required | Default | Description | | -------- | ------ | -------- | ----------- | ------------------------------------------------------------------------------------ | | start | string | No | 30 days ago | Window start, `YYYY-MM-DD` or full ISO timestamp | | end | string | No | now | Window end, `YYYY-MM-DD` or full ISO timestamp (capped at now) | | format | string | No | summary | `summary` (one row per matter + Unassigned + totals) or `detail` (one row per event) | | matterId | string | No | -- | Slice to one matter (UUID). A sliced view has no Unassigned row and no org totals | **What it returns:** `periodStart`, `periodEnd`, a `summary` array, `totalCost`, `totalEvents`, and `eventsMissingCost`. When `format` is `detail`, the response also carries a `rows` array. **Example prompt:** "Export the per-matter cost audit for August" # Integrate with Anthropic Source: https://docs.marginfront.com/recipes/anthropic Add MarginFront usage tracking to your Anthropic Claude agent # Integrate MarginFront with Anthropic This recipe shows how to add MarginFront usage tracking to an app that calls the Anthropic API (Claude models). Same pattern as the [OpenAI recipe](./openai), but Anthropic names its fields slightly differently. **What this does for you:** You call Claude. MarginFront records the model, token count, and customer. You see costs on your dashboard. If you have a pricing plan, your customer gets billed automatically. *** ## Prerequisites 1. A MarginFront API key (`mf_sk_...`). Get one from **Build > API keys** in the dashboard. 2. An Anthropic API key. > **You don't need to create the agent or signal first.** When you fire your first event with a new `agentCode` or `signalName`, MarginFront creates them automatically. The same goes for `customerExternalId`. You can rename and enrich any of them in the dashboard later. ```bash theme={null} npm install @marginfront/sdk @anthropic-ai/sdk express ``` *** ## Complete working example Copy-paste and run. Receives a question from a customer, asks Claude for an answer, sends the answer back, and tells MarginFront what happened. ```typescript theme={null} import express from "express"; import Anthropic from "@anthropic-ai/sdk"; import { MarginFrontClient } from "@marginfront/sdk"; const app = express(); app.use(express.json()); // Set up clients once at startup const anthropic = new Anthropic(); // reads ANTHROPIC_API_KEY from environment const mf = new MarginFrontClient(process.env.MF_API_SECRET_KEY!); app.post("/research", async (req, res) => { const { customerId, question } = req.body; // --- Step 1: Call Claude --- const response = await anthropic.messages.create({ model: "claude-sonnet-4-20250514", max_tokens: 1024, messages: [{ role: "user", content: question }], }); // --- Step 2: Send the answer to your customer --- const answer = response.content[0].type === "text" ? response.content[0].text : ""; res.json({ answer }); // --- Step 3: Track the usage in MarginFront (AFTER responding) --- // Runs after the response is sent. If MarginFront is unreachable, // the SDK queues the event and retries. Your customer never waits. await mf.usage.record({ customerExternalId: customerId, // who used it agentCode: "research-bot", // which product did the work signalName: "analyses", // what you're measuring model: response.model, // 'claude-sonnet-4-20250514' -- from Anthropic's response modelProvider: "anthropic", // tells MarginFront which pricing table to check inputTokens: response.usage.input_tokens, // Anthropic calls it input_tokens outputTokens: response.usage.output_tokens, // Anthropic calls it output_tokens }); }); app.listen(3000, () => { console.log("Server running on http://localhost:3000"); }); ``` *** ## Where the data comes from: field mapping | What MarginFront needs | Where to get it from Anthropic | Example value | | ---------------------- | -------------------------------------------- | ---------------------------- | | `model` | `response.model` | `"claude-sonnet-4-20250514"` | | `inputTokens` | `response.usage.input_tokens` | `1024` | | `outputTokens` | `response.usage.output_tokens` | `512` | | `cacheReadTokens` | `response.usage.cache_read_input_tokens` | `15626` | | `cacheWriteTokens` | `response.usage.cache_creation_input_tokens` | `23488` | The two cache fields are optional — only send them if you use prompt caching. See [Prompt caching](#prompt-caching) below. ### The naming difference from OpenAI This is the one thing that trips people up: | Provider | Input tokens field | Output tokens field | | --------- | --------------------- | ------------------------- | | OpenAI | `usage.prompt_tokens` | `usage.completion_tokens` | | Anthropic | `usage.input_tokens` | `usage.output_tokens` | MarginFront always uses `inputTokens` and `outputTokens` (camelCase). You just need to read from the right field on the provider side. *** ## Prompt caching If you use Claude prompt caching, pass the cache token counts so MarginFront prices cached traffic at the cheaper cache rate instead of folding it into the full input rate. Both fields are optional — leave them off and cache contributes nothing, exactly as before. Anthropic returns two cache counts on `response.usage`: | MarginFront field | Anthropic field | What it is | | ------------------ | -------------------------------------------- | ----------------------------------- | | `cacheReadTokens` | `response.usage.cache_read_input_tokens` | Tokens served from the cache (hits) | | `cacheWriteTokens` | `response.usage.cache_creation_input_tokens` | Tokens written into the cache | ```typescript theme={null} const response = await anthropic.messages.create({ model: "claude-sonnet-4-20250514", max_tokens: 1024, messages: [{ role: "user", content: question }], }); await mf.usage.record({ customerExternalId: customerId, agentCode: "research-bot", signalName: "analyses", model: response.model, modelProvider: "anthropic", inputTokens: response.usage.input_tokens, outputTokens: response.usage.output_tokens, cacheReadTokens: response.usage.cache_read_input_tokens, // cache hits cacheWriteTokens: response.usage.cache_creation_input_tokens, // cache writes }); ``` Read-back of these counts depends on your Claude API version having prompt caching enabled. If a response has no cached tokens, the fields come back as `0` — pass them through as-is or omit them. *** ## What happens if MarginFront is down? Same as OpenAI -- nothing bad. The SDK runs in **fire-and-forget mode** by default: * **MarginFront unreachable?** Event goes into a local retry buffer. Retries automatically. * **Validation error?** Warning logged, event dropped. Your customer still got their answer. * **Your server crashes?** That one event is lost. Acceptable for most use cases. **MarginFront never blocks your agent.** Your customer always gets their answer. *** ## Streaming responses Anthropic's streaming works differently from OpenAI. The `message_stop` event includes the final message with usage data: ```typescript theme={null} const stream = await anthropic.messages.stream({ model: "claude-sonnet-4-20250514", max_tokens: 1024, messages: [{ role: "user", content: question }], }); // Collect chunks as they arrive let fullContent = ""; stream.on("text", (text) => { fullContent += text; // Send each chunk to the customer as it arrives }); // Wait for the stream to finish to get usage data const finalMessage = await stream.finalMessage(); await mf.usage.record({ customerExternalId: customerId, agentCode: "research-bot", signalName: "analyses", model: finalMessage.model, modelProvider: "anthropic", inputTokens: finalMessage.usage.input_tokens, outputTokens: finalMessage.usage.output_tokens, }); ``` Unlike OpenAI, you don't need to pass any special option to get usage data -- Anthropic always includes it in the final message. *** ## Extended thinking (Claude models with thinking enabled) If you use Claude's extended thinking feature, the input and output token counts in `response.usage` already include thinking tokens. You don't need to do anything special -- just pass them as-is: ```typescript theme={null} const response = await anthropic.messages.create({ model: "claude-sonnet-4-20250514", max_tokens: 16000, thinking: { type: "enabled", budget_tokens: 10000, }, messages: [{ role: "user", content: question }], }); // Usage already includes thinking tokens -- no extra work needed await mf.usage.record({ customerExternalId: customerId, agentCode: "research-bot", signalName: "deep-analyses", model: response.model, modelProvider: "anthropic", inputTokens: response.usage.input_tokens, outputTokens: response.usage.output_tokens, }); ``` *** ## Using multiple Claude models If your agent picks different models for different tasks (e.g., Haiku for fast summaries, Sonnet for detailed analysis), just pass the model that was actually used: ```typescript theme={null} const modelToUse = needsDeepAnalysis ? "claude-sonnet-4-20250514" : "claude-haiku-4-20250514"; const response = await anthropic.messages.create({ model: modelToUse, max_tokens: 1024, messages: [{ role: "user", content: question }], }); // response.model reflects whichever model was used await mf.usage.record({ customerExternalId: customerId, agentCode: "research-bot", signalName: "analyses", model: response.model, modelProvider: "anthropic", inputTokens: response.usage.input_tokens, outputTokens: response.usage.output_tokens, }); ``` MarginFront looks up the cost per model automatically. A Haiku call costs less than a Sonnet call, and your dashboard shows the difference. *** ## Error handling (if you want more control) Turn off fire-and-forget to catch tracking errors: ```typescript theme={null} const mf = new MarginFrontClient(process.env.MF_API_SECRET_KEY!, { fireAndForget: false, }); try { await mf.usage.record({ customerExternalId: customerId, agentCode: "research-bot", signalName: "analyses", model: response.model, modelProvider: "anthropic", inputTokens: response.usage.input_tokens, outputTokens: response.usage.output_tokens, }); } catch (error) { // Log it, but don't crash your request handler console.error("MarginFront tracking failed:", error); } ``` *** ## Auto-provisioning note If you log an event for a `customerExternalId` or `agentCode` that MarginFront hasn't seen before, it will auto-create a minimal customer or agent record. Handy for prototyping, but can mask typos. If events show up under an unexpected name, check your IDs in the dashboard. *** ## Reading back what you earned You sent events. Now you want numbers: revenue, cost, margin, MRR. Three canonical paths, all point to the same data. ### Via SDK (inside your app) ```typescript theme={null} import { MarginFrontClient } from "@marginfront/sdk"; const mf = new MarginFrontClient(process.env.MF_API_SECRET_KEY!); // Last 30 days of revenue + cost + margin for one customer const end = new Date(); const start = new Date(end.getTime() - 30 * 24 * 60 * 60 * 1000); const revenue = await mf.analytics.revenue({ startDate: start.toISOString(), endDate: end.toISOString(), customerId: "cus_123", }); const cost = await mf.analytics.costBreakdown({ startDate: start.toISOString(), endDate: end.toISOString(), customerId: "cus_123", }); const mrr = await mf.analytics.mrr({ customerId: "cus_123" }); ``` ### Via REST (from any language or `curl`) ```bash Bash theme={null} curl "https://api.marginfront.com/v1/analytics/revenue?customerId=cus_123&startDate=2026-03-25&endDate=2026-04-24" \ -H "x-api-key: $MF_API_SECRET_KEY" curl "https://api.marginfront.com/v1/analytics/cost?customerId=cus_123&startDate=2026-03-25&endDate=2026-04-24" \ -H "x-api-key: $MF_API_SECRET_KEY" curl "https://api.marginfront.com/v1/analytics/mrr?customerId=cus_123" \ -H "x-api-key: $MF_API_SECRET_KEY" ``` ```powershell PowerShell theme={null} curl.exe "https://api.marginfront.com/v1/analytics/revenue?customerId=cus_123&startDate=2026-03-25&endDate=2026-04-24" ` -H "x-api-key: $env:MF_API_SECRET_KEY" curl.exe "https://api.marginfront.com/v1/analytics/cost?customerId=cus_123&startDate=2026-03-25&endDate=2026-04-24" ` -H "x-api-key: $env:MF_API_SECRET_KEY" curl.exe "https://api.marginfront.com/v1/analytics/mrr?customerId=cus_123" ` -H "x-api-key: $env:MF_API_SECRET_KEY" ``` ### Via MCP (from an AI agent like Claude, Cursor, or Copilot) If you've connected MarginFront's MCP server to your AI coding assistant, the agent can call canonical analytics tools directly: ``` Tool: get_customer_revenue Args: { customerExternalId: "acme-001", startDate: "2026-03-25", endDate: "2026-04-24" } Tool: get_cost_metrics Args: { startDate: "2026-03-25", endDate: "2026-04-24", customerExternalId: "acme-001" } Tool: get_mrr Args: { customerExternalId: "acme-001" } ``` All three paths return the same canonical numbers. Pick whichever fits your workflow. # Credit Pools Source: https://docs.marginfront.com/recipes/credit-pools Sell a bundle of usage upfront, watch it count down, and read the same balance from your own code # Sell a Credit Pool (and Stay Aligned With Your Own Bill) You sell an AI agent. You want to price it the way customers like to buy: "5,000 tasks for \$99 a month, then 3 cents per task after that." That pricing shape is a **credit pool**. This guide walks the whole loop, start to finish: 1. Create the pool pricing 2. Connect it to your agent and a customer 3. Record usage from your code 4. Read the countdown from your code, so your product and your invoice always say the same thing One rule to hold onto the whole way through, because everything else follows from it: > **Nothing stops when the pool hits zero.** MarginFront is the meter, never the breaker. A customer who uses up their pool keeps working, and the extra usage bills at your overage rate. If you want usage to stop at zero, that decision belongs in your code, and the last section shows you exactly how. ## Before you start You need: * A secret API key (`mf_sk_...`) from **Build > API keys** in the dashboard * The SDK installed if you want the SDK examples: `npm install @marginfront/sdk` ```typescript theme={null} import { MarginFrontClient } from "@marginfront/sdk"; const mf = new MarginFrontClient(process.env.MF_API_SECRET_KEY); ``` A note on order: usage tracking alone needs no setup (agents, signals, and customers auto-create on the first event). Credit pools are different. The pool prices a **signal**, so the agent and the signal have to exist before you can create the pool. That's why this guide creates them explicitly in steps 1 and 2. *** ## Step 1: Create the agent and the signal The **agent** is your product. The **signal** is the unit you sell (the word your customer would use: tasks, reports, messages). ```bash theme={null} curl -X POST https://api.marginfront.com/v1/agents \ -H "x-api-key: mf_sk_your_key" \ -H "Content-Type: application/json" \ -d '{ "name": "Task Runner", "agentCode": "task-runner" }' ``` ```bash theme={null} curl -X POST https://api.marginfront.com/v1/signals \ -H "x-api-key: mf_sk_your_key" \ -H "Content-Type: application/json" \ -d '{ "name": "tasks", "agentId": "AGENT_ID" }' ``` Save the `id` from each response. The next steps need them. (You can also create both in the dashboard, or ask the MCP server to do it.) ## Step 2: Create the plan and the credit pool A **plan** is the container ("Pro Plan"). The **credit pool** is one pricing rule inside it. You describe the pool with exactly three numbers: | Number | Meaning | Our example | | ------------- | ---------------------------------------------------------- | ----------- | | `poolSize` | How many units the pool covers each billing cycle | 5000 | | `poolPrice` | The flat fee charged every cycle, whether or not it's used | 99 | | `overageRate` | The per-unit price after the pool runs out | 0.03 | First the plan: ```bash theme={null} curl -X POST https://api.marginfront.com/v1/pricing-plans \ -H "x-api-key: mf_sk_your_key" \ -H "Content-Type: application/json" \ -d '{ "name": "Pro Plan" }' ``` Then the pool. One call, three numbers. Pick whichever surface you're using: **SDK:** ```typescript theme={null} const strategy = await mf.pricingStrategies.createCreditPool("PLAN_ID", { name: "Task Pool", agentId: "AGENT_ID", signalId: "SIGNAL_ID", poolSize: 5000, poolPrice: 99, overageRate: 0.03, }); ``` **curl:** ```bash theme={null} curl -X POST https://api.marginfront.com/v1/pricing-plans/PLAN_ID/pricing-strategies \ -H "x-api-key: mf_sk_your_key" \ -H "Content-Type: application/json" \ -d '[ { "name": "Task Pool", "agentId": "AGENT_ID", "chargeType": "usage", "pricingModel": "credit_pool", "signalId": "SIGNAL_ID", "creditPool": { "poolSize": 5000, "poolPrice": 99, "overageRate": 0.03 } } ]' ``` (The body is an array because this endpoint creates strategies in bulk. One strategy still goes in an array.) **MCP:** ask your AI assistant to run `create_pricing_strategy` with `pricingModel: "credit_pool"` and the same `creditPool` block. In plain English: "Add a credit pool to the Pro Plan: 5,000 tasks for \$99 a month, then 3 cents each." The server compiles those three numbers into the stored two-tier shape for you. Two rules the server enforces, with plain error messages when you hit them: * **One pool per plan.** A second active credit pool on the same plan is rejected. Put a second pool on its own plan. * **No minimum commitment on a pool.** The pool fee already is the minimum the customer pays every cycle. ## Step 3: Link the plan to the agent ```bash theme={null} curl -X POST https://api.marginfront.com/v1/pricing-plans/PLAN_ID/agents \ -H "x-api-key: mf_sk_your_key" \ -H "Content-Type: application/json" \ -d '{ "agentId": "AGENT_ID" }' ``` Or with the SDK: `await mf.pricingPlans.linkAgent("PLAN_ID", "AGENT_ID")`. ## Step 4: Create the customer ```typescript theme={null} const customer = await mf.customers.create({ name: "Acme Corp", externalId: "acme-001", email: "billing@acme.com", }); ``` `externalId` is the ID from your own system. It's how your usage events find this customer later. ## Step 5: Create the subscription The subscription ties the customer to the plan and starts the clock. The billing period comes from `startDate`, and the pool's countdown lives inside that period. ```bash theme={null} curl -X POST https://api.marginfront.com/v1/subscriptions \ -H "x-api-key: mf_sk_your_key" \ -H "Content-Type: application/json" \ -d '{ "name": "Acme Corp: Pro Plan", "planId": "PLAN_ID", "agentId": "AGENT_ID", "customerId": "CUSTOMER_ID", "startDate": "2026-08-01T00:00:00Z", "billingCycle": "monthly", "billingModel": "usage" }' ``` The response includes `currentBillingPeriodStart` and `currentBillingPeriodEnd`. That window is the pool's period: 5,000 fresh units each cycle, unused units don't roll over, and the flat \$99 bills every cycle either way. The SDK doesn't create subscriptions today (it reads them). Use curl, the dashboard, or the MCP `create_subscription` tool for this step. ## Step 6: Record usage This part is the normal MarginFront integration. Fire one event per business outcome, with `quantity` counting the signal's units: ```typescript theme={null} await mf.usage.record({ customerExternalId: "acme-001", agentCode: "task-runner", signalName: "tasks", model: "claude-sonnet-4", modelProvider: "anthropic", inputTokens: 1200, outputTokens: 300, quantity: 1, // one task done }); ``` Each event's quantity draws down the pool as it lands. Record 250 tasks and the pool reads 4,750 left. See [Tracking Events](/sdk/tracking-events) for the full event guide. ## Step 7: Read the balance from your own code This is the step that keeps your product and your bill telling the same story. The dashboard, the API, the SDK, and the MCP server all read one shared balance path, so the number your code sees is the number the invoice math uses. **SDK:** ```typescript theme={null} // All pools, emptiest first const { balances } = await mf.creditBalances.list(); // One subscription const pool = await mf.creditBalances.get("SUBSCRIPTION_ID"); console.log(`${pool.remainingUnits} of ${pool.poolSizeUnits} units left`); // Only the pools that need a human: 20% or less remaining const lowPools = await mf.creditBalances.list({ belowPercent: 20 }); ``` **curl:** ```bash theme={null} curl https://api.marginfront.com/v1/credit-balances \ -H "x-api-key: mf_sk_your_key" curl https://api.marginfront.com/v1/subscriptions/SUBSCRIPTION_ID/credit-balance \ -H "x-api-key: mf_sk_your_key" ``` **MCP:** ask "how many units does Acme have left?" and the assistant runs `get_credit_balances`. Reads work with any secret key for your organization, except an **ingest-only** key, which gets a clean 403. The full field list lives in the [Credit Balances API reference](/api-reference/credit-balances). *** ## Letting more than one metric draw from the pool Out of the box a pool counts one thing: the signal you priced. One unit of that signal takes one credit out of the pool. Most products sell more than one thing. A report costs you more to produce than a note does, and you want both to come out of the same bundle the customer bought. `creditRates` is how you say that. It maps each signal to how many credits one unit of it burns: ```typescript theme={null} await mf.pricingStrategies.update("PLAN_ID", "STRATEGY_ID", { creditRates: { SIGNAL_REPORT_ID: 4, // one report burns 4 credits SIGNAL_VIDEO_ID: 10, // one video burns 10 SIGNAL_NOTE_ID: 0.5, // two notes burn 1 credit }, }); ``` Now "5,000 credits for \$99" buys any mix the customer likes: 1,250 reports, or 500 videos, or 10,000 notes, or any blend that adds up to 5,000. ### The rules the server enforces Each of these comes back as a `400` with a message naming the fix: * **Every rate has to be above zero.** To make a metric free, leave it out of the map. A rate of `0` is refused rather than accepted, because it usually means someone left the field blank. * **Every key has to be a live signal in your organization.** A mistyped ID would sit in the map forever, matching nothing. * **A metric can burn pool credits or bill per unit, never both.** If a signal draws from the pool and also has its own usage pricing on the same plan, the customer pays twice for one piece of work. That write is refused. * **The pool's own signal is a member whether or not you name it**, at one credit per unit. Name it only when you want a different number. * **Rates belong on a credit pool and nowhere else.** Send `{}` to clear them. Leave the field out and the stored rates stay put. ### One countdown, not one per metric Members share a single balance, so the customer watches one number: ```typescript theme={null} const pool = await mf.creditBalances.get("SUBSCRIPTION_ID"); console.log(`${pool.remainingUnits} of ${pool.poolSizeUnits} credits left`); ``` Two reports and three notes at the rates above take 9.5 credits out of the pool, not "two of one thing and three of another." Overage works the same way: credits past the pool size, priced at your overage rate. The balance fields keep their `...Units` names so nothing breaks for single-metric pools. Once you set rates, read "unit" as "credit." ### If you want a separate ceiling per metric, sell two plans A pool is one balance. There's no per-metric limit inside it, so "100 reports AND 50 videos, each capped on its own" isn't something rates can express. Sell that as two plans, each with its own pool, and the two balances stay independent. Rates describe relative cost, not separate allowances. ### Don't count the same unit twice Before rates existed, the way to fake a multi-metric pool was to send the credit cost as the quantity. A report that "cost" 4 credits went in as `quantity: 4`. That trick and real rates don't mix. Turn on a rate of 4 while your events still arrive pre-multiplied and every report takes 16 credits. Pick one or the other. If you're moving off the workaround, set the rates and switch those events back to real units (one report is `quantity: 1`) in the same change. Events already recorded keep the quantity they were recorded with, so make the switch at the start of a billing period unless you want a mixed period on the bill. ### When a rate change takes effect A rate edit isn't retroactive and isn't delayed. Draws already taken keep the rate that was in force when the event arrived, and the invoice applies the current rate when the period closes. In practice the edit lands on the period that's running right now. *** ## The two numbers (read this once, save yourself a support ticket) Every balance row carries two numbers that can disagree, and both are right: * **`remainingUnits`** is the countdown, and it counts your manual top-ups. * **`overageInProgressUnits`** is the invoice's own overage math, which never sees top-ups, so the bill can never disagree with what this field told you. Example: Acme burns through all 5,000 units, then uses 300 more, and you top them up with 1,500 goodwill units. The balance now honestly reads `remainingUnits: 1200` AND `overageInProgressUnits: 300`. The customer has units to use, and 300 units are billing as overage this period. Both true at once. When you show a customer what they'll owe, use `overageInProgressUnits`. When you show them what's left, use `remainingUnits`. ## What zero (and negative) means `remainingUnits` goes negative once the pool is overdrawn. Nothing else changes: * The customer's requests keep working. MarginFront never blocks, throttles, or drops anything. * Every unit past the pool bills at your `overageRate` when the period's invoice is generated. * The pool refills at the next billing period. MarginFront is the meter, never the breaker. If your product promises "strict prepaid, no surprises," the last section of this guide is yours. ## The alert ladder: 50%, 80%, 95%, empty As a pool drains, MarginFront emails your team at four stages: **50% used, 80% used, 95% used, and empty** (empty means remaining units at or below zero). How the emails behave: * **Who gets them:** your organization's billing email if one is set (Settings > Billing), otherwise every member with the owner or finance role. * **One email per stage per period.** A pool that crosses 80% today won't re-email 80% tomorrow. Every stage re-arms when the next billing period starts. * **Only the highest new stage emails.** A pool that jumps from fresh to 97% in one burst gets one 95% email, not three. * **Topping up un-crosses stages, but never rewinds an email.** Add units and the used percent drops. A stage that already emailed this period stays quiet even if the pool drains back through it. Every stage re-arms when the next billing period starts. * **Pause and resume anytime.** Pausing silences the emails for one subscription and nothing else: the countdown keeps counting and the balance stays visible everywhere. Pause from the dashboard or `POST /v1/subscriptions/:id/credit-alerts-pause` with `{"paused": true}`. Send `{"paused": false}` to turn them back on; stages the pool crossed while paused can then fire on the next evaluation. The emails link to the dashboard's Needs Attention page, which brings us to: ## When a pool runs dry: the Needs Attention flow The dashboard's **Needs Attention** page (the link inside every pool email) lists every pool that's empty or under 20% remaining, with an honest banner: "Working as designed: nothing is blocked, the overage keeps billing." Paused-alert pools still appear there, labeled Paused, because pausing emails doesn't hide the situation. Each row has a **Top up or pause** button with the two resolutions a human actually reaches for: * **Top up**: add units to the pool right now. * **Pause or resume alerts**: quiet the emails for a customer you've already talked to. The same section, with the same actions, lives in **Unit Economics > Plans** under Credit Pools. Both roads resolve identically. ## Topping up A top-up adds units to a customer's pool mid-period. From the dashboard it's a button; from the API: ```bash theme={null} curl -X POST https://api.marginfront.com/v1/subscriptions/SUBSCRIPTION_ID/credit-topup \ -H "x-api-key: mf_sk_your_key" \ -H "Content-Type: application/json" \ -d '{ "units": 1500, "idempotencyKey": "topup-acme-2026-08-goodwill", "note": "Goodwill credit for the August incident" }' ``` Three things worth knowing: * **`idempotencyKey` is required, and it's your safety net.** Send the same key twice (a retry, a double-click) and the SAME grant comes back instead of a second one, so a customer can never be double-credited. Reuse a key with different units, or on a different subscription, and you get a 409 telling you so. Any string unique to the top-up works; a UUID is fine. * **Top-ups never expire.** The customer paid for those units. Draw order burns the current period's grant first, so a top-up remainder survives into the next period. * **A top-up doesn't shrink overage already on the books.** It raises `remainingUnits` going forward. The overage the invoice already counted stays counted (that's the two-numbers rule from above). Top-ups and alert pausing need a key or dashboard user with the **owner, admin, or finance** role. Anything less gets a 403 (an API-key caller sees the allowed roles named in the message; a dashboard session without the role gets a generic forbidden). ## If your product sells strict prepaid: the self-limiting pattern Maybe your promise to customers is "when the credits run out, the agent stops." MarginFront won't stop it for you, on purpose: a billing hiccup should never take your product down, and plenty of businesses want overage revenue. **Your code is the enforcement point.** The balance read exists exactly so you can make that call: ```typescript theme={null} async function runTask(customerExternalId: string, subscriptionId: string) { const pool = await mf.creditBalances.get(subscriptionId); if (pool.remainingUnits <= 0) { // YOUR product's decision, enforced by YOUR code. // MarginFront reported the number; it did not block anything. return { ok: false, message: "You're out of credits. Top up to keep going.", }; } const result = await doTheActualWork(); await mf.usage.record({ customerExternalId, agentCode: "task-runner", signalName: "tasks", model: "claude-sonnet-4", modelProvider: "anthropic", inputTokens: result.inputTokens, outputTokens: result.outputTokens, quantity: 1, }); return { ok: true, result }; } ``` Being honest about this pattern's edges: * The balance read is a point-in-time snapshot with a short cache (up to 30 seconds). Requests already in flight when the pool empties will land and bill as overage. For most products a few units of slack is nothing; if it matters, check less often and keep your own local count between checks. * Checking before every single request adds a network read to your hot path. A common middle ground: check every Nth request, or only once `remainingUnits` drops under 10% of `poolSizeUnits`. * If you block at zero, tell the customer how to get going again (a top-up on your end, or their next billing period). ## Next steps * **[Credit Balances API reference](/api-reference/credit-balances)**: every field on the balance row, both write endpoints, all the error cases * **[Pricing Strategies](/api-reference/pricing-strategies)**: the stored tier shape behind the three numbers, and every validation rule * **[Tracking Events](/sdk/tracking-events)**: the full guide to recording usage # Non-LLM Tools Source: https://docs.marginfront.com/recipes/non-llm Track SMS, scraping, PDF generation, and other non-LLM usage # Integrate MarginFront with Non-LLM Tools Not everything your agent does is an LLM call. Your agent might send SMS messages via Twilio, scrape web pages, generate PDFs, send emails, process images, or transcribe audio. MarginFront tracks all of it through the same event endpoint. **What this does for you:** Every tool your agent uses -- LLM or not -- shows up in one dashboard. You see the full cost picture, not just the AI part. *** ## How non-LLM events differ from LLM events Same endpoint (`POST /v1/usage/record`), same SDK method (`mf.usage.record()`). The difference is which fields carry the "how much" information: | Event type | "How much" field | Example | | ---------- | ------------------------------ | ----------------------------------- | | LLM | `inputTokens` + `outputTokens` | 523 input tokens, 117 output tokens | | Non-LLM | `quantity` | 1 SMS sent, 12 pages scraped | For non-LLM events, `model` and `modelProvider` are descriptive labels you choose. Use something readable -- they'll show up in your dashboard and analytics. `inputTokens` and `outputTokens` are always optional; leave them out for non-LLM work. *** ## Prerequisites ```bash theme={null} npm install @marginfront/sdk ``` ```typescript theme={null} import { MarginFrontClient } from "@marginfront/sdk"; // One client, reused for all events (LLM and non-LLM) const mf = new MarginFrontClient(process.env.MF_API_SECRET_KEY!); ``` > **You don't need to create the agent or signal first.** When you fire your first event with a new `agentCode` or `signalName`, MarginFront creates them automatically. The same goes for `customerExternalId`. You can rename and enrich any of them in the dashboard later. *** ## Example 1: Twilio SMS Your agent sends a text message to a customer's phone number. You want to track each SMS as one unit of usage. ```typescript theme={null} import twilio from "twilio"; const twilioClient = twilio( process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN, ); // Send the SMS first const smsResult = await twilioClient.messages.create({ body: "Your order has shipped! Tracking: ABC123", from: process.env.TWILIO_PHONE_NUMBER, to: customerPhone, }); // Then tell MarginFront about it await mf.usage.record({ customerExternalId: customerId, agentCode: "notification-bot", signalName: "sms-sent", model: "sms-send", // descriptive label -- you pick this modelProvider: "twilio", // who provided the service quantity: 1, // one SMS was sent }); ``` **Why `model: 'sms-send'`?** For non-LLM services, `model` is just a label that helps you identify what happened when you look at the dashboard later. Pick something readable. Other good options: `'outbound-sms'`, `'sms-notification'`, `'transactional-sms'`. *** ## Example 2: Web scraping (variable quantity) Your research agent scrapes a website and returns multiple pages of content. The number of pages varies per job, so `quantity` changes each time. ```typescript theme={null} // Scrape the target URL const pages = await scraper.scrape(targetUrl); // Process the scraped content (whatever your agent does with it) const summary = await processPages(pages); // Track the usage -- quantity is how many pages were scraped await mf.usage.record({ customerExternalId: customerId, agentCode: "research-bot", signalName: "pages-scraped", model: "web-scraper", // descriptive label for the tool modelProvider: "internal", // "internal" works for tools you built yourself quantity: pages.length, // could be 3, could be 50 -- depends on the site }); ``` **Why `modelProvider: 'internal'`?** If the tool is something you built (not a third-party service), use `'internal'` as the provider. MarginFront won't find it in any pricing table, so the cost will be `null` -- which is fine. You're tracking the signal quantity for billing purposes, not calculating LLM costs. > **Fire ONE event when the whole scrape job finishes -- not one event per page.** `quantity: pages.length` is how you tell MarginFront this one job handled N pages. Looping to fire one event per page would multiply your invoice, flood your analytics, and burn API calls for no reason. Same rule for minutes of audio, images in a batch, messages in a thread -- one event per outcome, `quantity` does the counting. See [Choosing your signal name and quantity](/concepts#choosing-your-signal-name-and-quantity) for the full rule. *** ## Example 3: PDF generation Your reporting agent generates a multi-page PDF for a customer. You bill by the number of pages in the report. ```typescript theme={null} // Generate the PDF const pdf = await generateReport(customerData); // Send it to the customer res.setHeader("Content-Type", "application/pdf"); res.send(pdf.buffer); // Track the usage await mf.usage.record({ customerExternalId: customerId, agentCode: "report-bot", signalName: "report-pages", model: "pdf-generator", // descriptive label modelProvider: "internal", // you built this tool quantity: pdf.pageCount, // 1-page report costs less than a 20-page report }); ``` *** ## Example 4: Email sending (via Resend, SendGrid, etc.) ```typescript theme={null} import { Resend } from "resend"; const resend = new Resend(process.env.RESEND_API_KEY); await resend.emails.send({ from: "agent@yourapp.com", to: recipientEmail, subject: "Your weekly report", html: reportHtml, }); await mf.usage.record({ customerExternalId: customerId, agentCode: "notification-bot", signalName: "emails-sent", model: "transactional-email", modelProvider: "resend", quantity: 1, }); ``` *** ## Example 5: Mixing LLM and non-LLM in one workflow Real agents often use multiple services for a single business outcome. A place-report agent generates a report about a location by searching Google for context, then asking Gemini to analyze + write it, then calling the Places API to attach map metadata. From the customer's perspective that's ONE report about ONE place. From your cost perspective three underlying services contributed: a search API, an LLM, and a non-LLM map lookup. Track it as ONE event with a `services` array. Each entry becomes a per-service cost line under one parent event. The dashboard shows one event with a rolled-up total; the Cost-by-service chart splits it across the three services. ```typescript theme={null} import { GoogleGenAI } from "@google/genai"; import { Client as MapsClient } from "@googlemaps/google-maps-services-js"; import { MarginFrontClient } from "@marginfront/sdk"; const gemini = new GoogleGenAI({}); const maps = new MapsClient({}); const mf = new MarginFrontClient(process.env.MF_API_SECRET_KEY); async function generatePlaceReport(customerId: string, placeName: string) { // Step 1: Google Search API for background context on the place const searchResults = await googleSearch.query({ q: placeName, num: 5 }); // Step 2: Gemini analyzes the search results and writes the report const geminiResponse = await gemini.models.generateContent({ model: "gemini-2.5-pro", contents: `Write a market report about ${placeName}. Sources: ${JSON.stringify(searchResults)}`, }); const reportText = geminiResponse.text ?? ""; // Step 3: Places API attaches map metadata (address, hours, rating) const placesData = await maps.placesNearby({ params: { location: placeName, radius: 1000, key: process.env.GOOGLE_MAPS_KEY, }, }); // Step 4: Tell MarginFront about the WHOLE place report (one event, three services) await mf.usage.record({ customerExternalId: customerId, agentCode: "place-report-bot", signalName: "place-reports", // Top-level quantity stays signal-level: ONE place report. // Per-service volume lives inside each services[] entry. quantity: 1, services: [ { // Google Search API call(s) for background context model: "google-search", modelProvider: "google", quantity: 1, // 1 search query }, { // Gemini analyzed the search results and wrote the report. // Track tokens AND the call count: useful for analytics + future // per-call pricing once the catalog supports it. model: geminiResponse.modelVersion ?? "gemini-2.5-pro", modelProvider: "google", inputTokens: geminiResponse.usageMetadata?.promptTokenCount ?? 0, outputTokens: geminiResponse.usageMetadata?.candidatesTokenCount ?? 0, quantity: 1, // 1 LLM call }, { // Places API queries for map metadata model: "google-maps-places", modelProvider: "google", quantity: placesData.data.results.length, // e.g., 3 places resolved }, ], }); return reportText; } ``` ONE event shows up in your dashboard. Cost rolls up across all three services: Google Search (mapped to your `service_pricing` if you've added it, `null` until then), Gemini tokens (calculated from the catalog), and Google Maps Places (same as Search). The "Cost by service" chart on the Cost tab shows where the total split. > **One outcome, one event.** The most common mistake here is firing three `mf.usage.record` calls (one per service). That used to be the workaround before multi-service shipped: it triplicated the report on your dashboard, made margin math harder, and could have multi-counted the customer's invoice when all three events shared a signal. With `services[]`, you fire one event per business outcome regardless of how many underlying services contributed. ### When to use `services[]` vs single-service shape Use the **single-service shape** (top-level `model` + `modelProvider`) when one event uses one underlying service. This is the 90% case for chatbots: an agent answers one question with GPT-4o, sends one SMS, transcribes one audio file. ```typescript theme={null} // Single-service: one event, one service await mf.usage.record({ customerExternalId: customerId, agentCode: "cs-bot-v2", signalName: "messages", model: "gpt-4o", modelProvider: "openai", inputTokens: 523, outputTokens: 117, }); ``` Use **`services[]`** when one event is backed by multiple underlying services contributing to the same business outcome (the place-report example above is three services; cold-outreach pipelines are often four or more). The two shapes are mutually exclusive: send model + modelProvider OR send services\[], never both, never neither. If you mix shapes, MarginFront rejects the request with a clear error pointing at the fix. ### LLM services can carry tokens AND quantity together The Gemini entry in the example above sets `inputTokens`, `outputTokens`, AND `quantity: 1` simultaneously. That's intentional. LLM `services[]` entries can carry all three, which lets you: * Track the call count alongside token totals (e.g., when retries or chain-of-thought intermediate prompts cause one outcome to trigger multiple LLM calls). * Layer per-call pricing on top of per-token pricing once your `service_pricing` catalog supports it (audit in progress). * Compare "calls per outcome" against "tokens per outcome" in your own reporting. If you don't care about call counts, omit `quantity` on LLM entries. Cost calculation is tokens-only for LLM services today; per-call pricing is on the roadmap. ### Multiple calls of the same model in one event If your agent makes two Gemini calls for one report (a draft pass + a refinement pass that both contribute to the same outcome), you have two equally valid ways to record it: **Option A: list each call as its own `services[]` entry.** Each becomes a distinct cost line. Use this when you want per-call resolution. ```typescript theme={null} services: [ { model: "gemini-2.5-pro", modelProvider: "google", inputTokens: 4500, outputTokens: 800, quantity: 1, }, { model: "gemini-2.5-pro", modelProvider: "google", inputTokens: 1200, outputTokens: 400, quantity: 1, }, ]; ``` **Option B: aggregate into one entry with summed tokens and `quantity` = call count.** Cleaner if you don't need per-call resolution. ```typescript theme={null} services: [ { model: "gemini-2.5-pro", modelProvider: "google", inputTokens: 5700, // 4500 + 1200 outputTokens: 1200, // 800 + 400 quantity: 2, // 2 LLM calls aggregated }, ]; ``` Both produce the same rolled-up parent cost. *** ## What you pick vs what MarginFront calculates For non-LLM events, here's what's yours to define and what MarginFront handles: | Field | You set it | MarginFront calculates it | | --------------- | --------------------------------------- | ----------------------------------------------------------------- | | `model` | Yes -- pick a descriptive label | No | | `modelProvider` | Yes -- the service name or `'internal'` | No | | `quantity` | Yes -- how many units of work | No | | `inputTokens` | Optional (leave out for non-LLM) | No | | `outputTokens` | Optional (leave out for non-LLM) | No | | Service cost | No | Yes, if model+provider is in the pricing table. Otherwise `null`. | | Revenue | No | Yes, from pricing plan: `quantity x price_per_unit` | *** ## Including tokens AND quantity Some events straddle both worlds. For example, an image generation call uses tokens (for the prompt) but also produces a quantity (number of images): ```typescript theme={null} await mf.usage.record({ customerExternalId: customerId, agentCode: "creative-bot", signalName: "images-generated", model: "dall-e-3", modelProvider: "openai", quantity: 4, // four images generated inputTokens: 85, // tokens used in the prompt (optional) // no outputTokens for image generation }); ``` All three fields (`quantity`, `inputTokens`, `outputTokens`) are always optional. Use whichever ones are relevant to the work that happened. *** ## Cost tracking for non-LLM tools MarginFront's built-in catalog covers 300+ LLM models plus a curated set of non-LLM services (Twilio SMS and voice, Google Maps, SendGrid, Hunter, Exa, and more). If your non-LLM tool isn't in the catalog yet, the model+provider won't match anything. That's fine: * The event saves with `cost = null` (never dropped, never zero). * Revenue is still calculated from your pricing plan (`quantity x price_per_unit`). * If you want MarginFront to calculate the actual cost of a non-LLM tool (e.g., the SMS provider you use), open the **Needs Attention** flow on the dashboard and map your model name to its catalog entry. Cost-side mapping is optional. Many users only care about the revenue side for non-LLM tools. *** ## Fire-and-forget still applies Just like with LLM events, the SDK's default fire-and-forget mode means: * If MarginFront is down, events retry automatically from a local buffer. * If there's a validation error, the SDK logs a warning and moves on. * Your agent never stalls waiting for MarginFront. This is especially important for non-LLM tools where the work is already done (SMS already sent, PDF already generated) -- there's no point blocking on the tracking call. *** ## Reading back what you earned You sent events. Now you want numbers: revenue, cost, margin, MRR. Three canonical paths, all point to the same data. ### Via SDK (inside your app) ```typescript theme={null} import { MarginFrontClient } from "@marginfront/sdk"; const mf = new MarginFrontClient(process.env.MF_API_SECRET_KEY!); // Last 30 days of revenue + cost + margin for one customer const end = new Date(); const start = new Date(end.getTime() - 30 * 24 * 60 * 60 * 1000); const revenue = await mf.analytics.revenue({ startDate: start.toISOString(), endDate: end.toISOString(), customerId: "cus_123", }); const cost = await mf.analytics.costBreakdown({ startDate: start.toISOString(), endDate: end.toISOString(), customerId: "cus_123", }); const mrr = await mf.analytics.mrr({ customerId: "cus_123" }); ``` ### Via REST (from any language or `curl`) ```bash Bash theme={null} curl "https://api.marginfront.com/v1/analytics/revenue?customerId=cus_123&startDate=2026-03-25&endDate=2026-04-24" \ -H "x-api-key: $MF_API_SECRET_KEY" curl "https://api.marginfront.com/v1/analytics/cost?customerId=cus_123&startDate=2026-03-25&endDate=2026-04-24" \ -H "x-api-key: $MF_API_SECRET_KEY" curl "https://api.marginfront.com/v1/analytics/mrr?customerId=cus_123" \ -H "x-api-key: $MF_API_SECRET_KEY" ``` ```powershell PowerShell theme={null} curl.exe "https://api.marginfront.com/v1/analytics/revenue?customerId=cus_123&startDate=2026-03-25&endDate=2026-04-24" ` -H "x-api-key: $env:MF_API_SECRET_KEY" curl.exe "https://api.marginfront.com/v1/analytics/cost?customerId=cus_123&startDate=2026-03-25&endDate=2026-04-24" ` -H "x-api-key: $env:MF_API_SECRET_KEY" curl.exe "https://api.marginfront.com/v1/analytics/mrr?customerId=cus_123" ` -H "x-api-key: $env:MF_API_SECRET_KEY" ``` ### Via MCP (from an AI agent like Claude, Cursor, or Copilot) If you've connected MarginFront's MCP server to your AI coding assistant, the agent can call canonical analytics tools directly: ``` Tool: get_customer_revenue Args: { customerExternalId: "acme-001", startDate: "2026-03-25", endDate: "2026-04-24" } Tool: get_cost_metrics Args: { startDate: "2026-03-25", endDate: "2026-04-24", customerExternalId: "acme-001" } Tool: get_mrr Args: { customerExternalId: "acme-001" } ``` All three paths return the same canonical numbers. Pick whichever fits your workflow. # Integrate with OpenAI Source: https://docs.marginfront.com/recipes/openai Add MarginFront usage tracking to your OpenAI-powered agent # Integrate MarginFront with OpenAI This recipe shows how to add MarginFront usage tracking to an Express.js app that calls OpenAI. By the end, every OpenAI call your agent makes will automatically show up in your MarginFront dashboard with cost and usage data. **What this does for you:** You call OpenAI. MarginFront records what model was used, how many tokens were consumed, and which customer it was for. You see the cost on your dashboard. If you have a pricing plan set up, your customer gets billed automatically. *** ## Prerequisites 1. A MarginFront API key (`mf_sk_...`). Get one from **Build > API keys** in the dashboard. 2. An OpenAI API key. > **You don't need to create the agent or signal first.** When you fire your first event with a new `agentCode` or `signalName`, MarginFront creates them automatically. The same goes for `customerExternalId`. You can rename and enrich any of them in the dashboard later. ```bash theme={null} npm install @marginfront/sdk openai express ``` *** ## Complete working example This is a full Express.js endpoint you can copy-paste and run. It receives a question from a customer, asks OpenAI for an answer, sends the answer back, and then tells MarginFront what happened. ```typescript theme={null} import express from "express"; import OpenAI from "openai"; import { MarginFrontClient } from "@marginfront/sdk"; const app = express(); app.use(express.json()); // Set up clients once at startup (not per-request) const openai = new OpenAI(); // reads OPENAI_API_KEY from environment const mf = new MarginFrontClient(process.env.MF_API_SECRET_KEY!); app.post("/chat", async (req, res) => { const { customerId, message } = req.body; // --- Step 1: Call OpenAI --- const response = await openai.chat.completions.create({ model: "gpt-4o", messages: [{ role: "user", content: message }], }); // --- Step 2: Send the answer to your customer --- const answer = response.choices[0].message.content; res.json({ answer }); // --- Step 3: Track the usage in MarginFront (AFTER responding) --- // This runs after the response is sent. If MarginFront is unreachable, // the SDK queues the event locally and retries later. Your customer // never waits for MarginFront. await mf.usage.record({ customerExternalId: customerId, // who used it (your customer's ID) agentCode: "cs-bot", // which product did the work signalName: "messages", // what you're measuring model: response.model, // 'gpt-4o' -- straight from OpenAI's response modelProvider: "openai", // tells MarginFront which pricing table to check inputTokens: response.usage!.prompt_tokens, // from OpenAI's response outputTokens: response.usage!.completion_tokens, // from OpenAI's response }); }); app.listen(3000, () => { console.log("Server running on http://localhost:3000"); }); ``` *** ## Where the data comes from: field mapping Every value you pass to MarginFront comes directly from OpenAI's response object. Here's where each one lives: | What MarginFront needs | Where to get it from OpenAI | Example value | | ---------------------- | ---------------------------------- | ------------- | | `model` | `response.model` | `"gpt-4o"` | | `inputTokens` | `response.usage.prompt_tokens` | `523` | | `outputTokens` | `response.usage.completion_tokens` | `117` | The remaining fields (`customerExternalId`, `agentCode`, `signalName`, `modelProvider`) come from your own code, not from OpenAI. *** ## What happens if MarginFront is down? Nothing bad. The SDK runs in **fire-and-forget mode** by default: * **MarginFront unreachable?** The event goes into a local retry buffer. The SDK retries automatically in the background. Your customer never notices. * **MarginFront rejects the event?** (e.g., validation error) The SDK logs a warning to the console. The event is dropped -- but your customer still got their answer. * **Your server crashes before the event is sent?** That one event is lost. This is rare and acceptable for most use cases. If you need guaranteed delivery, set `fireAndForget: false` and handle errors yourself. The important thing: **MarginFront never blocks your agent.** Your customer always gets their answer, whether MarginFront is having a good day or a bad one. *** ## Error handling (if you want more control) The default fire-and-forget mode is fine for most apps. But if you want to know when tracking fails (for logging, alerting, etc.), turn it off: ```typescript theme={null} // Opt out of fire-and-forget mode const mf = new MarginFrontClient(process.env.MF_API_SECRET_KEY!, { fireAndForget: false, }); // Now you can catch errors try { await mf.usage.record({ customerExternalId: customerId, agentCode: "cs-bot", signalName: "messages", model: response.model, modelProvider: "openai", inputTokens: response.usage!.prompt_tokens, outputTokens: response.usage!.completion_tokens, }); } catch (error) { // Log it, alert on it, whatever you need. // But don't let it crash your request handler. console.error("MarginFront tracking failed:", error); } ``` *** ## Streaming responses If you use OpenAI's streaming mode (`stream: true`), you won't get token counts in the stream chunks. You need to wait for the stream to finish and read the `usage` field from the final response: ```typescript theme={null} const stream = await openai.chat.completions.create({ model: "gpt-4o", messages: [{ role: "user", content: message }], stream: true, stream_options: { include_usage: true }, // required to get token counts }); let fullContent = ""; let finalUsage: OpenAI.CompletionUsage | undefined; for await (const chunk of stream) { // Send each chunk to the customer as it arrives const text = chunk.choices[0]?.delta?.content || ""; fullContent += text; // The very last chunk has the usage data if (chunk.usage) { finalUsage = chunk.usage; } } // Now track the event with the final token counts if (finalUsage) { await mf.usage.record({ customerExternalId: customerId, agentCode: "cs-bot", signalName: "messages", model: "gpt-4o", modelProvider: "openai", inputTokens: finalUsage.prompt_tokens, outputTokens: finalUsage.completion_tokens, }); } ``` **Important:** You must pass `stream_options: { include_usage: true }` or OpenAI won't include token counts in the streamed response. *** ## Multiple models in one agent If your agent uses different models for different tasks (e.g., GPT-4o for complex questions, GPT-4o-mini for simple ones), just pass whichever model was actually used. MarginFront looks up the cost per model automatically: ```typescript theme={null} // The model might change based on the task const modelToUse = isSimpleQuestion ? "gpt-4o-mini" : "gpt-4o"; const response = await openai.chat.completions.create({ model: modelToUse, messages: [{ role: "user", content: message }], }); // Pass response.model -- MarginFront handles the cost lookup await mf.usage.record({ customerExternalId: customerId, agentCode: "cs-bot", signalName: "messages", model: response.model, // could be 'gpt-4o' or 'gpt-4o-mini' modelProvider: "openai", inputTokens: response.usage!.prompt_tokens, outputTokens: response.usage!.completion_tokens, }); ``` *** ## Auto-provisioning note If you log an event for a `customerExternalId` or `agentCode` that MarginFront hasn't seen before, it will auto-create a minimal customer or agent record. This is handy for prototyping but can mask typos -- if events show up under a customer name you don't recognize, double-check your IDs in the dashboard. *** ## Reading back what you earned You sent events. Now you want numbers: revenue, cost, margin, MRR. Three canonical paths, all point to the same data. ### Via SDK (inside your app) ```typescript theme={null} import { MarginFrontClient } from "@marginfront/sdk"; const mf = new MarginFrontClient(process.env.MF_API_SECRET_KEY!); // Last 30 days of revenue + cost + margin for one customer const end = new Date(); const start = new Date(end.getTime() - 30 * 24 * 60 * 60 * 1000); const revenue = await mf.analytics.revenue({ startDate: start.toISOString(), endDate: end.toISOString(), customerId: "cus_123", }); const cost = await mf.analytics.costBreakdown({ startDate: start.toISOString(), endDate: end.toISOString(), customerId: "cus_123", }); const mrr = await mf.analytics.mrr({ customerId: "cus_123" }); ``` ### Via REST (from any language or `curl`) ```bash Bash theme={null} curl "https://api.marginfront.com/v1/analytics/revenue?customerId=cus_123&startDate=2026-03-25&endDate=2026-04-24" \ -H "x-api-key: $MF_API_SECRET_KEY" curl "https://api.marginfront.com/v1/analytics/cost?customerId=cus_123&startDate=2026-03-25&endDate=2026-04-24" \ -H "x-api-key: $MF_API_SECRET_KEY" curl "https://api.marginfront.com/v1/analytics/mrr?customerId=cus_123" \ -H "x-api-key: $MF_API_SECRET_KEY" ``` ```powershell PowerShell theme={null} curl.exe "https://api.marginfront.com/v1/analytics/revenue?customerId=cus_123&startDate=2026-03-25&endDate=2026-04-24" ` -H "x-api-key: $env:MF_API_SECRET_KEY" curl.exe "https://api.marginfront.com/v1/analytics/cost?customerId=cus_123&startDate=2026-03-25&endDate=2026-04-24" ` -H "x-api-key: $env:MF_API_SECRET_KEY" curl.exe "https://api.marginfront.com/v1/analytics/mrr?customerId=cus_123" ` -H "x-api-key: $env:MF_API_SECRET_KEY" ``` ### Via MCP (from an AI agent like Claude, Cursor, or Copilot) If you've connected MarginFront's MCP server to your AI coding assistant, the agent can call canonical analytics tools directly: ``` Tool: get_customer_revenue Args: { customerExternalId: "acme-001", startDate: "2026-03-25", endDate: "2026-04-24" } Tool: get_cost_metrics Args: { startDate: "2026-03-25", endDate: "2026-04-24", customerExternalId: "acme-001" } Tool: get_mrr Args: { customerExternalId: "acme-001" } ``` All three paths return the same canonical numbers. Pick whichever fits your workflow. # Analytics (Revenue, Cost, MRR) Source: https://docs.marginfront.com/sdk/analytics Seven methods for reading revenue, cost, margin, billed, collected, and MRR numbers out of MarginFront # Analytics The analytics resource on the SDK is how you read finished numbers back out of MarginFront. Revenue, cost, margin, billed, collected, MRR. Same math the dashboard runs, same shapes. All seven methods return plain TypeScript objects with typed fields. Money fields are `number` (not strings). If a margin can't be calculated (because revenue is zero), `marginPercent` is `null`, never `0` or `NaN`. This page covers: 1. `analytics.revenue`: revenue, cost, margin, and the breakdown of where revenue came from 2. `analytics.costBreakdown`: cost sliced by agent, customer, signal, day, plan, and model 3. `analytics.agentEarned`: activity-only revenue, before any fees are added on 4. `analytics.invoiceTotals`: billed, collected, outstanding, overdue 5. `analytics.mrr`: last complete calendar month's billed total 6. `analytics.runRateMrr`: trajectory if the last 30 days keep going 7. `analytics.committedMrr`: contractual floor, regardless of usage For the shape of every response type, see [Types Reference](/sdk/types-reference). *** ## Setup ```typescript theme={null} import { MarginFrontClient } from "@marginfront/sdk"; const mf = new MarginFrontClient(process.env.MF_API_SECRET_KEY); ``` All seven methods are on `mf.analytics`. No separate import, no special setup. *** ## 1. `analytics.revenue` **What it does in plain English:** gives you the whole revenue picture for a time window. How much you earned, how much it cost you, your margin, and where the revenue came from (per subscription, per pricing strategy). This is the number you'd show on an executive dashboard. It includes usage-based revenue plus recurring fees, seat fees, and one-time fees (prorated if the subscription only partly overlapped the window). ### Signature ```typescript theme={null} await mf.analytics.revenue(params: RevenueMetricsParams): Promise ``` ### Parameters | Name | Type | Required | Description | | ---------------- | -------- | -------- | --------------------------------------------------- | | `startDate` | `string` | yes | ISO date, inclusive (e.g., `"2026-04-01"`). | | `endDate` | `string` | yes | ISO date, inclusive. | | `customerId` | `string` | no | Filter to one customer. UUID, not your external ID. | | `agentId` | `string` | no | Filter to one agent. UUID. | | `signalId` | `string` | no | Filter to one signal. UUID. | | `subscriptionId` | `string` | no | Filter to one subscription. UUID. | Any combination of filters can be mixed. No filter means org-wide. ### Response shape ```typescript theme={null} interface RevenueMetrics { revenue: number; cost: number; margin: number; marginPercent: number | null; usageRevenue: number; recurringRevenue: number; seatRevenue: number; onetimeRevenue: number; eventCount: number; eventCountWithNullCost: number; bySubscription: SubscriptionRevenue[]; byStrategy: StrategyRevenue[]; } ``` ### Example ```typescript theme={null} // Org-wide revenue for April const metrics = await mf.analytics.revenue({ startDate: "2026-04-01", endDate: "2026-04-30", }); console.log(`Revenue: $${metrics.revenue.toFixed(2)}`); console.log(`Cost: $${metrics.cost.toFixed(2)}`); console.log(`Margin: $${metrics.margin.toFixed(2)}`); if (metrics.marginPercent !== null) { console.log(`Margin %: ${metrics.marginPercent.toFixed(1)}%`); } else { console.log(`Margin %: not available (no revenue yet)`); } // Revenue for a single customer const customerMetrics = await mf.analytics.revenue({ startDate: "2026-04-01", endDate: "2026-04-30", customerId: "c4e8d1a2-1234-4567-8901-abcdef123456", }); ``` ### What this returns, in plain English * **`revenue`** total dollars earned in the window. Usage plus recurring plus seat plus one-time fees. * **`cost`** total service cost (the bill from OpenAI, Anthropic, Twilio, etc.) for the events in the window. * **`margin`** revenue minus cost. * **`marginPercent`** the margin as a percentage. Comes back as `null` (shown as a dash in dashboards) when revenue is zero, so you never see a `0%` or `NaN%` that would be misleading. * **`usageRevenue` / `recurringRevenue` / `seatRevenue` / `onetimeRevenue`** the four ways revenue can show up, split out. They add up to `revenue`. * **`eventCount`** every event counted in the window. * **`eventCountWithNullCost`** events that are in the count but whose cost couldn't be calculated yet (unknown model, no pricing map). A "needs attention" number. See [Tracking Events: NEEDS\_COST\_BACKFILL](/sdk/tracking-events#needs_cost_backfill--what-it-means). * **`bySubscription`** one row per active subscription in the window, each with its own revenue/cost/margin breakdown. * **`byStrategy`** one row per pricing strategy that contributed revenue, with the strategy's charge type, pricing model, and signal id. *** ## 2. `analytics.costBreakdown` **What it does in plain English:** shows you where your money is going. Total cost in the window, sliced six different ways: by agent, by customer, by signal, by day, by pricing plan, and by LLM model. Optional period-over-period comparison. This is the number behind a "cost explorer" dashboard. Find the expensive customer. Find the expensive model. Find the day your bill spiked. ### Signature ```typescript theme={null} await mf.analytics.costBreakdown(params: CostMetricsParams): Promise ``` ### Parameters | Name | Type | Required | Description | | -------------------- | --------- | -------- | -------------------------------------------------------------------------- | | `startDate` | `string` | yes | ISO date. | | `endDate` | `string` | yes | ISO date. | | `customerId` | `string` | no | Filter to one customer. | | `agentId` | `string` | no | Filter to one agent. | | `signalId` | `string` | no | Filter to one signal. | | `subscriptionId` | `string` | no | Filter to one subscription. | | `includePriorWindow` | `boolean` | no | When `true`, adds a `prior` field with same-length-previous-window totals. | ### Response shape ```typescript theme={null} interface CostMetrics { cost: number; eventCount: number; eventCountWithNullCost: number; byAgent: CostByAgentRow[]; byCustomer: CostByCustomerRow[]; bySignal: CostBySignalRow[]; byDay: CostByDayRow[]; byPlan: CostByPlanRow[]; byModel: CostByModelRow[]; prior?: Omit; } ``` Each breakdown row has the shape `{ : string | null, cost: number, eventCount: number }`. See [Types Reference](/sdk/types-reference) for the exact row types. ### Example ```typescript theme={null} // Cost for April, with March comparison included const cost = await mf.analytics.costBreakdown({ startDate: "2026-04-01", endDate: "2026-04-30", includePriorWindow: true, }); console.log(`Total cost: $${cost.cost.toFixed(2)}`); console.log(`Events: ${cost.eventCount}`); if (cost.eventCountWithNullCost > 0) { console.log( `Attention: ${cost.eventCountWithNullCost} events have no cost yet. ` + `Map their models in the dashboard.`, ); } // Top 3 most expensive models const topModels = cost.byModel.sort((a, b) => b.cost - a.cost).slice(0, 3); for (const row of topModels) { console.log(` ${row.model ?? "(unmapped)"}: $${row.cost.toFixed(2)}`); } // Trend vs prior window if (cost.prior) { const delta = cost.cost - cost.prior.cost; const sign = delta >= 0 ? "+" : ""; console.log(`Change vs prior window: ${sign}$${delta.toFixed(2)}`); } ``` ### What this returns, in plain English * **`cost`** total service cost in the window. * **`eventCount`** every event in the window. * **`eventCountWithNullCost`** events whose cost couldn't be calculated. Counted, not dropped. Shows up as a "needs attention" indicator. * **`byAgent` / `byCustomer` / `bySignal` / `byPlan` / `byModel`** cost broken out along each dimension. The id fields can be `null` when an event isn't yet attached to that dimension (e.g., an orphan event with no pricing plan). * **`byDay`** cost per UTC calendar day, one row per day with activity. * **`prior`** only present when you passed `includePriorWindow: true`. Same shape as the outer object, but for the same-length window immediately before `startDate`. Lets you show trend arrows without a second call. *** ## 3. `analytics.agentEarned` **What it does in plain English:** tells you how much revenue your agents produced from actual activity, before any fixed fees are added on top. Usage events multiplied by their pricing strategy rates, summed up. This is the earliest signal that something is working. It moves the moment an event fires, without waiting for an invoice to finalize. If you want to answer "are my agents doing billable work this week," this is the number. ### Signature ```typescript theme={null} await mf.analytics.agentEarned(params: AgentEarnedParams): Promise ``` ### Parameters | Name | Type | Required | Description | | ---------------- | -------- | -------- | --------------------------- | | `startDate` | `string` | yes | ISO date. | | `endDate` | `string` | yes | ISO date. | | `customerId` | `string` | no | Filter to one customer. | | `agentId` | `string` | no | Filter to one agent. | | `signalId` | `string` | no | Filter to one signal. | | `subscriptionId` | `string` | no | Filter to one subscription. | ### Response shape ```typescript theme={null} interface AgentEarned { revenue: number; eventCount: number; bySubscription: AgentEarnedSubscriptionRow[]; byStrategy: AgentEarnedStrategyRow[]; } ``` ### Example ```typescript theme={null} // How much did my agents earn from activity this week? const earned = await mf.analytics.agentEarned({ startDate: "2026-04-20", endDate: "2026-04-26", }); console.log(`Activity revenue: $${earned.revenue.toFixed(2)}`); console.log(`From ${earned.eventCount} events`); // Per-agent earnings for (const row of earned.bySubscription) { console.log( ` Sub ${row.subscriptionId}: $${row.revenue.toFixed(2)} (${row.eventCount} events)`, ); } ``` ### What this returns, in plain English * **`revenue`** activity-only revenue in the window. Usage events × their pricing strategy rate. No recurring fees, no seat fees, no one-time fees. * **`eventCount`** events that produced revenue (events tied to a usage pricing strategy). * **`bySubscription`** per-subscription breakdown: how much revenue each sub produced and how many events did it. * **`byStrategy`** per-pricing-strategy breakdown: which specific rate earned what, and the total quantity that ran through it. ### When to use this vs `analytics.revenue` Use `agentEarned` when you care about activity in near-real-time. Use `revenue` when you want the full picture including fixed fees and prorations. They'll give you different numbers for the same window, on purpose. *** ## 4. `analytics.invoiceTotals` **What it does in plain English:** tells you what you actually invoiced and what your customers actually paid. Billed, collected, draft inventory, outstanding A/R, and a live overdue count. This is the finance-team view. It reads realized amounts off of invoices, not a formula. Non-draft invoices reconcile to the revenue formula, so you can trust these numbers directly. ### Signature ```typescript theme={null} await mf.analytics.invoiceTotals(params: InvoiceTotalsParams): Promise ``` ### Parameters | Name | Type | Required | Description | | ------------ | -------- | -------- | ---------------------------------- | | `startDate` | `string` | yes | ISO date. | | `endDate` | `string` | yes | ISO date. | | `customerId` | `string` | no | Filter to one customer's invoices. | ### Response shape ```typescript theme={null} interface InvoiceTotals { billed: number; collected: number; draft: number; outstanding: number; overdueAmount: number; overdueCount: number; invoicesSentCount: number; } ``` ### Example ```typescript theme={null} const totals = await mf.analytics.invoiceTotals({ startDate: "2026-04-01", endDate: "2026-04-30", }); console.log(`Billed: $${totals.billed.toFixed(2)}`); console.log(`Collected: $${totals.collected.toFixed(2)}`); console.log(`Outstanding: $${totals.outstanding.toFixed(2)}`); if (totals.overdueCount > 0) { console.log( `Overdue: ${totals.overdueCount} invoices, $${totals.overdueAmount.toFixed(2)} total`, ); } ``` ### What this returns, in plain English * **`billed`** total on invoices you issued in the window (status `issued` or `overdue`), counted by invoice date. Waiting on payment. * **`collected`** cash that actually landed in the window: payments that went through with a payment date in the window, net of refunds. Counted by the day the money arrived, not the invoice date. * **`draft`** total on invoices you generated but haven't sent yet. Inventory, not revenue. * **`outstanding`** what's still open right now: invoices that are `issued` or `overdue` and not yet paid. A current-state number with no date window, always zero or more. * **`overdueAmount`** total on invoices currently past due. Age-based, not window-based. Current state right now. * **`overdueCount`** how many invoices are currently past due. * **`invoicesSentCount`** how many non-draft invoices were dated in the window. *** ## 5. `analytics.mrr` **What it does in plain English:** tells you how much you actually billed last complete calendar month. Monthly recurring revenue, based on invoices that went out. This is the "book MRR" number. Stable across the month (doesn't change until the month rolls over), based on realized billing. If you want forward-looking MRR, use `runRateMrr` or `committedMrr` below. ### Signature ```typescript theme={null} await mf.analytics.mrr(params?: MrrParams): Promise ``` ### Parameters All optional. | Name | Type | Required | Description | | ---------------- | -------- | -------- | --------------------------- | | `customerId` | `string` | no | Filter to one customer. | | `subscriptionId` | `string` | no | Filter to one subscription. | No date parameters. The window is fixed: first of last calendar month to first of this month. ### Response shape ```typescript theme={null} interface Mrr { mrr: number; arr: number; } ``` ### Example ```typescript theme={null} const { mrr, arr } = await mf.analytics.mrr(); console.log(`MRR: $${mrr.toFixed(2)}`); console.log(`ARR: $${arr.toFixed(2)}`); // Per-customer MRR const customerMrr = await mf.analytics.mrr({ customerId: "c4e8d1a2-1234-4567-8901-abcdef123456", }); ``` ### What this returns, in plain English * **`mrr`** total billed across all invoices dated in the previous calendar month. * **`arr`** annualized: `mrr × 12`. A convenience so you don't multiply in the UI. *** ## 6. `analytics.runRateMrr` **What it does in plain English:** tells you what your MRR would be if the last 30 days of activity kept going at the same pace. Recurring fees, seat fees, and actual usage rolled forward. This is the "current run rate" number. Useful when usage moves fast and last month's MRR is already stale. ### Signature ```typescript theme={null} await mf.analytics.runRateMrr(params?: MrrParams): Promise ``` ### Parameters Same as `analytics.mrr`: optional `customerId` and `subscriptionId`. No date window. ### Response shape ```typescript theme={null} interface RunRateMrrResult { mrr: number; breakdown: RunRateMrrBreakdownRow[]; } interface RunRateMrrBreakdownRow { subscriptionId: string; customerId: string; agentId: string; planId: string; recurring: number; seatBased: number; usage: number; total: number; } ``` ### Example ```typescript theme={null} const { mrr, breakdown } = await mf.analytics.runRateMrr(); console.log(`Run-rate MRR: $${mrr.toFixed(2)}`); // Top 3 contributing subscriptions const top = breakdown.sort((a, b) => b.total - a.total).slice(0, 3); for (const row of top) { console.log( ` Sub ${row.subscriptionId}: $${row.total.toFixed(2)} ` + `(recurring $${row.recurring.toFixed(2)}, ` + `seats $${row.seatBased.toFixed(2)}, ` + `usage $${row.usage.toFixed(2)})`, ); } ``` ### What this returns, in plain English * **`mrr`** projected monthly revenue if the last 30 days keep up this pace. * **`breakdown`** one row per active subscription with its contribution split into recurring, seat, and usage parts. The three add up to `total`, and all the `total`s add up to `mrr`. *** ## 7. `analytics.committedMrr` **What it does in plain English:** the floor. Tells you the monthly revenue your contracts guarantee, regardless of whether customers actually use the product. Recurring fees, seat fees, and usage minimums only. Use this to answer "what's the least we'll bill next month?" By definition, committed MRR is always less than or equal to run-rate MRR (because run-rate counts actual usage, committed only counts minimums). ### Signature ```typescript theme={null} await mf.analytics.committedMrr(params?: MrrParams): Promise ``` ### Parameters Same as `analytics.mrr` and `analytics.runRateMrr`: optional `customerId` and `subscriptionId`. ### Response shape ```typescript theme={null} interface CommittedMrrResult { mrr: number; breakdown: CommittedMrrBreakdownRow[]; } interface CommittedMrrBreakdownRow { subscriptionId: string; customerId: string; agentId: string; planId: string; recurring: number; seatBased: number; usage: number; // minimum commitment × rate, not actual usage total: number; } ``` ### Example ```typescript theme={null} const { mrr: committed } = await mf.analytics.committedMrr(); const { mrr: runRate } = await mf.analytics.runRateMrr(); console.log(`Committed MRR (floor): $${committed.toFixed(2)}`); console.log(`Run-rate MRR (actual pace): $${runRate.toFixed(2)}`); const gap = runRate - committed; console.log(`Usage upside: $${gap.toFixed(2)}`); ``` ### What this returns, in plain English * **`mrr`** the monthly revenue your contracts guarantee. Recurring fees always apply. Seat fees use the booked seat count. Usage uses the minimum commitment on each pricing strategy (zero when there's no minimum set). * **`breakdown`** same shape as `runRateMrr`'s breakdown, but the `usage` column uses the minimum-commitment floor instead of actual events. *** ## Field types at a glance All dollar amounts are `number`, not `string`. Values come back at full precision. Round at render time, not in storage: ```typescript theme={null} const metrics = await mf.analytics.revenue({ ... }); // Right: one round at the UI boundary display(metrics.revenue.toFixed(2)); // Wrong: stored rounded numbers lose precision for downstream math store(Number(metrics.revenue.toFixed(2))); ``` `marginPercent` is `number | null`. A `null` means "no revenue yet, so margin can't be computed." Render it as a dash, not as `0%`. See [Types Reference](/sdk/types-reference) for the full interface definitions. *** ## When to call which method | Question | Method | | ---------------------------------------------------- | ------------------------- | | What did we earn this month? | `analytics.revenue` | | Where is our money going? | `analytics.costBreakdown` | | How much activity in the last 24 hours? | `analytics.agentEarned` | | What's in A/R? | `analytics.invoiceTotals` | | What was last month's MRR (stable reporting number)? | `analytics.mrr` | | What's our current run rate? | `analytics.runRateMrr` | | What's our contractual floor? | `analytics.committedMrr` | *** ## Next steps * **[Types Reference](/sdk/types-reference)**: every interface in one place * **[Subscriptions with Revenue](/sdk/subscriptions)**: attach canonical revenue to a single subscription fetch * **[Customers with Revenue](/sdk/customers)**: same, but for customers * **[Tracking Events](/sdk/tracking-events)**: the write side. Send events in so these methods have something to read # Customers Source: https://docs.marginfront.com/sdk/customers Create, update, list, and fetch customers. Plus getWithRevenue for detail pages. # Customers The customers resource handles the usual create, read, update, delete, plus one method built for customer detail pages: `getWithRevenue`. It fetches the customer record AND their revenue numbers for a time window in one round trip. This page covers: 1. Creating a customer 2. Listing customers 3. Getting one customer 4. Updating a customer 5. Deleting a customer 6. Getting a customer with their revenue numbers attached *** ## Setup ```typescript theme={null} import { MarginFrontClient } from "@marginfront/sdk"; const mf = new MarginFrontClient(process.env.MF_API_SECRET_KEY); ``` *** ## `customers.create` Creates a new customer. If you pass `agentCode`, MarginFront also auto-creates a subscription between the customer and that agent. ### Signature ```typescript theme={null} await mf.customers.create(params: CreateCustomerData): Promise ``` ### Example ```typescript theme={null} const customer = await mf.customers.create({ name: "Acme Corp", externalId: "acme-001", email: "billing@acme.com", agentCode: "cs-bot-v2", // optional: auto-creates a subscription }); ``` See the [Customers API reference](/api-reference/customers) for the full field list. *** ## `customers.list` Returns a paginated list of customers with optional filters. ### Signature ```typescript theme={null} await mf.customers.list(params?: CustomerListParams): Promise ``` ### Example ```typescript theme={null} const { data, totalResults, hasMore } = await mf.customers.list({ limit: 20, page: 1, }); for (const customer of data) { console.log(`${customer.name} (${customer.externalId})`); } ``` *** ## `customers.get` Fetches one customer by id, including their subscriptions. ### Signature ```typescript theme={null} await mf.customers.get(id: string): Promise ``` ### Example ```typescript theme={null} const customer = await mf.customers.get("c4e8d1a2-1234-4567-8901-abcdef123456"); console.log(customer.name); console.log(`Subscriptions: ${customer.subscriptions?.length ?? 0}`); ``` *** ## `customers.update` Updates an existing customer. Pass only the fields you want to change. ### Signature ```typescript theme={null} await mf.customers.update(id: string, params: UpdateCustomerData): Promise ``` ### Example ```typescript theme={null} const updated = await mf.customers.update( "c4e8d1a2-1234-4567-8901-abcdef123456", { email: "new-billing@acme.com", netTerms: 45, }, ); ``` *** ## `customers.delete` Deletes a customer. ### Signature ```typescript theme={null} await mf.customers.delete(id: string): Promise ``` ### Example ```typescript theme={null} await mf.customers.delete("c4e8d1a2-1234-4567-8901-abcdef123456"); ``` *** ## `customers.getWithRevenue` **What it does in plain English:** fetches a customer AND their revenue numbers for a time window, in one call. Lets you render a customer detail page (profile, subscriptions, revenue, cost, margin) without juggling two round trips. Behind the scenes the SDK fires both requests in parallel, so the wall-clock time is roughly the time of one call. ### Signature ```typescript theme={null} await mf.customers.getWithRevenue( id: string, window?: CustomerRevenueWindow, ): Promise ``` ### Parameters | Name | Type | Required | Description | | ------------------ | -------- | -------- | ---------------------------------------------- | | `id` | `string` | yes | The customer id (MarginFront's internal UUID). | | `window.startDate` | `string` | no | ISO date. Defaults to 30 days ago if omitted. | | `window.endDate` | `string` | no | ISO date. Defaults to today if omitted. | Pass both window fields together or neither. Omitting both defaults to the last 30 days UTC. ### Response shape ```typescript theme={null} interface CustomerDetailWithRevenue { customer: Customer; revenue: RevenueMetrics; } ``` The `customer` field is the same shape as `customers.get` returns. The `revenue` field is the same canonical shape `analytics.revenue` returns, scoped to this one customer. See [Types Reference](/sdk/types-reference) for the complete interfaces. ### Example ```typescript theme={null} // Default window (last 30 days) const { customer, revenue } = await mf.customers.getWithRevenue( "c4e8d1a2-1234-4567-8901-abcdef123456", ); console.log(`${customer.name}`); console.log(`External id: ${customer.externalId ?? "(none)"}`); console.log(`Status: ${customer.status}`); console.log(`Revenue: $${revenue.revenue.toFixed(2)}`); console.log(`Cost: $${revenue.cost.toFixed(2)}`); console.log(`Margin: $${revenue.margin.toFixed(2)}`); if (revenue.marginPercent !== null) { console.log(`Margin %: ${revenue.marginPercent.toFixed(1)}%`); } else { console.log(`Margin %: not available (no revenue in window)`); } console.log(`Events: ${revenue.eventCount}`); // Which subscriptions contributed the most revenue const topSubs = revenue.bySubscription .sort((a, b) => b.revenue - a.revenue) .slice(0, 3); for (const row of topSubs) { console.log( ` Sub ${row.subscriptionId}: $${row.revenue.toFixed(2)} revenue`, ); } // Explicit window const april = await mf.customers.getWithRevenue( "c4e8d1a2-1234-4567-8901-abcdef123456", { startDate: "2026-04-01", endDate: "2026-04-30", }, ); ``` ### What this returns, in plain English * **`customer`** the usual customer record. Profile, contact info, status, subscriptions list (if populated), billing settings. * **`revenue`** a full canonical revenue block for this one customer, in the window you asked for. Revenue, cost, margin, marginPercent, plus the usage/recurring/seat/onetime breakdown, a per-subscription row list, and a per-pricing-strategy row list. For the full shape of `RevenueMetrics`, see [Types Reference](/sdk/types-reference#revenuemetrics). ### When to use this Customer detail pages. Account manager dashboards. Churn-risk reviews. Anywhere you need the customer record AND how much they're contributing in one view. For just the customer record, use `customers.get`. For revenue across many customers, use `analytics.revenue` with no filter (and read `bySubscription` for per-customer rollups). *** ## Next steps * **[Analytics](/sdk/analytics)**: all seven canonical analytics methods * **[Types Reference](/sdk/types-reference)**: full interface definitions * **[Subscriptions with Revenue](/sdk/subscriptions)**: the same pattern, but scoped to a subscription # Portal Sessions Source: https://docs.marginfront.com/sdk/portal-sessions Mint, look up, and revoke customer portal links from the Node SDK. # Portal Sessions The SDK gives you four methods for managing customer portal links. These are the one-time URLs your customers click to see their own billing. This page covers: 1. Creating a portal session 2. Getting one portal session 3. Listing portal sessions 4. Revoking a portal session If you're new to portal sessions, start with the [Portal Sessions API reference](/api-reference/portal-sessions) for a plain-English explanation of what they are and why you'd use them. *** ## Setup ```typescript theme={null} import { MarginFrontClient } from "@marginfront/sdk"; const mf = new MarginFrontClient(process.env.MF_API_SECRET_KEY); ``` All four portal-sessions methods require a **secret** key (`mf_sk_...`). The SDK throws an `AuthenticationError` immediately if you initialize the client with a publishable key. *** ## `portalSessions.create` Mints a one-time portal link for a customer. The returned `url` is what you send to your customer. ### Signature ```typescript theme={null} await mf.portalSessions.create(params: CreatePortalSessionParams): Promise ``` ### Example ```typescript theme={null} const session = await mf.portalSessions.create({ customerExternalId: "acme-001", returnUrl: "https://your-app.com/billing", }); console.log(session.url); // → https://portal.marginfront.com/r/mgl_a1b2c3... // Send this URL to your customer (email, SMS, button in your app, etc.). ``` Pass either `customerExternalId` (your system's ID) or `customerId` (MarginFront's UUID). `returnUrl` is optional and informational. See the [Portal Sessions API reference](/api-reference/portal-sessions) for the full field list and error codes. *** ## `portalSessions.get` Looks up one portal session by ID. Useful for checking whether a link has been opened or has expired. ### Signature ```typescript theme={null} await mf.portalSessions.get(sessionId: string): Promise ``` ### Example ```typescript theme={null} const session = await mf.portalSessions.get("ps_a1b2c3d4..."); console.log(session.expiresAt); ``` The `token` and `url` fields are NOT included in this response. Those are only returned by `create`. The return type is `ListedPortalSession`, the same shape returned by `list`. If you need the URL again, mint a new session with `create`. *** ## `portalSessions.list` Lists portal sessions your organization has created. Optionally filter to one customer. ### Signature ```typescript theme={null} await mf.portalSessions.list(params?: ListPortalSessionsParams): Promise ``` ### Example ```typescript theme={null} // All recent sessions const sessions = await mf.portalSessions.list(); // Just one customer's sessions, including expired ones const customerSessions = await mf.portalSessions.list({ customerId: "550e8400-e29b-41d4-a716-446655440000", includeExpired: true, }); customerSessions.forEach((s) => console.log(s.id, s.isUsed, s.expiresAt)); ``` By default, expired and already-used sessions are filtered out. Set `includeExpired: true` to see them all. *** ## `portalSessions.revoke` Immediately invalidates a portal session. Use this if you sent a link to the wrong customer or need to cut access early. ### Signature ```typescript theme={null} await mf.portalSessions.revoke(sessionId: string): Promise ``` ### Example ```typescript theme={null} await mf.portalSessions.revoke("ps_a1b2c3d4..."); ``` Once revoked, the link stops working immediately. This is a hard delete. The session can't be brought back. # Spend Controls Source: https://docs.marginfront.com/sdk/spend-controls Read and manage your team's AI coding spend caps from the Node SDK. # Spend Controls The SDK gives you six methods for working with **spend caps**: the limits your company sets on its own AI coding spend. These are the same caps the dashboard's Internal AI Spend page shows, readable and writable from code. This page covers: 1. Listing caps 2. Reading spend against the caps 3. Reading coverage ("how many developers are protected?") 4. Creating a cap 5. Updating a cap 6. Deleting a cap If you're new to spend caps, start with the [Spend Controls API reference](/api-reference/spend-controls) for a plain-English explanation of scopes, modes, and how enforcement works. *** ## Setup ```typescript theme={null} import { MarginFrontClient } from "@marginfront/sdk"; const mf = new MarginFrontClient(process.env.MF_API_SECRET_KEY); ``` The three read methods work with any secret key for your organization. The three write methods (`create`, `update`, `delete`) need a key that belongs to an **owner or finance** user. A key without that role gets the server's own plain-English `403`, surfaced as-is. *** ## `spendControls.list` Lists every cap policy for your organization. Each cap carries a `sentence` field: the cap written out as one plain sentence, built by the server. ### Signature ```typescript theme={null} await mf.spendControls.list(): Promise ``` ### Example ```typescript theme={null} const caps = await mf.spendControls.list(); caps.forEach((c) => console.log(c.sentence)); // → "Stop AI spend at $5,000 across the whole team per month" ``` *** ## `spendControls.spend` Returns your team's internal coding-agent spend for the current `day`, `week`, or `month` window (computed in UTC on the server). Pass a developer's `customerExternalId` (their email) to also get that developer's number. ### Signature ```typescript theme={null} await mf.spendControls.spend( period: "day" | "week" | "month", customerExternalId?: string, ): Promise ``` ### Example ```typescript theme={null} const week = await mf.spendControls.spend("week"); console.log(week.org.spentUsd); // e.g. 42.5, or null if nothing priced yet const alice = await mf.spendControls.spend("week", "alice@acme.com"); console.log(alice.dev?.spentUsd); ``` `spentUsd` is `null` when there's no priced usage in the window. A `null` means "nothing to total yet," never a fake `0`. Check `unpricedEventCount`: if it's above zero, some events couldn't be priced and the true spend is higher than `spentUsd` shows. *** ## `spendControls.coverage` Answers "N of M developers armed" over the trailing 7 days: how many developers' machines have cap enforcement turned on, out of every developer seen at all. ### Signature ```typescript theme={null} await mf.spendControls.coverage(): Promise ``` ### Example ```typescript theme={null} const { armed, total } = await mf.spendControls.coverage(); console.log(`${armed} of ${total} developers armed`); ``` `asOf` is `null` when there's been no recent activity. The server never makes up a timestamp. *** ## `spendControls.create` Creates a cap. Owner or finance key required. ### Signature ```typescript theme={null} await mf.spendControls.create(params: CreateSpendCapParams): Promise ``` ### Example ```typescript theme={null} // A whole-team ceiling const cap = await mf.spendControls.create({ scope: "org", amountUsd: 5000, period: "month", mode: "track", }); console.log(cap.sentence); // → "Stop AI spend at $5,000 across the whole team per month" // A per-developer cap. scopeValue is the developer's customerExternalId (email). await mf.spendControls.create({ scope: "dev", scopeValue: "alice@acme.com", amountUsd: 200, period: "week", mode: "enforce", }); ``` Every cap covers all AI tools; the SDK sends `providerScope: "all"` for you. That's what counts toward the cap; the pause itself happens on Claude Code and Codex tool calls on armed machines. A developer cap can never be set higher than the whole-team ceiling: the server rejects it with a plain message you can show as-is. *** ## `spendControls.update` Adjusts an existing cap's `amountUsd`, `period`, `mode`, `coolOffHours`, or `alertThresholds`. Owner or finance key required. A cap's identity (its scope and target) can't be changed; delete and recreate instead. ### Signature ```typescript theme={null} await mf.spendControls.update(id: string, patch: UpdateSpendCapParams): Promise ``` ### Example ```typescript theme={null} const cap = await mf.spendControls.update(capId, { amountUsd: 8000 }); console.log(cap.sentence); // the refreshed sentence ``` *** ## `spendControls.delete` Deletes a cap. Owner or finance key required. The response echoes the removed cap's sentence so you can confirm what was deleted. ### Signature ```typescript theme={null} await mf.spendControls.delete(id: string): Promise ``` ### Example ```typescript theme={null} const { sentence } = await mf.spendControls.delete(capId); console.log(`Removed: ${sentence}`); ``` *** ## The types ```typescript theme={null} type SpendCapPeriod = "day" | "week" | "month"; type SpendCapMode = "track" | "enforce"; interface SpendCap { id: string; scope: "org" | "team" | "dev"; scopeValue: string | null; providerScope: string; amountUsd: number; period: SpendCapPeriod; mode: SpendCapMode; coolOffHours: number | null; alertThresholds: number[]; createdByUserId: string | null; createdAt: string; updatedAt: string; sentence: string; } interface CreateSpendCapParams { scope: "org" | "dev"; scopeValue?: string; providerScope?: "all"; amountUsd: number; period: SpendCapPeriod; mode: SpendCapMode; coolOffHours?: number; alertThresholds?: number[]; } interface UpdateSpendCapParams { amountUsd?: number; period?: SpendCapPeriod; mode?: SpendCapMode; coolOffHours?: number; alertThresholds?: number[]; } interface SpendScopeTotal { customerExternalId?: string; spentUsd: number | null; eventCount: number; unpricedEventCount: number; } interface SpendReadback { period: SpendCapPeriod; window: { start: string; end: string }; asOf: string; org: SpendScopeTotal; dev: SpendScopeTotal | null; } interface SpendCoverage { armed: number; total: number; asOf: string | null; } interface DeleteSpendCapResponse { deleted: boolean; sentence: string; } ``` Note the read types accept a wider `scope` vocabulary than `create` does. That's deliberate: `create` takes `org` or `dev` only, while `list` can return older rows without breaking your types if the vocabulary ever widens. # Subscriptions Source: https://docs.marginfront.com/sdk/subscriptions Fetch a subscription together with its revenue, cost, and margin in one call # Subscriptions The subscriptions resource on the SDK covers the usual list + get operations, plus one method built for subscription detail pages: `getWithRevenue`. It fetches the subscription and its revenue/cost/margin for a time window in one round trip. This page covers: 1. Listing subscriptions 2. Getting one subscription 3. Getting a subscription with its revenue numbers attached *** ## Setup ```typescript theme={null} import { MarginFrontClient } from "@marginfront/sdk"; const mf = new MarginFrontClient(process.env.MF_API_SECRET_KEY); ``` *** ## `subscriptions.list` Returns a paginated list of subscriptions with optional filters. ### Signature ```typescript theme={null} await mf.subscriptions.list(params?: ListSubscriptionsParams): Promise ``` ### Parameters All optional. | Name | Type | Description | | -------------------- | -------- | -------------------------------------------------------------- | | `customerId` | `string` | Filter to one customer (MarginFront's internal UUID). | | `customerExternalId` | `string` | Filter to one customer by YOUR external id. | | `agentId` | `string` | Filter to one agent. | | `status` | `string` | `"active"`, `"paused"`, `"cancelled"`, `"ended"`, `"pending"`. | | `page` | `number` | Page number (default 1). | | `limit` | `number` | Results per page (default 20). | ### Example ```typescript theme={null} const { subscriptions, totalResults } = await mf.subscriptions.list({ status: "active", limit: 50, }); console.log(`${totalResults} active subscriptions`); for (const sub of subscriptions) { console.log(` ${sub.customer.name} on ${sub.plan.name}`); } ``` *** ## `subscriptions.get` Fetches one subscription by id. Returns the full subscription detail plus a usage summary for the current billing period. ### Signature ```typescript theme={null} await mf.subscriptions.get(subscriptionId: string): Promise ``` ### Example ```typescript theme={null} const sub = await mf.subscriptions.get("sub_abc123"); console.log(`Status: ${sub.status}`); console.log(`Plan: ${sub.plan.name}`); console.log(`Usage this period: ${sub.usage.totalQuantity}`); ``` *** ## `subscriptions.getWithRevenue` **What it does in plain English:** fetches a subscription AND its revenue numbers for a time window, in one call. Lets you render a subscription detail page (status, plan, usage, revenue, cost, margin) without juggling two round trips. Behind the scenes the SDK fires both requests in parallel, so the wall-clock time is roughly the time of one call. ### Signature ```typescript theme={null} await mf.subscriptions.getWithRevenue( subscriptionId: string, window?: SubscriptionRevenueWindow, ): Promise ``` ### Parameters | Name | Type | Required | Description | | ------------------ | -------- | -------- | --------------------------------------------- | | `subscriptionId` | `string` | yes | The subscription id. | | `window.startDate` | `string` | no | ISO date. Defaults to 30 days ago if omitted. | | `window.endDate` | `string` | no | ISO date. Defaults to today if omitted. | Pass both window fields together or neither. Omitting both defaults to the last 30 days UTC. ### Response shape ```typescript theme={null} interface SubscriptionDetailWithRevenue { subscription: SubscriptionDetail; revenue: RevenueMetrics; } ``` The `subscription` field is the same shape as `subscriptions.get` returns. The `revenue` field is the same canonical shape `analytics.revenue` returns, scoped to this one subscription. See [Types Reference](/sdk/types-reference) for the complete interfaces. ### Example ```typescript theme={null} // Default window (last 30 days) const { subscription, revenue } = await mf.subscriptions.getWithRevenue("sub_abc123"); console.log(`Customer: ${subscription.customer.name}`); console.log(`Plan: ${subscription.plan.name}`); console.log(`Status: ${subscription.status}`); console.log(`Revenue: $${revenue.revenue.toFixed(2)}`); console.log(`Cost: $${revenue.cost.toFixed(2)}`); console.log(`Margin: $${revenue.margin.toFixed(2)}`); if (revenue.marginPercent !== null) { console.log(`Margin %: ${revenue.marginPercent.toFixed(1)}%`); } else { console.log(`Margin %: not available (no revenue in window)`); } console.log(`Events: ${revenue.eventCount}`); if (revenue.eventCountWithNullCost > 0) { console.log( ` (${revenue.eventCountWithNullCost} events have no cost yet. ` + `Check the dashboard for unmapped models.)`, ); } // Explicit window const april = await mf.subscriptions.getWithRevenue("sub_abc123", { startDate: "2026-04-01", endDate: "2026-04-30", }); ``` ### What this returns, in plain English * **`subscription`** the usual subscription detail. Customer, agent, plan, status, billing cycle, current-period usage summary. * **`revenue`** a full canonical revenue block for this one subscription, in the window you asked for. Revenue, cost, margin, marginPercent, plus the usage/recurring/seat/onetime breakdown and a per-pricing-strategy row list. For the full shape of `RevenueMetrics`, see [Types Reference](/sdk/types-reference#revenuemetrics). ### When to use this Subscription detail pages. "Customer plan overview" dashboards. Anywhere you need the subscription AND how it's performing in one view. For just the subscription record (no revenue math), use `subscriptions.get`. For revenue across many subscriptions, use `analytics.revenue` with no filter. *** ## Next steps * **[Analytics](/sdk/analytics)**: all seven canonical analytics methods * **[Types Reference](/sdk/types-reference)**: full interface definitions * **[Customers with Revenue](/sdk/customers)**: the same pattern, but scoped to a customer # Tracking Usage Events Source: https://docs.marginfront.com/sdk/tracking-events Where to put the SDK code in your agent, what fields to send, and real-world examples # Tracking Usage Events Every time your AI agent does work for a customer (answers a question, sends an SMS, generates a report), you tell MarginFront about it by sending a **usage event**. MarginFront figures out the cost, rolls everything up at the end of the billing period, and generates an invoice. Think of it like a utility meter. Each event is a meter reading. MarginFront is the utility company that turns those readings into a bill. > **You don't need to set anything up first.** When you fire an event with a new `customerExternalId`, `agentCode`, or `signalName`, MarginFront creates the customer, agent, or signal automatically. Your existing user IDs from your own database flow straight through. The dashboard updates the moment the event lands. This page covers: 1. How to install and set up the SDK 2. What fields to send with every event 3. Four real-world examples (copy-paste ready) 4. Batch events, error handling, and retry behavior *** ## Install the SDK ```bash theme={null} npm install @marginfront/sdk ``` ## Initialize the client ```typescript theme={null} import { MarginFrontClient } from "@marginfront/sdk"; // Your secret API key from the MarginFront dashboard (Build > API keys). // NEVER put the actual key in your code -- use an environment variable. const mf = new MarginFrontClient(process.env.MF_API_SECRET_KEY); ``` That's it. The client is ready to send events. No `.connect()` call required for usage tracking. *** ## What fields do I send? ### Required for EVERY event | Field | Type | What it is | | -------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `customerExternalId` | string | Your customer's ID in **your** system. Whatever you use to identify them. If MarginFront has not seen this ID before, the customer is created automatically. | | `agentCode` | string | A stable identifier for the agent or product that did the work. If MarginFront has not seen this code before, the agent is created automatically. | | `signalName` | string | The billing unit being tracked (e.g. `messages`, `sms-sent`, `report-pages`). If MarginFront has not seen this name before, the signal is created automatically. | | `model` | string | The model or service that did the work. Pass whatever your provider returns. Examples: `"gpt-4o"`, `"claude-sonnet-4"`, `"twilio-sms"`. To discover the canonical names MarginFront recognizes, call `mf.services.list({ provider: '' })` (added in SDK 0.12.0). | | `modelProvider` | string | The provider name, **always lowercase**. This tells MarginFront which pricing table to look in. Examples: `"openai"`, `"anthropic"`, `"twilio"`, `"google"`, `"aws"`. | ### Recommended for LLM events | Field | Type | What it is | | -------------- | ------- | -------------------------------------------------------------------------------------------------- | | `inputTokens` | integer | The number of prompt tokens (what you sent to the model). Must be a whole integer, 0 or greater. | | `outputTokens` | integer | The number of completion tokens (what the model sent back). Must be a whole integer, 0 or greater. | MarginFront uses these to calculate cost. If you're tracking an LLM call and you don't send token counts, MarginFront can't calculate the cost for that event. ### Prompt-cache tokens (optional, LLM only) If you use prompt caching, send the cache token counts so cache-heavy traffic is priced at the cheaper cache rate instead of the full input rate. Both fields are optional and additive — leave them off and cache contributes nothing, exactly as before. | Field | Type | What it is | | ------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `cacheReadTokens` | integer | Prompt-cache read tokens (cache hits). Read it from your provider response (Anthropic `cache_read_input_tokens`, OpenAI `prompt_tokens_details.cached_tokens`). Priced at the cheaper cache-read rate. | | `cacheWriteTokens` | integer | Prompt-cache write tokens (cache creation). Read it from your provider response (Anthropic `cache_creation_input_tokens`). Priced at the cache-write rate. | ### For variable-quantity billing | Field | Type | Default | What it is | | ---------- | ------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `quantity` | number | `1` | How many units of work happened. Whole numbers or fractions both work. Use this for per-page, per-minute, per-SMS billing. Tracking a fractional unit like 14.137 seconds of compute? Send it exactly as it is — no rounding needed. | ### Optional | Field | Type | Default | What it is | | ------------- | ----------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `usageDate` | string (ISO 8601) or Date | now | When the work actually happened. Only needed if you're back-filling historical events. Example: `"2026-04-10T14:30:00Z"`. | | `metadata` | object | `{}` | Free-form key-value pairs. MarginFront stores them but does **NOT** use them for billing or cost calculation. Use metadata for your own debugging, analytics, or audit trail. | | `environment` | string (one of `production`, `staging`, `development`, `testing`) | — | Where the event happened. Lets MarginFront split your AI spend into Cost of Goods Sold vs Research & Development. See [Tagging events for cost classification](#tagging-events-for-cost-classification). Optional. | *** ## Tagging events for cost classification If your finance team wants to split your AI spend into **Cost of Goods Sold** (what it costs to serve paying customers) versus **Research & Development** (internal experiments and test runs), add an `environment` to each event: ```typescript theme={null} await mf.usage.record({ customerExternalId: "acme-001", agentCode: "cs-bot-v2", signalName: "messages", model: "gpt-4o", modelProvider: "openai", inputTokens: 523, outputTokens: 117, environment: "production", // "production" | "staging" | "development" | "testing" }); ``` The first time a signal sees an event that carries an `environment`, MarginFront sorts that signal into a cost category for you: | `environment` you send | Cost category it becomes | | ---------------------- | ----------------------------------------------------------- | | `production` | Production — Cost of Goods Sold | | `development` | Development — Research & Development | | `testing` | Development — Research & Development | | `staging` | Left unset on purpose — choose it yourself in the dashboard | This automatic sorting only happens while the signal is still unclassified. Once you set a category by hand in the dashboard, your choice sticks — later events still record their `environment`, but they no longer change the category. `environment` is optional. Leave it off and your events work exactly as before. You can see the breakdown on your dashboard's Cost Management page. *** ## Example 1: LLM Event (the 90% case) **Use case:** Your AI customer support bot answers a question using GPT-4o via the OpenAI SDK. You need to track the token usage so MarginFront can calculate cost and bill your customer. **Where does the MarginFront call go?** After the OpenAI response comes back, in the response handler. You need the token counts from the response, so you can't send the event before the LLM responds. ```typescript theme={null} import OpenAI from "openai"; import { MarginFrontClient } from "@marginfront/sdk"; const openai = new OpenAI(); const mf = new MarginFrontClient(process.env.MF_API_SECRET_KEY); async function handleCustomerQuestion(customerId: string, question: string) { // Step 1: Call OpenAI like you normally would const response = await openai.chat.completions.create({ model: "gpt-4o", messages: [ { role: "system", content: "You are a helpful customer support agent." }, { role: "user", content: question }, ], }); // Step 2: Get the answer to send back to the customer const answer = response.choices[0].message.content; // Step 3: Tell MarginFront what just happened. // // This runs in the background by default (fireAndForget: true). // If the network is down, it retries automatically. // If MarginFront is unreachable, your agent keeps running -- the customer // still gets their answer. Billing is important, but never more important // than your core product working. try { await mf.usage.record({ customerExternalId: customerId, // your customer's ID in your system agentCode: "cs-bot-v2", // the agent code from the dashboard signalName: "messages", // the metric you're tracking model: response.model, // "gpt-4o" -- straight from OpenAI's response modelProvider: "openai", // always lowercase inputTokens: response.usage.prompt_tokens, // how many tokens the prompt used outputTokens: response.usage.completion_tokens, // how many tokens the answer used }); } catch (error) { // With fireAndForget ON (the default), this catch block almost never runs. // The SDK handles retries internally. This is just a safety net. console.error("MarginFront tracking failed (non-blocking):", error); } return answer; } ``` **Key mapping from OpenAI's response to MarginFront fields:** | OpenAI response field | MarginFront field | | ---------------------------------- | ----------------- | | `response.model` | `model` | | `response.usage.prompt_tokens` | `inputTokens` | | `response.usage.completion_tokens` | `outputTokens` | *** ## Example 2: Non-LLM Discrete Event (quantity = 1) **Use case:** Your agent sends a Twilio SMS on behalf of a customer. There are no tokens involved -- it's a simple "one SMS was sent" event. **Where does the MarginFront call go?** After Twilio confirms the SMS was sent. You only want to bill for messages that actually went out. ```typescript theme={null} import twilio from "twilio"; import { MarginFrontClient } from "@marginfront/sdk"; const twilioClient = twilio( process.env.TWILIO_SID, process.env.TWILIO_AUTH_TOKEN, ); const mf = new MarginFrontClient(process.env.MF_API_SECRET_KEY); async function sendSmsForCustomer( customerId: string, to: string, body: string, ) { // Step 1: Send the SMS through Twilio const message = await twilioClient.messages.create({ to, from: process.env.TWILIO_PHONE_NUMBER, body, }); // Step 2: Twilio confirmed it was sent -- now tell MarginFront. // // quantity is 1 here (one SMS). You could omit it since 1 is the default, // but being explicit makes the code easier to read later. try { await mf.usage.record({ customerExternalId: customerId, agentCode: "notification-agent", signalName: "sms-sent", model: "sms-send", // not an LLM model -- just a label for the service modelProvider: "twilio", // which provider handled it quantity: 1, // one SMS sent }); } catch (error) { console.error("MarginFront tracking failed (non-blocking):", error); } return message.sid; } ``` **No `inputTokens` or `outputTokens` here.** Those fields are only for LLM calls. For non-LLM services, cost is based on `quantity` and the pricing you set up in the dashboard. *** ## Example 3: Variable-Quantity Event (quantity = N) > **Before you read this example, the one rule that keeps your bill accurate:** fire **ONE event per business outcome**, not one per page, minute, or token. A 50-page report is one event with `quantity: 50`. A 3-minute call is one event with `quantity: 3`. The `quantity` field exists so you don't have to loop. Looping would multiply your invoice and flood your analytics. See [Choosing your signal name and quantity](/concepts#choosing-your-signal-name-and-quantity) for the full mental model and a three-way comparison. **Use case:** Your agent generates a market research report for a customer. Reports vary in size -- a 3-page report costs less than a 15-page report. You bill per page. This event has **both** token counts (because the report was generated by an LLM) **and** a quantity (because billing is based on pages, not tokens). The tokens track your cost from the LLM provider. The quantity tracks the output size for billing your customer. ```typescript theme={null} import Anthropic from "@anthropic-ai/sdk"; import { MarginFrontClient } from "@marginfront/sdk"; const anthropic = new Anthropic(); const mf = new MarginFrontClient(process.env.MF_API_SECRET_KEY); async function generateReport(customerId: string, topic: string) { // Step 1: Generate the report with Claude const response = await anthropic.messages.create({ model: "claude-sonnet-4-20250514", max_tokens: 8000, messages: [ { role: "user", content: `Write a market research report on: ${topic}` }, ], }); const reportText = response.content[0].type === "text" ? response.content[0].text : ""; // Step 2: Figure out how many pages the report is. // (Your real logic might be more sophisticated -- this is just an example.) const estimated_pages = Math.ceil(reportText.length / 3000); // Step 3: Tell MarginFront about the report. // // quantity = number of pages. This is what the customer gets billed for. // inputTokens / outputTokens = LLM usage. This tracks your cost from Anthropic. // Both matter, but for different reasons. try { await mf.usage.record({ customerExternalId: customerId, agentCode: "research-agent", signalName: "report-pages", // the billable metric is pages model: response.model, // "claude-sonnet-4-20250514" modelProvider: "anthropic", inputTokens: response.usage.input_tokens, // Anthropic uses input_tokens (not prompt_tokens) outputTokens: response.usage.output_tokens, // Anthropic uses output_tokens (not completion_tokens) quantity: estimated_pages, // 15 pages = 15 units billed }); } catch (error) { console.error("MarginFront tracking failed (non-blocking):", error); } return { reportText, pages: estimated_pages }; } ``` **When to use `quantity`:** Any time the amount of work varies and you want billing to reflect that. Pages generated, minutes of audio transcribed, images produced, API calls batched -- if the number changes per event, use `quantity`. *** ## Example 4: Metadata **Use case:** You want to attach debugging information to an event -- which prompt template was used, which A/B test variant the customer saw, the conversation thread ID. This helps you analyze cost and performance later without affecting billing. ```typescript theme={null} await mf.usage.record({ customerExternalId: "acme-001", agentCode: "cs-bot-v2", signalName: "messages", model: "gpt-4o", modelProvider: "openai", inputTokens: 812, outputTokens: 245, // metadata is free-form. Put whatever is useful for YOUR debugging and analytics. // MarginFront stores it with the event but does NOT use it for billing or // cost calculation. It will not appear on invoices. metadata: { conversationId: "conv_abc123", // link this event back to a chat thread promptTemplate: "support-v3.2", // which prompt version generated this abTestVariant: "concise-responses", // for your own A/B test analysis customerTier: "enterprise", // useful for segmenting cost reports responseLatencyMs: 1243, // track performance alongside cost }, }); ``` **What you can put in metadata:** * Strings, numbers, booleans, nested objects -- any valid JSON. * There is no schema. MarginFront stores whatever you send. * Use it for audit trails, debugging, analytics, or linking events back to your own systems. **What metadata does NOT do:** * It does not affect billing. A `customerTier: "enterprise"` in metadata does not change the price. * It does not affect cost calculation. MarginFront ignores it completely for pricing. * It does not appear on invoices. *** ## Example 5: Multi-Service Event (one outcome, multiple services) **Use case:** Your cold-outreach agent finds a prospect via Exa, enriches them via Hunter.io, writes a personalized message via Claude Opus, and sends it via Pipedream. From the customer's perspective that's ONE outreach to ONE prospect. From your cost perspective four underlying services contributed. **Why one event:** Firing four separate `mf.usage.record` calls (one per service) used to be the only option, but it quadrupled the prospect on your dashboard, made margin math harder, and could have multiplied the customer's invoice if all four events shared a signal. With the `services[]` shape, you fire ONE event per business outcome regardless of how many services contributed. ```typescript theme={null} import Anthropic from "@anthropic-ai/sdk"; import { MarginFrontClient } from "@marginfront/sdk"; const anthropic = new Anthropic(); const mf = new MarginFrontClient(process.env.MF_API_SECRET_KEY); async function sendColdOutreach(customerId: string, prospectQuery: string) { // Step 1: Find the prospect via Exa const exaResults = await exa.search({ query: prospectQuery, type: "people" }); const prospect = exaResults.results[0]; // Step 2: Enrich with verified email + role via Hunter.io const enrichment = await hunter.enrich({ domain: prospect.domain, name: prospect.name, }); // Step 3: Generate the personalized message with Claude Opus const llmResponse = await anthropic.messages.create({ model: "claude-opus-4-1", max_tokens: 1500, messages: [ { role: "user", content: `Write a personalized cold outreach to ${enrichment.name} at ${enrichment.company}, role: ${enrichment.role}.`, }, ], }); const message = llmResponse.content[0].type === "text" ? llmResponse.content[0].text : ""; // Step 4: Send via Pipedream workflow await pipedream.invoke("send-cold-email", { to: enrichment.email, body: message, }); // Step 5: Tell MarginFront about the WHOLE outreach (one event, four services) try { await mf.usage.record({ customerExternalId: customerId, agentCode: "outreach-bot", signalName: "outreaches-sent", // Top-level quantity stays signal-level: ONE outreach to ONE prospect. // Per-service volume lives inside each services[] entry. quantity: 1, services: [ { // Exa people-search API call model: "exa-search", modelProvider: "exa", quantity: 1, // 1 search call }, { // Hunter.io enrichment call model: "hunter-enrich", modelProvider: "hunter", quantity: 1, // 1 enrichment call }, { // Claude Opus writing the message. Track tokens AND the call count. model: llmResponse.model, modelProvider: "anthropic", inputTokens: llmResponse.usage.input_tokens, outputTokens: llmResponse.usage.output_tokens, quantity: 1, // 1 LLM call }, { // Pipedream workflow firing the email model: "pipedream-workflow", modelProvider: "pipedream", quantity: 1, // 1 send }, ], }); } catch (error) { console.error("MarginFront tracking failed (non-blocking):", error); } return message; } ``` **What lands in MarginFront:** * ONE event in the live event feed. * The Cost tab's "Cost by service" chart shows the rolled-up total split across all four services (Exa, Hunter, Claude Opus, Pipedream). * Customer's invoice bills per signal (one outreach = one charge at your pricing plan rate), regardless of how many services contributed. ### LLM services: tokens AND quantity together LLM `services[]` entries can carry **both** `inputTokens`/`outputTokens` AND `quantity` at the same time. The cold-outreach example above does exactly this on the Claude entry. Three reasons you'd want both: 1. **Track the call count alongside tokens.** If the same LLM service was invoked N times within one outcome (e.g., one outreach that triggered 3 Claude calls because of retries or chain-of-thought intermediate prompts), `quantity: 3` records the call count for analytics; `inputTokens`/`outputTokens` carry the aggregated totals. 2. **Enable per-call pricing later.** MarginFront's cost calculation today is tokens-only for LLM models. If your provider charges per call AND per token (or you want to layer your own per-call markup), tracking call count is what enables that. (The `service_pricing` catalog's per-call cost coverage for LLM providers is being audited as a follow-up.) 3. **Reporting sanity-check.** Lets you compare "calls per outreach" to "tokens per outreach" in your own analytics or in the Cost tab's `byModel` breakdown. If you don't care about tracking the call count, omit `quantity` on LLM entries (or leave it at the default 1). Cost calculation is unaffected today. ### Two shapes, mutually exclusive | Shape | When to use | Top-level fields | `services[]` | | -------------- | ----------------------------------------------------------------- | --------------------------------------------- | -------------------------------- | | Single-service | One event, one underlying service. The 90% case for chatbots. | `model` + `modelProvider` + volume (required) | omit | | Multi-service | One event, multiple underlying services for one business outcome. | `quantity` is signal-level (default 1) | one entry per service (required) | Send `model` + `modelProvider` OR send `services[]`, never both, never neither. If you mix shapes, the SDK rejects the request before it leaves your code with a clear English error pointing at the fix. ### Multiple calls of the same model in one event If your agent makes two Claude calls for one outreach (e.g., a draft pass + a refinement pass that both contribute to the same message), you have two equally valid ways to record it: **Option A: list each call as its own `services[]` entry.** Each becomes a distinct cost line. Use this when you want per-call resolution (e.g., to compare draft cost vs refinement cost). ```typescript theme={null} services: [ { model: "claude-opus-4-1", modelProvider: "anthropic", inputTokens: 4500, outputTokens: 800, quantity: 1, }, { model: "claude-opus-4-1", modelProvider: "anthropic", inputTokens: 1200, outputTokens: 400, quantity: 1, }, ]; ``` **Option B: aggregate into one `services[]` entry with summed tokens and `quantity` = call count.** Cleaner if you don't need per-call resolution. ```typescript theme={null} services: [ { model: "claude-opus-4-1", modelProvider: "anthropic", inputTokens: 5700, // 4500 + 1200 outputTokens: 1200, // 800 + 400 quantity: 2, // 2 LLM calls aggregated }, ]; ``` Both produce the same rolled-up parent cost. The first is more granular for debugging; the second is more compact. See [Core Concepts: One event, multiple services](/concepts#one-event-multiple-services) for the conceptual mental model and [Usage Events API reference](/api-reference/usage-events) for the full request/response schema. *** ## Batch Events When your agent does several things in quick succession (or you're processing a queue), send them all in one request instead of one at a time. You can send 1 to 100 records per batch. ```typescript theme={null} const response = await mf.usage.recordBatch([ // LLM event { customerExternalId: "acme-001", agentCode: "cs-bot-v2", signalName: "messages", model: "gpt-4o", modelProvider: "openai", inputTokens: 523, outputTokens: 117, }, // Non-LLM event (different customer, different agent) { customerExternalId: "beta-corp", agentCode: "notification-agent", signalName: "sms-sent", model: "sms-send", modelProvider: "twilio", quantity: 3, }, // Another LLM event with metadata { customerExternalId: "acme-001", agentCode: "research-agent", signalName: "report-pages", model: "claude-sonnet-4-20250514", modelProvider: "anthropic", inputTokens: 4000, outputTokens: 6500, quantity: 12, metadata: { reportTopic: "Q2 market trends" }, }, ]); ``` You can mix LLM events and non-LLM events in the same batch. Different customers, different agents -- all fine. ### Checking for partial failures The API returns `200 OK` even when some records in the batch fail. Always check the response: ```typescript theme={null} const response = await mf.usage.recordBatch(records); // Check if any events in the batch had problems if (response.failed > 0) { console.warn( `${response.failed} of ${response.processed} events had issues:`, ); for (const failure of response.results.failed) { // "error" tells you what went wrong in plain English console.warn(` - ${failure.error}`); // "record" echoes back the original data so you can identify which event failed console.warn(` Record:`, failure.record); } } // The rest of the batch still succeeded -- you don't need to resend the whole thing console.log(`${response.successful} events recorded successfully`); ``` *** ## Fire-and-Forget Mode By default, `usage.record()` and `usage.recordBatch()` run in **fire-and-forget mode**. This means: * **Your agent never blocks** waiting for MarginFront. The call returns immediately. * **Network errors don't crash your agent.** If MarginFront is unreachable, the SDK puts the event into a retry buffer and tries again later. * **Validation errors log a warning and drop** (they can't be fixed by retrying). * **Auth failures (401/403) drop the event right away with a warning** that names the key as the cause. A revoked, rotated, or wrong-environment key can't be fixed by retrying, so the SDK tells you the truth instead of retrying and then throwing the event away. The response reports `buffered: 0`. * **The response never pretends.** Since SDK 0.19.0, a record lands in `results.success` only when the server confirmed it was stored. A swallowed transport failure or a validation drop shows up honestly: `successful: 0`, the record in `results.failed`, and the fields below telling you exactly what happened. ### How the retry buffer works When a network error happens: 1. The failed event goes into an in-memory buffer (holds up to **1,000 events**). 2. The SDK retries the buffer on a backoff schedule: **10s, 20s, 40s, 60s** (caps at 60s). 3. Each event gets **5 retry attempts**. After 5 failures, it's dropped with a warning. 4. When a retry succeeds, the backoff resets to 10s. 5. If the buffer is full (1,000 events), the oldest event is dropped to make room. **A dead key doesn't empty your buffer on the first failure.** If a retry comes back 401 or 403, that failure doesn't burn one of the 5 attempts and doesn't stretch the backoff. The buffer waits one more cycle (about 10 seconds) and throws the held events away only if the key is rejected again. A single 401 can come from a brief problem on MarginFront's side, so one is never treated as proof of a bad key. Any successful delivery resets the count. When delivery starts working again, one warning tells you how many events were lost while the key was rejected. (If your process restarts, the count resets too, so a fresh process treats its first 401 as unconfirmed.) ### Reading the response: success means delivered Fire-and-forget never throws, but it doesn't lie either. There's no single "it worked" boolean on `record()` and `recordBatch()` responses; the truth lives in the counts, the row placement, and two fields: | Field | Type | What it tells you | | ------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `successful` | number | Counts only records the server confirmed it stored. A record appears in `results.success` when (and only when) it was delivered AND stored. A transport failure counts nothing here, even when the events were buffered for retry. | | `delivered` | boolean | `true` only when a server response was actually received. `false` means the SDK swallowed a transport failure or a validation drop. | | `buffered` | number | How many events the local retry buffer actually accepted after a transport failure. `0` on validation drops (invalid events are never buffered). Absent on delivered responses, because there was nothing to buffer. | The one rule that keeps your bill right: **if `buffered > 0`, do NOT re-send those events yourself.** The SDK is already retrying them. Sending them again from your code would double-bill your customer. Re-send only rows the response marks `stored: false` after the SDK has given up on them (you'll see a dropped-after-retries warning in your logs). ```typescript theme={null} const response = await mf.usage.record({ ... }); if (!response.delivered) { // The event never reached the server this attempt. // buffered tells you whether the SDK is handling the retry. if ((response.buffered ?? 0) > 0) { // The SDK has it. Do nothing, or you'll double-bill. } else { // A validation drop: fix the record, this one is gone. console.warn( "Event dropped before send:", response.results.failed[0]?.error, ); } } ``` Failed rows in `results.failed[]` carry the same honesty per record: `code` (the server's failure code), `stored` (whether the server kept the event anyway), and `eventId` / `rawEventId` (the correlation handles for support). The `stored` flag is your retry oracle: `false` means safe to re-send, `true` means the server has it, and **absent means the server didn't say, so the SDK treats it as stored and doesn't re-queue it** (double-billing is the worse failure). The [stored flag reference](/api-reference/errors#the-stored-flag) covers the server-side codes. ### Knowing what an event created The first event for a new `customerExternalId`, `agentCode`, or `signalName` creates that customer, agent, or signal for you. The result tells you when that happened: ```typescript theme={null} const result = await mf.usage.record({ customerExternalId: "cust_789", // first event for this customer agentCode: "cs-bot", signalName: "support-reply", model: "gpt-4o", modelProvider: "openai", inputTokens: 523, outputTokens: 117, }); result.results.success[0].created; // { customer: true, agent: false, signal: true } ``` Four things to know before you branch on it: * **Failed-but-stored records have it too.** Records are created before pricing runs, so an event stored as `NEEDS_COST_BACKFILL` (an unknown model) still created its customer, agent, and signal, and the failed row says so. * **A missing `created` isn't the same as `false`.** A retry that collapsed into an already-recorded event via `idempotencyKey` returns no `created` field at all, and so do transport failures and older servers. `{ customer: false, ... }` is a real answer meaning "everything already existed." A missing field means "no information." Write `if (created?.customer)` and you're safe. Write `if (created.customer === false)` only when you know the field is there. * **Reviving a deleted record reports `false`.** Send an event for a customer, agent, or signal you deleted in the dashboard and MarginFront brings the original back, keeping its ID and its billing history, instead of making a duplicate or failing. Because the record already existed, the flag is `false`. * **A subscription deleted along with a customer stays deleted.** Recreate it in the dashboard if you want billing to pick up again. The everyday use for this: a `customerExternalId` you expected to already exist coming back with `customer: true` almost always means a typo. ### Turning off fire-and-forget If you want to handle errors yourself (for example, to log them to your own monitoring system), turn off fire-and-forget when you create the client: ```typescript theme={null} const mf = new MarginFrontClient(process.env.MF_API_SECRET_KEY, { fireAndForget: false, // Now usage.record() can throw errors }); try { await mf.usage.record({ customerExternalId: "acme-001", agentCode: "cs-bot-v2", signalName: "messages", model: "gpt-4o", modelProvider: "openai", inputTokens: 100, outputTokens: 50, }); } catch (error) { // With fireAndForget OFF, this catch block WILL run on network errors. // You're responsible for retrying or logging. console.error("Failed to record usage event:", error); } ``` **Recommendation:** Leave fire-and-forget ON (the default). Your agent should never break because the billing API is down. The retry buffer handles transient issues, and permanent failures (like a bad API key) will show up in your server logs as warnings. ### Telling a dead key apart from a network blip With fire-and-forget off, `usage.record()` throws, and you may want to handle a dead key differently from a network blip: one you fix by rotating the key, the other you fix by trying again later. Import `isAuthFailure` to tell them apart: ```typescript theme={null} import { MarginFrontClient, isAuthFailure } from "@marginfront/sdk"; try { await mf.usage.record({ customerExternalId: "acme-001", agentCode: "cs-bot-v2", signalName: "messages", model: "gpt-4o", modelProvider: "openai", inputTokens: 100, outputTokens: 50, }); } catch (error) { if (isAuthFailure(error)) { // The key is the problem. Trying again can't succeed -- rotate the key. } else { // A network blip. Trying again later may work. } } ``` `isAuthFailure` returns `true` for any 401 or 403 from the API. *** ## Field Reference ### Required for every event | Field | Type | Description | | -------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------- | | `customerExternalId` | string | Your customer's ID in your system. Must match the `externalId` you set when creating the customer in MarginFront (or via auto-provisioning). | | `agentCode` | string | The agent code from the MarginFront dashboard. Identifies which product/agent did the work. | | `signalName` | string | The name of the metric being tracked. Matches the signal you configured in the dashboard. | | `model` | string | The model or service identifier. Pass whatever your provider returns (e.g., `response.model` from OpenAI). Case-insensitive. | | `modelProvider` | string | The provider name, always lowercase. Tells MarginFront which pricing table to look up. | ### Recommended for LLM events | Field | Type | Description | | ------------------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `inputTokens` | integer | Number of prompt/input tokens. Must be a whole integer, 0 or greater. Required for MarginFront to calculate LLM cost. | | `outputTokens` | integer | Number of completion/output tokens. Must be a whole integer, 0 or greater. Required for MarginFront to calculate LLM cost. | | `cacheReadTokens` | integer | Optional. Prompt-cache read tokens (cache hits). Priced at the cheaper cache-read rate. Read it from the provider response (Anthropic `cache_read_input_tokens`, OpenAI `prompt_tokens_details.cached_tokens`). | | `cacheWriteTokens` | integer | Optional. Prompt-cache write tokens (cache creation). Priced at the cache-write rate. Read it from the provider response (Anthropic `cache_creation_input_tokens`). | ### For variable-quantity billing | Field | Type | Default | Description | | ---------- | ------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `quantity` | number | `1` | Number of billing units, 0 or greater. Whole numbers or fractions both work. Use for per-page, per-minute, per-image, per-SMS billing. For fractional units (e.g. 14.137 seconds of compute), send them exactly as they are — no rounding needed. | ### Optional | Field | Type | Default | Description | | ------------- | ------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `usageDate` | string (ISO 8601) or Date | now | When the work happened. Use for back-filling historical events. | | `metadata` | object | `{}` | Free-form key-value pairs. Stored but NOT used for billing or cost calculation. | | `environment` | string | — | Where the event happened: `production`, `staging`, `development`, or `testing`. Used to split spend into Cost of Goods Sold vs Research & Development. Optional. | *** ## What happens after you send an event Your agents can always fire events. MarginFront stores every event it receives, even when it can't figure out the cost yet. The response tells you which of three things happened so you know whether any follow-up action is needed. Think of it like dropping off a package at the post office. The package is always accepted. Sometimes the label is complete and it ships right away. Sometimes a piece of info is missing and it waits in a holding bin until you fill in the blank. Nothing gets thrown out. ### The three states | State | Event saved? | Cost calculated? | What it means | | --------------------- | ------------ | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `PROCESSED` | Yes | Yes | The event was received, the model was recognized, and the cost was calculated. Nothing for you to do. | | `MISSING_VOLUME_DATA` | Yes | No (cost is `null`) | The event was received, but it didn't include the token counts or quantity needed to price it. Cost stays blank until you backfill the missing numbers. | | `NEEDS_COST_BACKFILL` | Yes | No (cost is `null`) | The event was received, but cost couldn't be set. Either the `model` + `modelProvider` you sent isn't in MarginFront's pricing table yet, or it is in the table but has no rate for the cache tokens your event reported (so MarginFront won't quietly price that cache at \$0). Cost stays blank until the model is mapped or cache pricing is added. | **Why this exists:** your agents should never break because billing infrastructure hiccuped. If the cost can't be figured out in the moment, MarginFront still keeps the event so you don't lose history, and gives you a clean path to fix it later. ### How to fix each non-`PROCESSED` state Both fixes are one-time dashboard actions (or a single API call each). After you apply them, MarginFront retroactively recalculates cost for every affected event that was parked in that state. **If the event came back as `MISSING_VOLUME_DATA`:** * In the dashboard, go to the "Needs attention" section under Usage Events and fill in the missing token counts or quantity for the flagged events. * Or call `POST /v1/events/fill-volume` with the event ID and the correct volume numbers. **If the event came back as `NEEDS_COST_BACKFILL`:** * In the dashboard, go to the "Needs attention" section under Usage Events. If the model is unrecognized, map it to a known one. If the model is recognized but missing cache-token pricing, email `team@marginfront.com` to have cache pricing added. * For an unrecognized model, you can also call `POST /v1/events/map-model` with the model name and the service it should be priced as. In both cases, once the fix is applied, every past event in that state auto-updates with a real cost, and future events using the same model (or the same well-formed payload shape) will be `PROCESSED` immediately. No manual step needed again. **Do NOT retry events that were stored.** They're already in the database. Retrying would create duplicates. The fix happens on MarginFront's side, not by resending the event. *** ## Error Handling ### The API returns 200 even when events fail This is intentional. A batch of 10 events might have 9 successes and 1 failure. A `200` tells you the request was received. The response body tells you what actually happened. Always check `failed > 0` in the response: ```typescript theme={null} const response = await mf.usage.record({ customerExternalId: "acme-001", agentCode: "cs-bot-v2", signalName: "messages", model: "my-custom-model", modelProvider: "custom", inputTokens: 500, outputTokens: 200, }); // For single events, the response still tells you if there was a problem if (response.failed > 0) { for (const failure of response.results.failed) { console.warn("Event issue:", failure.error); } } ``` ### NEEDS\_COST\_BACKFILL -- what it means An event gets this status for one of two reasons: * **The model is unknown.** The `model` + `modelProvider` you sent isn't in MarginFront's pricing table. * **The model is known but has no cache pricing.** The model is in the table, but it has no rate for cache tokens — and your event included cache tokens (`cacheReadTokens` or `cacheWriteTokens`). Rather than quietly pricing that cache at \$0 and under-counting your cost, MarginFront flags the event. The `error` message on the failed record tells you which case you hit. In both cases: 1. The event is **saved** with `cost = null`. It is NOT lost. 2. The response includes the event in `results.failed` with the code `NEEDS_COST_BACKFILL` and `stored: true`. 3. In the MarginFront dashboard, go to the "Needs attention" section under Usage Events. 4. If the model is unknown, map it to a known one — MarginFront creates a permanent mapping and retroactively calculates cost for every event that used that model. If the model is known but missing cache pricing, email `team@marginfront.com` to have cache pricing added for it. 5. Once resolved, future events with that model auto-resolve. No manual step needed again. **Do NOT retry events that have `stored: true`.** They are already saved. Retrying would create duplicates. ### The "never break the core product" pattern Your agent exists to serve your customers. MarginFront exists to bill for that work. If billing fails, the customer should still get served. Always wrap usage tracking in a try/catch: ```typescript theme={null} // Your core product logic -- this MUST work const answer = await openai.chat.completions.create({ ... }); // Billing -- important but never more important than the answer try { await mf.usage.record({ ... }); } catch (error) { // Log it. Investigate later. The customer got their answer. console.error('Usage tracking failed:', error); } ``` Or better yet, leave `fireAndForget: true` (the default) and the SDK handles this pattern for you automatically. The call never throws, never blocks, and retries in the background. *** ## Common error codes | Code | Event saved? | What happened | What to do | | --------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `NEEDS_COST_BACKFILL` | Yes | The model+provider isn't in the pricing table, OR it is but has no rate for the cache tokens your event reported. Event saved with cost = null. | Do NOT retry. For an unknown model, map it in the dashboard or call `POST /v1/events/map-model`. For a known model missing cache pricing, email `team@marginfront.com`. Cost backfills automatically once resolved. | | `MISSING_VOLUME_DATA` | Yes | The event is missing token counts or quantity needed to price it. Event saved with cost = null. | Do NOT retry. Fill in the missing numbers in the dashboard or call `POST /v1/events/fill-volume`. Cost backfills automatically. | | `INTERNAL_ERROR` | No | Something broke on MarginFront's side. | Safe to retry. | | `VALIDATION_ERROR` | No | A required field is missing or has the wrong type. | Fix the data and resend. Check the `error` message for which field. | *** ## Quick checklist Before you ship, make sure: * [ ] The SDK is initialized with your **secret key** (`mf_sk_*`), not the publishable key * [ ] `customerExternalId` matches the external ID you set up for each customer * [ ] `agentCode` matches the agent code shown in the dashboard * [ ] `signalName` matches the signal name shown in the dashboard * [ ] For LLM events, you're sending `inputTokens` and `outputTokens` from the provider response * [ ] The `mf.usage.record()` call is **after** the work is done (after the LLM responds, after the SMS sends) * [ ] The call is wrapped in try/catch (or `fireAndForget` is ON, which is the default) * [ ] You're not retrying events that came back with `stored: true` # Types Reference Source: https://docs.marginfront.com/sdk/types-reference Every canonical TypeScript interface the SDK returns, in one place # Types Reference Every canonical type the SDK exports, with the full interface definition and a plain-English description of what it represents and when you'd see it. Money rule: **every dollar amount is `number`, not `string`.** Values come back at full precision. Round at render time, never before. Null rule: **a `null` means "not applicable here," not zero.** Most commonly: `marginPercent: null` means "revenue was zero, so margin cannot be computed." Render as a dash, not `0%`. *** ## RevenueMetrics The canonical revenue shape. Returned by `analytics.revenue`, embedded inside the revenue block of `customers.getWithRevenue` and `subscriptions.getWithRevenue`. ```typescript theme={null} interface RevenueMetrics { revenue: number; cost: number; margin: number; marginPercent: number | null; usageRevenue: number; recurringRevenue: number; seatRevenue: number; onetimeRevenue: number; eventCount: number; eventCountWithNullCost: number; bySubscription: SubscriptionRevenue[]; byStrategy: StrategyRevenue[]; } ``` **In plain English:** the whole revenue picture for a scope (org, one customer, one subscription, whatever you filtered by). Revenue is the full number including fees and prorations. Cost is what you paid to providers. Margin is revenue minus cost. The four xRevenue fields add up to the `revenue` total and tell you WHERE it came from (usage events, recurring fees, seats, or one-time charges). `eventCount` is every event; `eventCountWithNullCost` is the subset whose cost couldn't be calculated yet. **When you'd see it:** any canonical analytics call, plus the revenue blocks on customer and subscription detail pages. *** ## SubscriptionRevenue One row per active subscription inside `RevenueMetrics.bySubscription`. ```typescript theme={null} interface SubscriptionRevenue { subscriptionId: string; customerId: string; agentId: string; planId: string; revenue: number; cost: number; margin: number; usageRevenue: number; recurringRevenue: number; seatRevenue: number; onetimeRevenue: number; eventCount: number; } ``` **In plain English:** how one specific subscription contributed to the overall revenue. Useful for sorting "who are my biggest-revenue subscriptions" or drilling into a specific sub's margin. **When you'd see it:** inside `RevenueMetrics.bySubscription`, returned by `analytics.revenue` and the two `getWithRevenue` overlays. *** ## StrategyRevenue One row per pricing strategy inside `RevenueMetrics.byStrategy`. ```typescript theme={null} interface StrategyRevenue { strategyId: string; chargeType: ChargeType; pricingModel: PricingModel | null; signalId: string | null; revenue: number; quantity: number; } ``` **In plain English:** how one specific pricing rule contributed to the overall revenue. `chargeType` tells you whether this was a usage rate, a recurring fee, a seat fee, or a one-time charge. `pricingModel` tells you the shape of usage rates (flat/graduated/volume/credit\_pool). For usage strategies, `quantity` is the total quantity that ran through the rate in the window; for non-usage strategies it's `0`. **When you'd see it:** inside `RevenueMetrics.byStrategy`. *** ## CostMetrics The canonical cost shape. Returned by `analytics.costBreakdown`. ```typescript theme={null} interface CostMetrics { cost: number; eventCount: number; eventCountWithNullCost: number; byAgent: CostByAgentRow[]; byCustomer: CostByCustomerRow[]; bySignal: CostBySignalRow[]; byDay: CostByDayRow[]; byPlan: CostByPlanRow[]; byModel: CostByModelRow[]; prior?: Omit; } ``` **In plain English:** your total service cost in a window, sliced six different ways so you can find where the money is going. `prior` is a same-length window immediately before `startDate`, populated only when you asked for it with `includePriorWindow: true`. **When you'd see it:** `analytics.costBreakdown`. *** ## CostByAgentRow ```typescript theme={null} interface CostByAgentRow { agentId: string; cost: number; eventCount: number; } ``` **In plain English:** cost attributed to one agent in the window. *** ## CostByCustomerRow ```typescript theme={null} interface CostByCustomerRow { customerId: string; cost: number; eventCount: number; } ``` **In plain English:** cost attributed to one customer in the window. *** ## CostBySignalRow ```typescript theme={null} interface CostBySignalRow { signalId: string | null; cost: number; eventCount: number; } ``` **In plain English:** cost attributed to one signal in the window. `signalId: null` covers orphan events that aren't tied to a signal. *** ## CostByDayRow ```typescript theme={null} interface CostByDayRow { date: string; cost: number; eventCount: number; } ``` **In plain English:** cost for one UTC calendar day. One row per day with activity. `date` is an ISO string (JSON doesn't carry Date objects); convert on your side if you need a `Date`. *** ## CostByPlanRow ```typescript theme={null} interface CostByPlanRow { planId: string | null; cost: number; eventCount: number; } ``` **In plain English:** cost attributed to one pricing plan in the window. `planId: null` covers events whose subscription isn't on a plan. *** ## CostByModelRow ```typescript theme={null} interface CostByModelRow { model: string | null; cost: number; eventCount: number; } ``` **In plain English:** cost attributed to one LLM model in the window. `model: null` covers non-LLM events and events whose model wasn't recognized yet. *** ## AgentEarned The canonical shape returned by `analytics.agentEarned`. ```typescript theme={null} interface AgentEarned { revenue: number; eventCount: number; bySubscription: AgentEarnedSubscriptionRow[]; byStrategy: AgentEarnedStrategyRow[]; } ``` **In plain English:** activity-only revenue. Usage events × pricing strategy rates, summed up. No recurring fees, no seat fees, no one-time charges, no prorations. The earliest leading indicator that agents are doing billable work. **When you'd see it:** `analytics.agentEarned`. *** ## AgentEarnedSubscriptionRow ```typescript theme={null} interface AgentEarnedSubscriptionRow { subscriptionId: string; customerId: string; agentId: string; planId: string; revenue: number; eventCount: number; } ``` **In plain English:** activity-revenue contribution from one subscription. *** ## AgentEarnedStrategyRow ```typescript theme={null} interface AgentEarnedStrategyRow { strategyId: string; pricingModel: PricingModel | null; signalId: string | null; revenue: number; quantity: number; } ``` **In plain English:** activity-revenue contribution from one pricing strategy. `quantity` is the total quantity that ran through the rate. *** ## InvoiceTotals The canonical invoice-totals shape. Returned by `analytics.invoiceTotals`. ```typescript theme={null} interface InvoiceTotals { billed: number; collected: number; draft: number; outstanding: number; overdueAmount: number; overdueCount: number; invoicesSentCount: number; } ``` **In plain English:** finance-team numbers. `billed` is what you invoiced in the window (status issued or overdue), counted by invoice date. `collected` is the cash that landed in the window — payments that went through with a payment date in the window, net of refunds, counted by the day the money arrived (not the invoice date). `draft` is inventory of invoices not yet sent. `outstanding` is what's open right now — issued or overdue and not yet paid, a current-state number with no date window that's always zero or more. `overdueAmount` and `overdueCount` are current-state right now (not filtered by the date window). `invoicesSentCount` is how many non-draft invoices were dated in the window. **When you'd see it:** `analytics.invoiceTotals`. *** ## Mrr Returned by `analytics.mrr`. ```typescript theme={null} interface Mrr { mrr: number; arr: number; } ``` **In plain English:** last complete calendar month's billed total, plus its annualized version (`mrr × 12`). Stable across the current month (doesn't move until the next month rolls over). **When you'd see it:** `analytics.mrr`. *** ## RunRateMrrResult Returned by `analytics.runRateMrr`. ```typescript theme={null} interface RunRateMrrResult { mrr: number; breakdown: RunRateMrrBreakdownRow[]; } ``` **In plain English:** what MRR would be if the last 30 days of activity kept going at this pace. Recurring + seat + actual usage, monthly-normalized. One breakdown row per active subscription. **When you'd see it:** `analytics.runRateMrr`. *** ## RunRateMrrBreakdownRow ```typescript theme={null} interface RunRateMrrBreakdownRow { subscriptionId: string; customerId: string; agentId: string; planId: string; recurring: number; seatBased: number; usage: number; total: number; } ``` **In plain English:** per-subscription slice of run-rate MRR. `recurring` is the monthly-normalized recurring fees. `seatBased` is seat fees based on max(booked, minimum) seats. `usage` is actual last-30-days events run through the pricing rate. The three add up to `total`, and all the `total`s add up to the top-level `mrr`. *** ## CommittedMrrResult Returned by `analytics.committedMrr`. ```typescript theme={null} interface CommittedMrrResult { mrr: number; breakdown: CommittedMrrBreakdownRow[]; } ``` **In plain English:** contractual floor. The monthly revenue your contracts guarantee, regardless of whether customers actually use the product. By definition, this is always less than or equal to `RunRateMrrResult.mrr`. **When you'd see it:** `analytics.committedMrr`. *** ## CommittedMrrBreakdownRow ```typescript theme={null} interface CommittedMrrBreakdownRow { subscriptionId: string; customerId: string; agentId: string; planId: string; recurring: number; seatBased: number; usage: number; total: number; } ``` **In plain English:** per-subscription slice of committed MRR. Same shape as the run-rate breakdown, but the `usage` column is `minimum_commitment × rate` (or `0` when the pricing strategy has no minimum set), not actual usage. *** ## SubscriptionDetailWithRevenue Returned by `subscriptions.getWithRevenue`. ```typescript theme={null} interface SubscriptionDetailWithRevenue { subscription: SubscriptionDetail; revenue: RevenueMetrics; } ``` **In plain English:** a wrapper that pairs the normal subscription record with a canonical revenue block scoped to that subscription for a date window. Lets you build a subscription detail page in one round trip. **When you'd see it:** `subscriptions.getWithRevenue`. *** ## CustomerDetailWithRevenue Returned by `customers.getWithRevenue`. ```typescript theme={null} interface CustomerDetailWithRevenue { customer: Customer; revenue: RevenueMetrics; } ``` **In plain English:** a wrapper that pairs the normal customer record with a canonical revenue block scoped to that customer for a date window. Lets you build a customer detail page in one round trip. **When you'd see it:** `customers.getWithRevenue`. *** ## UsageRecordResponse Returned by `usage.record` and `usage.recordBatch`. Since SDK 0.19.0 it tells the delivery truth: a record appears in `results.success` (and counts toward `successful`) only when the server confirmed it was stored, and the top-level fields say whether the server was reached at all. ```typescript theme={null} interface UsageRecordResponse { processed: number; successful: number; failed: number; delivered?: boolean; buffered?: number; results: { success: UsageRecordSuccess[]; failed: UsageRecordFailure[]; }; } ``` **In plain English:** `delivered` is `true` only when a server response was actually received; `false` means the SDK swallowed a transport failure or a validation drop (fire-and-forget mode). `buffered` is how many records the local retry buffer actually accepted after a transport failure: `0` on validation drops (invalid records are never buffered), absent on delivered responses. If `buffered > 0` the SDK will retry those records itself; re-sending them from your code would double-bill. **When you'd see it:** every `usage.record` / `usage.recordBatch` call. *** ## UsageRecordFailure One row of `UsageRecordResponse.results.failed`. ```typescript theme={null} interface UsageRecordFailure { record: UsageRecord; error: string; code?: string; stored?: boolean; eventId?: string; rawEventId?: string | null; created?: { customer: boolean; agent: boolean; signal: boolean }; } ``` **In plain English:** `code` is the server failure code (`NEEDS_COST_BACKFILL`, `MISSING_VOLUME_DATA`, `INTERNAL_ERROR`); it's absent when the record never reached the server. `stored` is the retry oracle: `false` means not persisted (including transport failures) and safe to retry; `true` means persisted server-side despite the failure, so do NOT re-send it; absent means the server didn't say, and the retry policy treats it as persisted. `eventId` and `rawEventId` are the correlation handles to quote when you contact support; `rawEventId` is `null` when the raw row itself couldn't be created. `created` says which records this event brought into being. Rows in `results.success` carry the same field. A stored failure still creates its customer, agent, and signal, so it reports them here. The field is absent (not `false`) on an idempotent replay, on a transport failure, and on older servers, so branch with `created?.customer` rather than comparing against `false`. Reviving a record you deleted in the dashboard reports `false`, because the record already existed. Full rules in [Knowing what an event created](/sdk/tracking-events#knowing-what-an-event-created). **When you'd see it:** any partial failure from `usage.record` / `usage.recordBatch`. *** ## CreditPoolInput The three-number credit-pool shortcut. Accepted by `pricingStrategies.createCreditPool` (and inside `CreatePricingStrategyData` / `UpdatePricingStrategyData` as the `creditPool` field), and returned derived on every `PricingStrategy` as `creditPool`. ```typescript theme={null} interface CreditPoolInput { poolSize: number; poolPrice: number; overageRate: number; } ``` **In plain English:** the three numbers a human actually thinks in. `poolSize` is how many units the pool covers each billing cycle, `poolPrice` is the flat fee charged every cycle whether or not it gets used, and `overageRate` is the per-unit price beyond the pool. Send this instead of hand-building `tiers` and the server compiles the canonical two-tier shape. Worth knowing: in the raw `tiers` array a credit pool's FIRST tier rate is a flat fee for the whole pool while the second is per-unit. That asymmetry is easy to get backwards, which is why this type exists. On reads, `PricingStrategy.creditPool` is this same shape derived server-side: `null` on every non-pool strategy, and on any pool whose stored tiers aren't a clean two-tier shape (read `tiers` directly in that case). **When you'd see it:** `pricingStrategies.createCreditPool`, strategy create/update params, and every returned `PricingStrategy`. *** ## creditRates Which signals draw from a credit pool, and how many credits one unit of each costs. Accepted on `CreatePricingStrategyData` and `UpdatePricingStrategyData`, and returned on `PricingStrategy`. ```typescript theme={null} creditRates?: Record | null; ``` **In plain English:** a map of signal ID to credits per unit, like `{ reportSignalId: 4, noteSignalId: 0.5 }`. Without it a pool counts only its own signal, one credit per unit, which is what every existing pool does. With it, several signals share one balance at different costs. The pool's own signal is a member whether or not you name it, at rate 1. Every value must be above zero: to make a metric free, leave it out of the map. Send `{}` to clear every rate; omit the field and the stored rates stay put. `null` or absent on reads means a single-metric pool, and on every non-pool strategy. **When you'd see it:** strategy create/update params on a `credit_pool` strategy, and every returned `PricingStrategy`. *** ## CreditBalance One credit-pool subscription's countdown. Returned by `creditBalances.get`, and in the `balances` array of `creditBalances.list`. ```typescript theme={null} interface CreditBalance { subscriptionId: string; subscriptionName: string; customerId: string; customerName: string | null; customerExternalId: string | null; agentId: string; planId: string; planName: string | null; poolSizeUnits: number; consumedUnits: number; remainingUnits: number; overageInProgressUnits: number; remainingPercent: number; overageRatePerUnit: number | null; periodStart: string | null; periodEnd: string | null; alertsPaused: boolean; noActivePeriod: boolean; implicitFullPool: boolean; } ``` **In plain English:** two of these numbers are deliberately different and both are true. `remainingUnits` counts manual top-ups; `overageInProgressUnits` is the invoice's own math (usage past the pool size, top-up blind). A topped-up pool can show units left AND overage at the same time; the invoice always matches `overageInProgressUnits`. `remainingUnits` goes negative once the pool is overdrawn, and nothing stops at zero: the pool is a meter, not a breaker. `noActivePeriod: true` (with `periodStart`/`periodEnd` nulled) means there's no period counting down right now: the subscription never got a billing period, or the clock sits outside the stored window, like the gap between one period ending and the next day's re-mint. `implicitFullPool: true` means no drawable grant exists for the current period slot (it can accompany `noActivePeriod`); the pool reads FULL, because a gap between two mint points is an untouched pool, not a drained one. **When you'd see it:** `creditBalances.list` and `creditBalances.get`. See the [Credit Balances API reference](/api-reference/credit-balances) for the endpoint-level detail. *** ## CreditBalanceListParams / CreditBalanceListResponse ```typescript theme={null} interface CreditBalanceListParams { customerId?: string; agentId?: string; belowPercent?: number; } interface CreditBalanceListResponse { balances: CreditBalance[]; asOf: string; } ``` **In plain English:** the list filters. `belowPercent` keeps only pools with that percent of the pool (or less) still left; `20` is the near-empty cut the dashboard uses, `0` narrows to pools at or past zero. `asOf` timestamps the computation, and the list comes back emptiest first. *** ## Parameter types (for completeness) All canonical analytics methods accept typed parameter objects. These are exported so you can use them in your own function signatures. ### RevenueMetricsParams ```typescript theme={null} interface RevenueMetricsParams { startDate: string; endDate: string; customerId?: string; agentId?: string; signalId?: string; subscriptionId?: string; } ``` ### CostMetricsParams ```typescript theme={null} interface CostMetricsParams { startDate: string; endDate: string; customerId?: string; agentId?: string; signalId?: string; subscriptionId?: string; includePriorWindow?: boolean; } ``` ### InvoiceTotalsParams ```typescript theme={null} interface InvoiceTotalsParams { startDate: string; endDate: string; customerId?: string; } ``` Invoice totals accept a narrower filter surface than revenue (only `customerId`). That's intentional: invoice math runs at the customer scope, not per-subscription or per-signal. ### MrrParams ```typescript theme={null} interface MrrParams { customerId?: string; subscriptionId?: string; } ``` MRR methods do not take a date window. `analytics.mrr` uses a fixed "last complete calendar month" window. `analytics.runRateMrr` uses a fixed "last 30 days" window. `analytics.committedMrr` is not window-dependent at all. ### AgentEarnedParams ```typescript theme={null} interface AgentEarnedParams { startDate: string; endDate: string; customerId?: string; agentId?: string; signalId?: string; subscriptionId?: string; } ``` *** ## Rounding and precision Every money field in every type above is a raw `number`. No pre-rounding, no string wrapping. Round exactly once, at the UI boundary: ```typescript theme={null} const metrics = await mf.analytics.revenue({ ... }); // Right display(metrics.revenue.toFixed(2)); // Wrong: compounds rounding errors across downstream math const rounded = Number(metrics.revenue.toFixed(2)); const later = rounded * someMultiplier; ``` *** ## Next steps * **[Analytics](/sdk/analytics)**: the seven methods that return these shapes * **[Subscriptions](/sdk/subscriptions)**: where `SubscriptionDetailWithRevenue` comes from * **[Customers](/sdk/customers)**: where `CustomerDetailWithRevenue` comes from # Track Claude Code & Codex Spend Source: https://docs.marginfront.com/tools/code-cost-clarity See what your team spends on Claude Code and Codex, per developer and per model, inside MarginFront # Track Claude Code & Codex Spend `@marginfront/code-cost-clarity` is a small command-line tool. One command wires your coding-agent usage telemetry (from Claude Code, Codex, or both) through a local collector and into MarginFront, priced per developer, per model, with accurate prompt-cache token splitting. **This is a guardrail, not a hard limit. It tracks spend and can pause work on machines where it's installed. It can't stop charges at Anthropic or OpenAI, and it won't catch a machine that never installed it. For a hard ceiling, set a spend limit in your Anthropic Console or OpenAI account. You're responsible for your actual provider charges.** Want a hard ceiling? See [Set a hard spend ceiling for Codex at OpenAI](/tools/openai-hard-ceiling). **By default this is internal cost visibility, not billing.** Your company watches its own AI coding spend (per developer, per model). It doesn't charge developers. Out of the box it doesn't cut anyone off either: turning on spend caps is a separate, opt-in step (see [Spend Control](#spend-control-opt-in-caps) below). **This tool never reads, needs, or transmits your Anthropic or OpenAI API key. The only credential it uses is your MarginFront key, and only to send usage to MarginFront.** > **Beta (pilot software).** Code Cost Clarity is in active development. Pin a version, expect rough edges, and report issues. > **On Windows? Start with [Code Cost Clarity on Windows](/tools/code-cost-clarity-windows).** This page describes the macOS setup. Windows takes the same commands and gets the same background meter that starts at every sign-in, but a few details differ, including the desktop apps, which aren't metered there yet. The Windows page has all of them. > **Use `npx`, not `npm install`.** This is a CLI tool, not a library. Installing it into a project pulls in its dependencies and can surface unrelated audit warnings. Run it with `npx @marginfront/code-cost-clarity@latest init` (the `@latest` skips a stale npx cache). The `npm i ...` box on npmjs.com is npm's auto-generated default for every package and is not the intended usage here. *** ## What this does (one sentence) Every time a developer runs Claude Code or Codex, this tool captures the token usage and sends it to MarginFront as a usage event, so you can see who used what, on which model, and how much it cost. *** ## The mental model (read this first) Think of it like a cash-register receipt system: 1. **Claude Code (and Codex)** is the register. As it works, it broadcasts receipts: how many tokens, which model, which developer. 2. **The collector** (`otelcol-contrib`, open source) is the catcher. It runs in the background, catches the receipts, and writes them to a file. 3. **The forwarder** (the glue inside this tool) reads those receipts and sends each one to MarginFront. 4. **MarginFront** records the event, prices it, and shows it under the developer's email. ``` Claude Code (your task) | broadcasts usage on a timer (you choose the interval) + once at session end v Collector (otelcol-contrib, local) --- converts cumulative->delta so nothing double-counts | writes usage lines to a local file v Forwarder (this tool, `run`) | POST /v1/sdk/usage/record v MarginFront (cost dashboard) ``` Codex feeds the **same** catcher, so the collector and forwarder don't change. Turning Codex on is one config block. See [Also capture Codex](#also-capture-codex) below. **How often does it report?** It is timer-based, not per-message. Claude Code flushes usage on the export interval (`OTEL_METRIC_EXPORT_INTERVAL`) plus a final flush when the session ends. It ships at **5 minutes** by default (batched, so you get roughly one event per turn). Lower it (60 seconds or 5 seconds) for a more live drip, or raise it (10 minutes) to batch harder. The collector keeps repeated reports from double-counting no matter the interval. *** ## Quick start (macOS) On Windows the commands are the same, but a few details around them differ. Follow [Code Cost Clarity on Windows](/tools/code-cost-clarity-windows) instead. **Get your MarginFront key first.** Log in at app.marginfront.com, then go to Build -> API keys -> Create key pair and copy the secret key (`mf_sk_...`). `init` will ask you to paste it. ```bash theme={null} # One command. It asks you to paste your MarginFront secret key (mf_sk_...), # wires telemetry into Claude Code and Codex, downloads the collector (~360 MB, # one time), and starts a background meter that restarts itself at every login. # Use the secret key (mf_sk_...); a publishable key (mf_pk_...) is rejected. # Add --no-prompt on a server or in a script. npx @marginfront/code-cost-clarity init ``` Then **just code**: ```bash theme={null} claude # or: codex (or open the Claude/Codex desktop apps) ``` That's it. No `source`, no second terminal. Your spend shows up in MarginFront under your developer email, automatically, and the background meter restarts itself at every login. Windows gets the same background meter; inside a WSL distro there's none, so you keep `run` open instead. > **Already running Claude or Codex? Quit and reopen them after `init`** (and start a fresh terminal session). The desktop apps and each terminal session read the telemetry config when they launch, so anything that was already open won't emit until it's reopened. > **There is no `code-cost-clarity` command on its own.** Installing it does not add a new command to your computer, so typing `code-cost-clarity status` by itself says "command not found" and nothing is wrong. Every command runs through `npx`, like the ones on this page. Check the meter anytime: ```bash theme={null} npx @marginfront/code-cost-clarity status ``` **Prefer a live terminal view** instead of the background meter? After `init`, run `npx @marginfront/code-cost-clarity run` to stream your spend in the terminal (Ctrl-C stops it, and shuts its collector down too). You'll see a line per turn like: ``` [14:22:07] recorded developer@example.com · in=3210 out=287 · server=$0.0232 · cc=$0.0236 event=9f0c2a71-... ``` Also use Codex? See [Also capture Codex](#also-capture-codex). It's one config block and the same automatic capture. *** ## What gets captured, and what doesn't This meters the coding agents: it reads the `claude_code.*` and `codex.*` telemetry that Claude Code and Codex emit to the local collector. `init` wires telemetry into **both** places these agents look, so it captures them in the terminal **and** the desktop apps: * **Terminal / CLI** (`claude`, `codex`) and local IDE coding sessions read the config from `~/.claude/settings.json` and `~/.codex/config.toml`. * **The Claude Code and Codex desktop apps** don't read that config block, and a Dock-launched app never sees your shell, so on macOS `init` also writes the same vars into the launchd GUI-session environment, which Dock-launched apps inherit. Quit and reopen a desktop app after `init` so it reads them at launch. The desktop apps aren't covered on Windows yet; run your coding agent from a terminal there. It also labels each MCP tool call with its server and tool name, so a paid MCP tool (a web-search API, an enrichment service) can be priced apart from a free one. Only those two names ever leave your machine; the tool never sends the arguments a tool call was made with. It does **not** capture: * **The Claude consumer chat app** (the chat window). That's the general assistant, not Claude Code, so it never emits the coding-agent telemetry this reads. * **Anything in a cloud sandbox or not connected to your machine**: a cloud session, or a remote / devcontainer / SSH setup where the agent can't reach the local collector at `127.0.0.1:4318`. If a surface doesn't run on your machine and reach the local collector, this can't see it. *** ## The commands The background meter below is a launchd job on macOS and a Task Scheduler logon task on Windows. Inside a WSL distro there's no background meter, so `init` finishes cleanly without installing one, and `start`, `stop`, and `status` have no meter to act on there. `run`, `preview`, and `uninstall` behave the same everywhere. \| Command | What it does | \| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- --------------------------------------------------------------------------------------------------------------------------------------------------------------| \| `init` | Creates `~/.marginfront-ccc/`, writes your settings and the collector config, downloads the collector binary, and **installs a background meter that starts at login** (a launchd job on macOS, a Task Scheduler logon task on Windows). Wires telemetry into Claude Code and Codex, including the **desktop apps** on macOS (via the launchd GUI-session env). On a normal terminal it also **asks you to paste your MarginFront secret key (`mf_sk_...`), checks it with MarginFront before saving, and saves it for you** (nothing to hand-edit). A key the server rejects (wrong, revoked, or a publishable `mf_pk_...` key) gets a plain no and a fresh prompt instead of being saved. If your team shares one Claude/Codex login, it also asks which developer this machine's cost belongs to (see [Sharing one login across the team](#sharing-one-login-across-the-team)). Safe to re-run: it keeps a working saved key and never overwrites it, but if that saved key has stopped working it re-checks and asks you for a fresh one instead of skipping past the one thing that needs fixing. Inside a WSL distro it also finishes cleanly (there's no background meter to install there); see the [Windows page](/tools/code-cost-clarity-windows). Add `--no-prompt` on a server or in a script. Add `--control` to also turn on spend caps (see [Spend Control](#spend-control-opt-in-caps)). | \| `status` | Shows whether the background meter is running, plus recent activity. If your key has stopped working, it says so plainly: a red Key line with when sending stopped, a note that the meter is still recording but not sending, and a count of the turns waiting in the queue to go out. | \| `start` | Starts (or reinstalls) the background meter. `init` does this for you; use it to bring the meter back after `stop`. | \| `preview ` | Prints the exact record it would send for one captured snapshot (Claude Code or Codex). Needs no MarginFront API key, so it's great for a dry run. | \| `run` | **Optional live terminal view.** Starts the collector in the foreground and streams your spend to the terminal until Ctrl-C (Claude Code, Codex, or both, no extra flag). The background meter from `init` already does this without a terminal; use `run` when you want to watch it live. Add `--fold-cache` only for an unpriced model (see below). | \| `stop` | Pauses the background meter (and any foreground `run`). `start` brings it back. Inside a WSL distro, where there's no background meter, `stop` still ends a running foreground forwarder and clears a stale pid file, which is exactly what `run` tells you to do when it reports one already running. On Windows, `stop` shuts the collector down hard rather than asking it to wrap up first, so usage from the seconds right before it can go unsent. | \| `uninstall` | Stops everything (the background meter and the collector), reverts the telemetry config it wrote (Claude `settings.json`, the Codex `[otel]` block, the spend-cap hook and check script if you turned Control on, and the desktop GUI-session env), and deletes the collector binary and runtime files (reclaims the \~360 MB). Keeps your settings. Add `--purge` to also delete your settings and MarginFront API key. | \| `help`, `version` | The usual. | *** ## Spend Control (opt-in caps) By default this tool only **watches** spend. Turn on Spend Control and it can **stop** a Claude Code or Codex tool call when your team's admin-set cap is reached. **What it does.** Your admin sets a spend cap in MarginFront (for example, "\$200 per developer per week"), on the dashboard's Internal AI Spend page or through the [Spend Controls API](/api-reference/spend-controls). With Control on, this tool installs a tiny check that Claude Code and Codex both run before every tool call. If the cap is reached, the tool call is stopped with a message that names the cap and tells you to contact your admin for a raise. It runs on the same spend numbers you already meter; nothing new leaves your machine. **Turn it on:** ```bash theme={null} npx @marginfront/code-cost-clarity init --control ``` You'll see a consent prompt that spells out the change (Control converts this from a meter that watches you into one that can stop you). Say `y` to arm it. For Claude Code, it arms on your **next** `claude` session (or a `/hooks` reload). For Codex there's one extra step, and only you can do it: open codex, run `/hooks`, and approve the MarginFront cap check. Codex skips hooks nobody has reviewed, so nothing changes there until you do. After arming, cap changes from your admin apply even to sessions already running. **Turn it off:** ```bash theme={null} npx @marginfront/code-cost-clarity uninstall ``` That removes the cap hook and the check script completely, along with the rest of the setup. **Honest limits (read these):** * **It fails open.** A missing, unreadable, or out-of-date cap file never blocks you. A broken cap should never brick your machine, so it doesn't. * **It stops tool calls, not tokens.** The prompt that proposed a blocked call is already billed. What the cap kills is unbounded agentic work: the runaway sessions that produce surprise bills. * **Caps in track mode never block.** They only watch and send alert emails. Only a cap in **enforce** mode that covers **all** AI tools stops anything on your machine; a cap scoped to a single provider is charted but doesn't block. * **Per-developer caps need to know who the developer is.** A whole-team cap enforces on every machine. A cap aimed at one developer enforces only on machines where `CCC_DEVELOPER_EMAIL` names that developer (see [Sharing one login across the team](#sharing-one-login-across-the-team)). The tool never guesses whose machine this is, because guessing wrong would block the wrong person. * **It pauses Claude Code and Codex.** Gemini and xAI spend still counts toward every cap, but the device cap doesn't pause those tools. * **Hosted tools are never paused.** A tool that runs on the provider's servers (like built-in web search) never takes the local tool path, so the check never sees it. * **An unapproved Codex hook is inert.** Until you approve it in `/hooks`, Codex silently skips it. `ccc status` tells you which state you're in, and it says "armed" only when the cap can actually stop something. * **"Your cap" vs. "the model refused."** If the model itself declines a request (its own safety layer), that isn't this cap. The block message says so, so you can tell the two apart. * Requires `python3` on macOS and Linux (standard on both). Windows doesn't need it: the check runs on Node, which the tool already needs. *** ## Per-developer attribution is automatic What makes "who spent what" work is the developer's email, and **Claude Code puts it in the telemetry on its own.** It's the developer's logged-in Claude account email. There is no manual email setup. Each developer just loads the telemetry settings and runs Claude Code normally. **Works whether the developer signs in with an org-managed seat or an interactive login.** On org-managed Claude seats the email is stamped for free. If a developer's sign-in doesn't surface an email, the tool doesn't drop the usage. It attributes it to a clearly labeled placeholder customer (`claude-code-no-identity`) and prints how to fix it (sign in with an org-managed seat, or attach a customer mapping). You'll see the placeholder in `preview` or `run` output if it ever kicks in. Codex attributes the same way, with one wrinkle: whether your email shows up depends on how you sign in to Codex (an org-managed seat or an OpenAI API-key sign-in stamps it; some interactive ChatGPT logins may not). That sign-in is between you and OpenAI; this tool never sees your OpenAI key. See [Also capture Codex](#also-capture-codex). > Your MarginFront **API key** is separate from the developer's identity: it's the tool's own credential for posting to MarginFront. `run` needs it; `preview` does not. *** ## Sharing one login across the team Some teams share **one** Claude or Codex login across the whole team. When that happens, every developer's telemetry carries the **same** email, so all the AI cost piles onto one person and per-developer attribution breaks. This tool fixes that without any change to how you sign in, because it runs locally on each laptop, so each laptop can carry its own developer identity. **During `init` it asks one question:** which developer this machine's AI cost belongs to. The question is pre-filled with your global git email (`git config --global user.email`), which is usually the right person sitting at this laptop. You have three choices: * **Press Enter** to accept the pre-filled email. * **Type a different email** to attribute this machine to someone else. * **Clear it and leave it blank** to auto-detect from whatever email the coding-agent login reports. Whatever you choose is saved as `CCC_DEVELOPER_EMAIL` in `~/.marginfront-ccc/.env` (mode 600, right next to your key). To change it later, edit that one line and then `stop` and `start` (or re-run `init`). **Which email wins, highest first:** 1. `CCC_DEVELOPER_EMAIL` (this machine's developer). When set, it wins for **every** record this machine sends: Claude and Codex, token turns and tool calls. 2. Otherwise the email the coding-agent login reports. 3. Otherwise the no-identity placeholder (`claude-code-no-identity` or `codex-no-identity`). Leave it blank and nothing changes: attribution follows the coding-agent login's own email, then the placeholder. > This is internal cost visibility only. `CCC_DEVELOPER_EMAIL` re-labels which developer the cost shows up under in your MarginFront. It does **not** change who pays or any customer's bill. The `--no-prompt` install (server or script) skips this question and leaves `CCC_DEVELOPER_EMAIL` unset. *** ## Cache pricing Claude Code reports four kinds of tokens (fresh input, output, cache-read, and cache-creation), and Anthropic prices them differently. This tool **splits them into their correct typed fields** (`cacheReadTokens` / `cacheWriteTokens`) so MarginFront can price each at its own cache rate instead of lumping everything into one input number. **That token split is accurate by default.** * **Default (recommended).** Cache tokens go in the `cacheReadTokens` and `cacheWriteTokens` fields; MarginFront prices each at the model's catalog rate. * **`--fold-cache` (emergency fallback only):** for a model MarginFront can't price yet, this rolls the cache tokens into billed input at the fresh-input rate so the cost is never a silent \$0. Cache reads cost less than fresh input, so that slice of the bill rounds up. Cache writes cost more than fresh input, so that slice rounds down; on a GPT-5.6-family Codex model the cache-write portion can read noticeably low (see [Also capture Codex](#also-capture-codex)). Use it only when a model has no cache price; otherwise the default is more accurate. The dollar figure uses your MarginFront catalog's rate for each model. If a model isn't priced yet, its usage lands `NEEDS_COST_BACKFILL` (a **visible** gap, never a silent \$0), and one click in MarginFront prices it **both retroactively and going forward**. The raw cache numbers are also kept in `metadata`, and Claude Code's own cache-accurate cost is in `metadata.claudeCodeCostUsd` to reconcile against. ### Which cache-write rate you pay (one setup question) Anthropic bills **cache writes** at two different rates, and which one you pay depends on how Claude Code signs in on the machine: * **Claude subscription (Pro / Max / Team)**: 1-hour cache, billed at **2x input** * **API key / Bedrock / Vertex**: 5-minute cache, billed at **1.25x input** `init` figures this out for you. If the machine already sets `FORCE_PROMPT_CACHING_5M` or `ENABLE_PROMPT_CACHING_1H` (in the shell or in `~/.claude/settings.json`'s `env` block), the answer is detected and saved with no question. Otherwise `init` asks **one** question and saves the answer as `CCC_CACHE_WRITE_TTL` in `~/.marginfront-ccc/.env`. Every usage record then carries the answer in `metadata.cacheWriteTtl`, and MarginFront prices that record's cache writes at the matching rate. **If it's unset** (you skipped the question, answered "not sure", or installed with `--no-prompt`): MarginFront prices cache writes at the **1-hour ceiling (2x input)**. It can overstate, but it can never under-count. Edit `CCC_CACHE_WRITE_TTL` (values `5m` or `1h`) anytime if the machine's sign-in changes. > A long-context model id like `claude-opus-4-8[1m]` is normalized to `claude-opus-4.8` to match MarginFront's pricing table. The raw id is kept in `metadata.rawModel`. *** ## Billable tool calls Tokens are most of what a coding agent costs, but they aren't all of it. A paid web search or a metered MCP tool costs money every time it runs, so this tool sends tool calls as their own line items, separate from the token turns. **There's nothing to set up, and nothing to keep in a list.** Every tool your agents run is forwarded, and your MarginFront pricing catalog decides what each one is worth: * A tool with a price row is priced at that rate, times the number of calls. * A tool with no price row lands `NEEDS_COST_BACKFILL`, the same visible gap an unpriced model gets. You can price it later, and it's never a silent charge. * Free built-ins like file reads, shell, and grep are forwarded too, and the catalog prices them **\$0**. Your usage view shows everything the agent did without inventing a cost for it. **A tool call is identified by its name.** An MCP tool arrives as `mcp__server__tool`, so a paid MCP tool is priced apart from a free one sitting next to it. The arguments the tool was called with never leave your machine. See [Privacy](#privacy). **Repeated calls are counted, not listed one by one.** If the same tool runs five times inside one reporting window, you get one line item with a quantity of five. > A tool line item carries a count, never tokens. The tokens the model spent deciding to make the call are already on the turn record, so nothing is billed twice. This works the same on macOS and Windows. The one difference is what gets seen in the first place: on Windows the desktop apps aren't metered yet, so tool calls made there don't reach the meter. *** ## Also capture Codex The same tool captures **Codex** too, and there's nothing extra to install: Codex reports to the **same** local collector. You get three setups for free: Claude Code only, Codex only, or both (the same developer's email shows up across all of it). **Turn it on.** Add an `[otel]` block to your `~/.codex/config.toml`, pointing at the same collector: ```toml theme={null} [otel] exporter = { otlp-http = { endpoint = "http://127.0.0.1:4318/v1/logs", protocol = "binary" } } ``` The `exporter` is a **table**, with the endpoint nested inside it and ending in `/v1/logs`. (A flat `exporter = "otlp-http"` with a bare top-level endpoint silently exports nothing. Verified.) Then run Codex normally. `run` already watches both sources from the one collector file, so there's no extra flag. `init` prints this same block at the end, so you have it handy. ### Why Codex's numbers are counted differently Anthropic and OpenAI count tokens differently, and getting this wrong silently mis-charges you. For **Codex**, some of the token counts are **already inside** the others: * The cached tokens are **already part of** the input count. * The cache-write tokens are **already part of** the input count too. * The reasoning tokens are **already part of** the output count. Picture the input count as one pie cut into three slices: tokens read back from the cache, tokens written into the cache, and fresh tokens that are neither. The three slices add up to the input count, so billing the input count *and* a cache slice charges that slice twice. So this tool does **not** add reasoning on top of output, and does **not** bill the input count on top of the cache slices sitting inside it. The accurate split it sends is: | MarginFront field | From Codex | Why | | ------------------ | ---------------------------------------------- | ------------------------------------------------------------------------------ | | `inputTokens` | input count - cached count - cache-write count | the fresh input only, with both cache slices taken out | | `cacheReadTokens` | cached count | priced at the cheaper cache-read rate | | `cacheWriteTokens` | cache-write count | priced at the cache-write rate, which for OpenAI is 1.25x the input rate | | `outputTokens` | output count | reasoning is already inside this, billed at the output rate as OpenAI bills it | **Not every setup reports a cache-write count.** You need a recent Codex build and a model in the GPT-5.6 family. Older Codex builds leave the field out, and models before GPT-5.6 report it as zero. Both read as no cache write. `cacheWriteTokens` is sent **only when the count is above zero**, so those setups send no cache-write field at all and never get flagged for missing a rate they don't use. The raw reasoning, tool, cached, and cache-write counts are all still recorded in `metadata` for your own audit. They're never billed twice. > **`--fold-cache` cuts both ways on Codex.** It bills the whole input count at the plain input rate and sends neither cache field. On the cache-**read** slice that rounds the bill **up**, because a read costs less than fresh input. On the cache-**write** slice it rounds the bill **down**, because a write costs *more* than fresh input, about 1.25x. So on a GPT-5.6-family model, `--fold-cache` can read roughly 20% low on the write slice. Reach for it only when a model has no cache rates in your catalog yet. The default split is the accurate one. ### Whether your email shows up depends on how you sign in Per-developer attribution rides on the developer's email from Codex's telemetry, the same idea as Claude Code. Whether that email surfaces depends on how you sign in to Codex: an org-managed seat or an OpenAI API-key sign-in stamps it; some interactive ChatGPT logins may not. (That sign-in is between you and OpenAI; this tool never sees your OpenAI key.) If it doesn't surface, the usage is **not** dropped. It lands under the `codex-no-identity` placeholder with a fix hint, exactly like the Claude Code path. ### Good to know * **A `gpt-5-codex` pricing row must exist first.** A full-rate row has to be in MarginFront's pricing catalog, and on a GPT-5.6-family model that row needs **both** cache rates, read and write. If a turn reports cache-write tokens and the row has no cache-write rate, that event lands as `NEEDS_COST_BACKFILL` (a visible gap, never a silent `$0`). Adding the missing rate is a one-time catalog step. * **`codex-auto-review` is skipped.** It's an internal pseudo-model, not a real billable model, so the tool drops it rather than mis-price it. * **`codex` and `codex exec` both export the usage logs this tool reads** (verified against a live session). Some Codex versions don't emit OTel *metrics*, and `codex mcp-server` telemetry has had bugs, but neither is the *logs* stream this tool uses, so neither affects capture here. * **The `[otel]` block above is the verified shape** (exporter as a table, endpoint nested with `/v1/logs`). If a future Codex version changes it, keep the exporter pointed at the local collector on `/v1/logs`. > **One thing to confirm:** check the exact Codex dollar figure against one real captured session, both that Codex reports per-turn (not running-total) token counts, and that your developer email shows up under your sign-in mode. The mapping above is the verified-safe default; the confirmation is a quick one-session check, not a reason to wait before installing. *** ## Confirm it landed (independent read-back) Pull the most recent events for one developer straight from the API: This one is a plain API call, so it runs anywhere, including Windows PowerShell. ```bash Bash theme={null} KEY="mf_sk_your_key_here" curl -s -H "x-api-key: $KEY" \ "https://api.marginfront.com/v1/events?customerExternalId=you@example.com&limit=5&sortBy=usageDate&sortOrder=desc" ``` ```powershell PowerShell theme={null} $env:KEY = "mf_sk_your_key_here" curl.exe -s -H "x-api-key: $env:KEY" ` "https://api.marginfront.com/v1/events?customerExternalId=you@example.com&limit=5&sortBy=usageDate&sortOrder=desc" ``` *** ## Maintain / shut off * **Collector version is pinned for safety.** The install downloads one known-good collector release and verifies its fingerprint before it ever runs. Moving to a newer collector ships in a package update (so the integrity check always has a fingerprint to verify against). There is no useful per-run version override. * **Stop temporarily:** `stop` pauses the background meter; `start` (or re-running `init`) brings it back. `status` shows whether it's running. * **Remove it:** `uninstall` frees the \~360 MB, stops everything, and reverts the telemetry config it wrote (Claude `settings.json`, the Codex `[otel]` block, and the desktop GUI-session env). It keeps your key; `uninstall --purge` deletes your settings too. *** ## Troubleshooting * **"No MARGINFRONT\_API\_KEY found":** paste your MarginFront key into `~/.marginfront-ccc/.env`, or `export MARGINFRONT_API_KEY=...` in the shell you start `run` from. That shell is a macOS terminal, PowerShell on Windows (`$env:MARGINFRONT_API_KEY = "..."`), or your WSL distro's prompt. `preview` works without a key. * **HTTP 401 / 403:** wrong or expired key, or you pasted a **publishable** key (`mf_pk_*`) where a **secret** key (`mf_sk_*`) is required. If your key stops working while the meter is running, your usage is **not** thrown away: the meter keeps recording, parks the outgoing turns in an on-disk queue, and re-checks the key every few minutes, sending everything it parked the moment the key works again. Saving a fresh secret key (through `init`, or by editing `.env` and running `start`) lifts the hold right away. Pull a fresh secret key from your MarginFront dashboard under **Build → API keys**. * **HTTP 422 / validation error:** body-shape mismatch. Run `preview ` and compare the record. * **Collector file stays empty:** make sure `init` finished and the background meter is running (`status`), and that you **opened a fresh Claude/Codex session after `init`** (an already-open app or shell won't emit until reopened). If it's still empty, re-run `init` to rewrite the collector config: it defaults to http/protobuf/4318, the transport that works with Claude Code, and gRPC/4317 silently exports nothing. * **`usageCost` is null:** the normalized model id didn't match the pricing table. Tokens are still recorded; the cache-accurate cost is in `metadata.claudeCodeCostUsd`. * **Numbers ballooning:** the collector's delta conversion isn't running. Re-run `init` to rewrite the collector config. * **Seeing `claude-code-no-identity` (or `codex-no-identity`)?** Your sign-in didn't surface an email. Use an org-managed Claude seat, or attach a customer mapping. (Codex uses the `codex-no-identity` placeholder when a ChatGPT login doesn't surface an email.) * **Codex spend not showing up?** Confirm the `[otel]` block is in your user-level `~/.codex/config.toml` (not a project config), the endpoint is `http://127.0.0.1:4318`, and `run` is going. Some Codex versions/subcommands have telemetry bugs, so check your version. * **"Collector integrity check FAILED" or "Refusing to run an unverified collector":** the collector that downloaded didn't match the fingerprint the tool trusts, so it was stopped before it ran. The most common cause is a corporate network that inspects and rewrites downloads (a TLS-inspecting proxy), or a corrupted transfer. Re-run `init` on a network without that kind of proxy. Don't try to bypass the check: a download that keeps failing the fingerprint on a clean network is exactly the case the check exists to stop. The "unverified" wording instead means you pointed `CCC_OTELCOL_VERSION` at a release the tool has no fingerprint for. Unset `CCC_OTELCOL_VERSION` to use the built-in pinned version. *** ## Privacy This tool watches your coding agents, so here's exactly what leaves your machine and what stays on it. ### What leaves your machine Each usage record carries: * **The developer's email, in plain text, on purpose.** Per-developer cost has to know who ran the turn, so the email isn't hashed or scrambled. If your sign-in doesn't surface one, the usage isn't dropped. It lands under a clearly labeled placeholder instead (`claude-code-no-identity`, or `codex-no-identity` for Codex). * **The model name, the token counts, and a session id.** * **The name of each tool call.** For an MCP tool, that's the server name and the tool name. * **Your git repo, branch, and commit**, when they can be worked out, so you can see which project the spend came from. A repo that lives only on your own disk sends nothing here, because there's no owner and repo name to send. ### What never leaves your machine * **What you typed, and what the agent wrote back.** Prompts, replies, and session transcripts all stay on your machine. * **Your code and your file contents.** * **The arguments a tool was called with.** Claude Code writes those into the telemetry file on your machine, and the meter reads straight past them. Only the two names are sent. * **File paths and shell commands.** * **Your Anthropic or OpenAI key.** This tool never reads it, needs it, or sends it. ### Where your MarginFront key lives Your MarginFront secret key is saved in one file, `~/.marginfront-ccc/.env`. It's never written into `~/.claude/settings.json`, never into `~/.codex/config.toml`, and never into a usage record. The only place it goes is the header on the request to MarginFront, which is how MarginFront knows the usage is yours. That file is locked to you: * **macOS and Linux:** the file is owner-only (mode 600). * **Windows:** `init` rebuilds the file's access list so it holds one entry, your own Windows account, and turns off inheritance. Other accounts on the PC can't open it. If Windows refuses, `init` tells you and prints the three clicks in File Explorer that fix it. Your key is saved either way, so don't skip that message. ### Reporting turns on for the whole machine Saying yes at the consent prompt turns usage reporting on for **every** Claude session on this computer, not only the terminal you set it up in. That's what lets you start coding without a second terminal, and it's a real, lasting change, which is why `init` lists everything it touches and asks first. * **In a terminal**, `init` asks, and you answer. * **With `--no-prompt`** (a server or a script), it goes ahead without asking and still prints the same list. * **Piped, with no terminal and no `--no-prompt`**, it answers **no** for you and wires nothing up. It does create `~/.marginfront-ccc` and download the collector before it asks, so a declined run leaves that one folder behind and nothing else. On macOS this covers the desktop apps as well as the terminal. On Windows the desktop apps aren't metered yet, so what you do in them stays outside the meter entirely. Two more things: * `~/.codex/config.toml` is only touched when Codex is on the machine. If you already have your own `[otel]` settings, `init` leaves the file alone and prints the block for you to paste. * `init` never overwrites a telemetry value you'd already set for yourself. It backs the file up before it changes anything. ### Turning it all back off `uninstall` reverses every change: * It removes the background meter and the copied meter program. * It removes only the telemetry settings it added, and only where the value still matches what it wrote, so anything you changed yourself survives. * It removes only its own `[otel]` block from `~/.codex/config.toml`, and keeps any lines you or Codex put inside it. * It deletes the collector and the runtime files. It keeps your saved key unless you add `--purge`. It never edits your `~/.zshrc` on the way out: if an older setup left a line in there, `uninstall` points at it and leaves the decision to you. *** ## Security * This tool never reads, needs, or transmits your Anthropic or OpenAI API key. The only credential it touches is your MarginFront key, used only to POST usage to MarginFront. * Your MarginFront API key never lives in the package. It's saved only in `~/.marginfront-ccc/.env` (mode 600) on your machine. The forwarder reads it from the environment only, never hardcoded, never logged. * The published package contains only the built code and its README. Captured telemetry, the collector binary, and your `.env` are all kept off the machine that runs this tool and out of the package. * The collector this tool downloads is checked against a built-in **SHA-256 fingerprint** (pinned from OpenTelemetry's signed checksums) before it is ever made runnable. If the downloaded file doesn't match, it is deleted and the install stops, so a download that was tampered with or rewritten in transit never runs on your machine. *** ## For developers (technical appendix) **Input shape:** OTLP/JSON, `resourceMetrics[].scopeMetrics[].metrics[]`. Two metrics matter: `claude_code.token.usage` (one datapoint per `type` in input/output/cacheRead/cacheCreation) and `claude_code.cost.usage` (USD). **Grouping key:** `(user.email, model, session.id)` → one MarginFront record per group. **Token mapping:** `input`→`inputTokens`, `output`→`outputTokens`, `cacheRead`→`cacheReadTokens`, `cacheCreation`→`cacheWriteTokens` (Anthropic's `cache_creation_input_tokens`). With `--fold-cache`, cache tokens are added into `inputTokens` instead and the typed fields are omitted (no double count). **Temporality:** Claude Code emits cumulative counters. The collector converts them to deltas; the forwarder trusts each line is already an increment. **Ingest:** `POST https://api.marginfront.com/v1/sdk/usage/record`, headers `Content-Type: application/json` and `x-api-key: ` (not Bearer). Body envelope `{ records: [...] }`. The endpoint auto-creates the customer (by `customerExternalId`) and agent (by `agentCode`) on first POST, and resolves your org from the MarginFront API key (the body can't override it). ### Codex source (the second input shape) **Input shape:** OTLP/JSON **logs**, `resourceLogs[].scopeLogs[].logRecords[]` (a different tree than Claude Code's `resourceMetrics`). The usage event is named `codex.sse_event`; it's detected by that name (on the `event.name` attribute or the record body) or, failing that, by the presence of any token-count attribute. **Value encoding:** log attribute values arrive as `intValue` (a JSON string, per the protobuf int64 rule) or `doubleValue` (a JSON number) for token counts, and `stringValue` for identity fields. All three are handled. **Token fields:** `input_token_count`, `output_token_count`, `cached_token_count`, `cache_write_token_count`, `reasoning_token_count`, `tool_token_count`, plus `model`, `user.email`, and a session id (`conversation.id` / `session.id`). `cache_write_token_count` shows up only on recent Codex builds, and models before the GPT-5.6 family report it as zero; both cases read as no cache write. The accurate nested mapping is in the Codex section above. Cached and cache-write are both subtracted from input, and reasoning is never added to output. **Malformed turns:** if a turn's cache slices somehow add up to more than its own input count, the slices are trimmed to fit rather than billed past it, with cache-read keeping priority over cache-write. That record carries `metadata.cacheSlicesClamped: true`, and its billed cache tokens read **lower** than the raw counts on the same record. The gap is deliberate, and it errs toward under-billing. **Granularity:** one MarginFront record per `codex.sse_event` (one per turn). There's no cumulative-to-delta step on the logs pipeline (that's a metrics-only processor); each Codex log record is already one turn's usage. **Identity:** `agentCode: "codex"`, `signalName: "codex-turn"`, `modelProvider: "openai"`, `environment: "development"`. Records carry `metadata.source: "codex"`. **Routing:** both sources write one JSON document per line to the same collector file. The forwarder routes each line by which tree it has (`resourceMetrics` → Claude Code, `resourceLogs` → Codex), so one watcher handles either or both. # Code Cost Clarity on Windows Source: https://docs.marginfront.com/tools/code-cost-clarity-windows Install it, run it, and cap your Claude Code and Codex spend on a Windows PC # Code Cost Clarity on Windows Code Cost Clarity runs on Windows. You install it from PowerShell, it meters your Claude Code and Codex spend in the background, and it starts itself again every time you sign in. You don't need WSL. Setup is the same single command as macOS. This page covers what's different about Windows: what to install first, what the background meter is, where your key lives, and the limits worth knowing before you start. *** ## What you need first **Windows 11 or Windows 10.** **Node.js.** Check it in **PowerShell**: ```powershell theme={null} node -v ``` A version number means you're set. "not recognized" means you need Node.js from nodejs.org first. **Your MarginFront secret key.** Log in at app.marginfront.com, go to **Build → API keys**, and copy the secret key (`mf_sk_...`). `init` asks you to paste it. A publishable key (`mf_pk_...`) is rejected. *** ## Setup One command, in **PowerShell**: ```powershell theme={null} npx @marginfront/code-cost-clarity@latest init ``` It asks for your secret key, wires telemetry into Claude Code and Codex, downloads the collector (about 360 MB, one time), and registers the background meter that starts at every sign-in. Then just code: ```powershell theme={null} claude ``` or: ```powershell theme={null} codex ``` That's it. There's no second terminal to keep open and nothing to start by hand. Your spend shows up in MarginFront under your developer email. > **Already running Claude or Codex? Quit and reopen them after `init`**, and open a fresh PowerShell window. Each session reads the telemetry config when it starts, so anything already open won't report until you reopen it. > **Use `npx`, not `npm install`.** This is a command-line tool, not a library. The `@latest` skips a stale npx cache. Check the meter anytime: ```powershell theme={null} npx @marginfront/code-cost-clarity@latest status ``` *** ## The background meter `init` registers the meter with Windows Task Scheduler as a logon task under your own account. It starts when you sign in, and it starts again on its own if it ever stops unexpectedly. It needs no administrator rights, and it never flashes a console window at you. Want to see it for yourself? Open Task Scheduler and look for **ai.marginfront.ccc** at the top of the library. `stop` pauses the meter, `start` brings it back, and `uninstall` removes it along with the rest of the setup. Those commands mean the same thing here as they do on macOS. *** ## Spend caps work on Windows Caps are opt-in on Windows too, and the command is the one you already know: ```powershell theme={null} npx @marginfront/code-cost-clarity@latest init --control ``` You'll see a consent prompt that spells out the change, because Control turns this from a meter that watches you into one that can stop you. Say `y` to arm it. For Claude Code it arms on your next `claude` session. For Codex there's one extra step, and only you can do it: open codex, run `/hooks`, and approve the MarginFront cap check. There's nothing extra to install on Windows. The cap check runs on Node, which you already have. Everything under [Spend Control](/tools/code-cost-clarity#spend-control-opt-in-caps) applies here, including the honest limits about what a cap can and can't stop. *** ## Where your key lives `init` saves your key to `C:\Users\\.marginfront-ccc\.env` and locks that file to your Windows account, so other accounts on the PC can't read it. If Windows refuses that lock, `init` says so and prints the three clicks in File Explorer that fix it. Don't skip that message. Your key is saved either way, but until the file's locked, another account on that PC can open it. Rather keep nothing on disk? Delete the key line from that file and set `MARGINFRONT_API_KEY` as an environment variable instead. *** ## Checking it's working Reporting runs on a timer rather than per message, and the default interval is 5 minutes, so give it that long before deciding nothing happened. Then: ```powershell theme={null} npx @marginfront/code-cost-clarity@latest status ``` If your spend still isn't showing up, work through these in order: * **Did you reopen Claude or Codex after `init`?** Telemetry settings are read at launch. Quit the agent and start it again. * **Is the meter running?** `status` tells you. If it isn't, `npx @marginfront/code-cost-clarity@latest start` brings it back. * **Is your coding agent running on Windows itself?** An agent running inside a WSL distro is invisible to the Windows meter. See [Running inside WSL](#running-inside-wsl) below. * **Is the key saved?** Check that `C:\Users\\.marginfront-ccc\.env` has your `mf_sk_...` key on the `MARGINFRONT_API_KEY` line. *** ## Known limitations * **The desktop apps aren't metered on Windows yet.** Run Claude Code and Codex from a terminal there. What you do in the Claude and Codex desktop apps doesn't reach the meter. * **Nobody has watched a full sign-out and sign-in on a real desktop yet.** Our tests confirm that Windows stored the meter as a logon task set to start when you sign in, which is the setting that makes it come back. Watching it happen on a physical PC is a check we haven't done. So run `status` once after your next sign-in. If it says the meter isn't running, `start` puts it back in a second, and we'd like to hear about it. * **Stopping on purpose can drop the last moments of usage.** On Windows, `stop` shuts the collector down hard instead of asking it to wrap up first, so usage from the seconds right before you stopped can go unsent. This applies to `stop` and `uninstall` only. Ordinary coding isn't affected. *** ## Running inside WSL You need this section only if your coding agent runs inside a WSL distro. Running Claude Code or Codex on Windows itself is the simpler path, and it's the one the rest of this page describes. **A coding agent inside a distro is invisible to the Windows meter.** The distro has its own home directory and its own localhost, so it never sees the settings `init` wrote on the Windows side and never finds the collector listening there. If that's your setup, install the tool inside that same distro. ### You need a general-purpose distro **If you have Docker Desktop, you don't already have what you need.** Docker Desktop installs its own internal WSL distributions (`docker-desktop` and, on older versions, `docker-desktop-data`). Those are managed appliances for running Docker itself. They aren't general-purpose Linux environments, and this tool doesn't work inside them. This trips people up because `wsl -l -v` lists the Docker distros, so WSL looks installed and ready. Check what you actually have. Run this in **PowerShell**: ```powershell theme={null} wsl -l -v ``` If everything listed is a `docker-desktop*` entry, you have no general-purpose distro. Install one in **PowerShell**: ```powershell theme={null} wsl --install -d Ubuntu ``` Ubuntu coexists with Docker Desktop. Installing it doesn't disturb your Docker setup. ### Setup inside the distro Open the distro when it finishes installing. Every command below runs at the **Ubuntu prompt**, not in PowerShell. You need Node.js inside the distro as well. Check with `node -v` there and install it there if it's missing. Node on the Windows side doesn't count, because the distro is a separate environment with its own programs. ```bash theme={null} npx @marginfront/code-cost-clarity@latest init ``` `init` wires up what it can and then tells you the background meter didn't install. That's expected: there's no background meter inside a distro. Start the meter yourself instead, and leave it open: ```bash theme={null} npx @marginfront/code-cost-clarity@latest run ``` This holds the terminal. You'll see a line per turn as usage arrives: ``` [14:22:07] recorded developer@example.com · in=3210 out=287 · server=$0.0232 · cc=$0.0236 event=9f0c2a71-... ``` **Capture happens only while `run` is open.** Usage that happens while it's closed is lost, not buffered. There's no queue that catches up later. Start `run` in a dedicated terminal when you begin work, and leave it there. Start your coding agent from a second Ubuntu prompt, with `run` still going in the first: ```bash theme={null} claude ``` or: ```bash theme={null} codex ``` *** For the full command list, what gets captured, and Spend Control, see the [main Code Cost Clarity page](/tools/code-cost-clarity). # Set a Hard Spend Ceiling for Codex at OpenAI Source: https://docs.marginfront.com/tools/openai-hard-ceiling The real hard stops for Codex spend live at OpenAI. Which one you can set depends on how your Codex is billed. # Set a Hard Spend Ceiling for Codex at OpenAI MarginFront meters what your team spends on Codex and can pause work on machines where it's installed. That's a guardrail, not a wall. The stops nobody at your company can remove are the ones OpenAI runs on its own servers. This page shows you which of those you're allowed to set, based on how your company pays for Codex, and exactly where to set it. **OpenAI's stops fail closed: the server refuses the request. MarginFront's device cap fails open: it's a guardrail, never a wall.** > **Everything here was checked on 2026-07-28 and every claim carries its source link.** OpenAI changes these controls. Open the linked page yourself before you commit a budget to it. *** ## Fail closed vs fail open (read this first) Two kinds of limit. The difference is the whole point of this page. **Fail closed is a locked door.** The stop lives on OpenAI's servers. When the number is hit, OpenAI refuses the request. Nobody at your company can argue past it, because the thing saying no isn't on their laptop. If it breaks, it stays shut. **Fail open is a seatbelt.** MarginFront's device cap runs on the developer's own machine. When the cap is hit it pauses work and tells them to come find you. If the check can't read its data, if the machine is offline, if somebody uninstalls it, work keeps going. A broken guardrail should never brick a developer's day, so it doesn't. Which do you want? Both, for different jobs. * You want **fail closed** for the number you'd have to explain to your board. One ceiling at OpenAI, set once, that nothing gets past. * You want **fail open** for the day-to-day. Per-developer visibility, a weekly budget, and a nudge that stops a runaway session long before it becomes the board conversation. That's [Track Claude Code & Codex Spend](/tools/code-cost-clarity). Set the OpenAI ceiling high enough that hitting it is a genuine emergency. Run your real budget with the day-to-day guardrail underneath it. *** ## Find your billing mode first Every lever below depends on this one answer. Ask whoever pays the OpenAI bill. | How your team runs Codex | Your section | | ------------------------------------------------------------ | --------------------------------------------------------- | | Signed in with an OpenAI API key, billed on the API Platform | [API key billing](#api-key-billing) | | ChatGPT Enterprise or Edu workspace seats | [ChatGPT Enterprise and Edu](#chatgpt-enterprise-and-edu) | | ChatGPT Business workspace seats | [ChatGPT Business](#chatgpt-business) | | Personal ChatGPT Plus or Pro accounts | [ChatGPT Plus and Pro](#chatgpt-plus-and-pro) | A team can be in more than one mode at once (some developers on API keys, some on personal Plus seats). Each mode needs its own ceiling. A ceiling in one mode does nothing for the others. Free and Go personal seats include Codex too. For a ceiling they work like [ChatGPT Plus and Pro](#chatgpt-plus-and-pro): no admin lever. *Source: [Codex pricing](https://learn.chatgpt.com/codex/pricing), retrieved 2026-07-28: "ChatGPT Work and Codex are included in your ChatGPT Free, Go, Plus, Pro, Business, Edu, or Enterprise plan."* *** ## API key billing **This is the strongest lever OpenAI offers.** It's a real hard stop, and it's the only mode with a documented way to set that stop from code. **What it does.** You set a monthly spend limit on your organization, on an individual project, or both. Turn on **Enforce a hard limit** and OpenAI stops serving traffic at that number: "When tracked spend reaches an applicable hard limit, affected API requests return a `429` error with the `insufficient_quota` code." **Hard stop or alert?** Hard stop, at OpenAI. Spend alerts are the separate, softer control on the same page: an alert "Sends a notification; API traffic continues." Alerts don't enforce a cap. You can run both, so an email warns you before the wall arrives. **Where to click.** 1. Go to **Organization limits** (`https://platform.openai.com/settings/organization/limits`). 2. Under **Spend**, select **Edit spend limit**. 3. Enter the **Monthly spend limit**. 4. Turn on **Enforce a hard limit**. This step is the one that makes it a wall instead of a note. 5. Select **Save**. You need permission to manage the organization or project settings you're editing. **Or set it from code.** OpenAI's Admin API creates or replaces the organization's monthly hard limit at `/v1/organization/spend_limit`. The amount is in cents, so the example below sets \$100 a month. ```bash theme={null} curl -X POST https://api.openai.com/v1/organization/spend_limit \ -H "Authorization: Bearer $OPENAI_ADMIN_KEY" \ -H "Content-Type: application/json" \ -d '{ "threshold_amount": 10000, "currency": "USD", "interval": "month" }' ``` > **An Admin key isn't a developer key.** It can change your billing controls. Keep it in your secret manager and run this from a machine you control. It never belongs on a developer's laptop, and MarginFront never asks for it. **The limits of this limit.** * **Monthly only.** The cap covers a monthly cycle and resets with the next one. There's no weekly or per-developer version here. * **It overshoots a little.** OpenAI is explicit: "Enforcement is not instantaneous. The API Platform can process a small amount of extra usage while the limit state propagates, so recorded spend can slightly exceed the configured amount." Treat the number as a ceiling with a small ledge on top, not a razor line. * **Turning it back on takes a moment too.** "Raising or removing the reached limit allows traffic to resume after the update propagates." * **Two limits can both apply.** An organization hard limit covers traffic across every project. A project hard limit covers only that project. Reaching either one returns the `429`. * **Your tier's usage limit is a separate thing.** OpenAI also assigns your organization an approved monthly usage limit based on its usage tier. That isn't the limit you set, and hitting it looks different when you're diagnosing a `429`. *Sources: [OpenAI spend limits guide](https://developers.openai.com/api/docs/guides/spend-limits) and [OpenAI Admin APIs guide](https://developers.openai.com/api/docs/guides/admin-apis), both retrieved 2026-07-28.* *** ## ChatGPT Enterprise and Edu **Two separate controls, and you want both.** One caps what each person can use. The other caps how far the whole workspace can run past its plan. **What it does.** * **Usage limits** set a monthly limit for each user. You can apply a workspace default, group defaults, and per-user overrides on top. Only workspace admins and owners can configure them. * **The workspace overage limit** caps eligible usage beyond your committed plan. **Set it to 0 and the workspace can't go into overage at all.** Leaving it on "No limit" means no cap is configured on eligible overage usage. "No limit" doesn't make credits free; eligible overage usage continues and may be billed under your agreement. **Hard stop or alert?** Hard stop, at OpenAI. Exhausted limits can pause access to eligible features. The overage limit at 0 means the workspace isn't allowed into overage. **Where to click.** Both live in the Global Admin Console, in the usage and billing settings for the workspace. Members and analytics viewers can't change them, so this has to be done by an admin or owner. **Or set it from code.** Eligible Enterprise and Edu workspaces get OpenAI's **Spend Controls API**, which automates the same monthly limits at the workspace, group, and user level. It's called with a workspace-scoped Admin key, created in the Global Admin Console under **Credentials > Admin keys**, carrying these scopes: * `chatgpt.enterprise.usage_limit.read` to read the current usage-limit settings. * `chatgpt.enterprise.usage_limit.write` to change them. That Admin key is an administrative credential. It doesn't authenticate model requests, and it should never sit on a developer's machine. **The limits of this limit.** * **Monthly only,** same as every other ceiling on this page. * **"Eligible workspace" isn't defined publicly.** Confirm with your OpenAI account contact that your workspace has the Spend Controls API before anyone plans work around it. The console controls are the safe assumption; the API is the one to verify. * **The API's exact routes sit behind an authenticated reference.** You need to be signed in as an admin to read them, so budget a little time before writing any automation. * **This covers ChatGPT workspace usage, not your API Platform bill.** If you also run API keys, that's a second ceiling in a different place. See [API key billing](#api-key-billing). > **OpenAI's help center blocks automated readers.** We confirmed the linked article's contents through a rendering proxy on 2026-07-28. Open it in a browser yourself before you set policy off it. *Sources: [Manage usage limits and overages in ChatGPT Enterprise and Edu](https://help.openai.com/en/articles/20001001-manage-usage-limits-and-overages-in-chatgpt-enterprise-and-edu) and [ChatGPT usage limits and spend controls](https://learn.chatgpt.com/docs/enterprise/usage-limits), both retrieved 2026-07-28.* *** ## ChatGPT Business **By default, all seats and users have no limits specified.** If nobody has been into these settings, your Business workspace has no ceiling at all. Check this before you assume a previous admin set one. **What it does.** You set a credit maximum per seat type, and you can override it for individual people. It's the console equivalent of the Enterprise usage limits, one tier down. **Hard stop or alert?** A configured credit maximum is a limit at OpenAI, not a notification. The catch is entirely the default: unset means unlimited, so the control only helps once somebody sets it. **Where to click.** 1. Go to **Workspace settings**, then **Billing**. 2. Find the seat type you want to cap and select **Add** to set a credit maximum. 3. To cap one person instead, select **Manage** on that seat type, then **Add** to set that person's limit. A per-user limit overrides the seat-type limit. **While you're in there, check auto top-up.** If a **Minimum balance** and **Target balance** are set, ChatGPT automatically charges the payment method on file to refill credits whenever the balance drops below the minimum. That's a spend accelerator sitting next to your spend limit. Know which one you've turned on. **The limits of this limit.** * **Console only.** The Spend Controls API is an Enterprise and Edu feature. On Business, somebody sets these by hand, so put a calendar reminder on reviewing them. * **Monthly only,** same as the rest. * **Unset is the dangerous state,** and it's also the shipped state. *Source: [Managing credits and spend controls in ChatGPT Business](https://help.openai.com/en/articles/20001155-managing-credits-and-spend-controls-in-chatgpt-business), retrieved 2026-07-28. This article also blocks automated readers and was confirmed through a rendering proxy; open it in a browser before you set policy off it.* *** ## ChatGPT Plus and Pro **There's no admin lever here.** Plus and Pro are personal plans. OpenAI publishes spend controls for API Platform organizations and for Business, Enterprise, and Edu workspaces. It publishes no admin spend-limit control for a personal Plus or Pro seat, because there's no admin console over somebody's personal account. **What you actually get.** The plan's own usage limits, which are OpenAI's product limits and not your budget, plus the option for the user to buy more credits when they run out. Buying more credits is the opposite of a ceiling. **What to do instead.** Two honest options. 1. **Move those developers to a billing mode that has a ceiling.** An API key on your organization, or a Business, Enterprise, or Edu workspace seat. This is the only way to get a fail-closed stop for their Codex work. 2. **Accept a fail-open guardrail and know what you bought.** [MarginFront's device cap](/tools/code-cost-clarity) meters the spend and pauses work on machines where it's installed. A developer can take it off, and it can't see a machine that never installed it. For personal seats it's the only control that exists, and it's a seatbelt. If your Codex-heavy people are on personal Plus or Pro seats, option 1 is the one that changes your risk. Option 2 makes the spend visible, which is worth a lot, but it'll never be the wall. *Source: [Codex pricing](https://learn.chatgpt.com/codex/pricing), retrieved 2026-07-28, which lists Plus and Pro as personal plans with usage limits and purchasable credits and documents no admin spend control for them.* *** ## What none of these do True in every mode above. Worth knowing before you promise a number to anyone. * **Monthly only.** Every OpenAI ceiling here runs on a monthly cycle. You can't express "\$200 per developer per week" at OpenAI. That shape of budget has to live somewhere else. * **One provider only.** An OpenAI ceiling caps OpenAI. It knows nothing about what your team spends with Anthropic, Google, or xAI, so it can't be your whole-company AI budget. * **Alerts notify. They don't stop.** The documented notification channel for spend alerts is email. An alert is a heads-up, not a brake. * **Codex has no dollar cap of its own.** There's no "cap Codex at \$X" switch inside Codex. Every stop above is an account control that Codex activity draws against. OpenAI says it plainly about the workspace controls: they "aren't a universal Codex limit system and don't govern OpenAI API Platform billing." * **Enforcement isn't instant.** Expect a small overshoot on the way up and a short delay on the way back down after you raise a limit. *Sources: [OpenAI spend limits guide](https://developers.openai.com/api/docs/guides/spend-limits), [OpenAI Admin APIs guide](https://developers.openai.com/api/docs/guides/admin-apis), and [ChatGPT usage limits and spend controls](https://learn.chatgpt.com/docs/enterprise/usage-limits), all retrieved 2026-07-28.* *** ## Belt and suspenders The two controls do different jobs, and neither one replaces the other. | | OpenAI's spend limit | MarginFront's device cap | | ------------------------- | ------------------------ | ------------------------------------------- | | Where it runs | OpenAI's servers | The developer's machine | | When it breaks | Stays shut (fail closed) | Lets work through (fail open) | | Can a developer remove it | No | Yes | | Time window | Monthly | Whatever window your admin sets | | Covers | OpenAI spend | The AI coding spend it meters | | Good for | The emergency ceiling | The daily budget and the per-developer view | Set the ceiling at OpenAI for the number that would actually hurt. Run the daily budget with [Track Claude Code & Codex Spend](/tools/code-cost-clarity), and set those caps from code with [MarginFront's Spend Controls API](/api-reference/spend-controls). You're still responsible for your real OpenAI charges either way.