Skip to main content

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

npm install @marginfront/sdk

Initialize the client

import { MarginFrontClient } from "@marginfront/sdk";

// Your secret API key from the MarginFront dashboard (Settings > 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

FieldTypeWhat it is
customerExternalIdstringYour 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.
agentCodestringA stable identifier for the agent or product that did the work. If MarginFront has not seen this code before, the agent is created automatically.
signalNamestringThe billing unit being tracked (e.g. messages, sms-sent, report-pages). If MarginFront has not seen this name before, the signal is created automatically.
modelstringThe 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: '<your-provider>' }) (added in SDK 0.12.0).
modelProviderstringThe provider name, always lowercase. This tells MarginFront which pricing table to look in. Examples: "openai", "anthropic", "twilio", "google", "aws".
FieldTypeWhat it is
inputTokensintegerThe number of prompt tokens (what you sent to the model). Must be a whole integer, 0 or greater.
outputTokensintegerThe 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.

For variable-quantity billing

FieldTypeDefaultWhat it is
quantityinteger1How many units of work happened. Must be a whole integer. Use this for per-page, per-minute, per-SMS billing. If you’re tracking a fractional unit (like 14.137 seconds of compute), round to a whole number first (e.g. Math.round(elapsedSeconds)).

Optional

FieldTypeDefaultWhat it is
usageDatestring (ISO 8601) or DatenowWhen the work actually happened. Only needed if you’re back-filling historical events. Example: "2026-04-10T14:30:00Z".
metadataobject{}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.

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.
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 fieldMarginFront field
response.modelmodel
response.usage.prompt_tokensinputTokens
response.usage.completion_tokensoutputTokens

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

ShapeWhen to useTop-level fieldsservices[]
Single-serviceOne event, one underlying service. The 90% case for chatbots.model + modelProvider + volume (required)omit
Multi-serviceOne 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).
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.
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 for the conceptual mental model and Usage Events API reference 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.
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:
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).

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.

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

Field Reference

Required for every event

FieldTypeDescription
customerExternalIdstringYour customer’s ID in your system. Must match the externalId you set when creating the customer in MarginFront (or via auto-provisioning).
agentCodestringThe agent code from the MarginFront dashboard. Identifies which product/agent did the work.
signalNamestringThe name of the metric being tracked. Matches the signal you configured in the dashboard.
modelstringThe model or service identifier. Pass whatever your provider returns (e.g., response.model from OpenAI). Case-insensitive.
modelProviderstringThe provider name, always lowercase. Tells MarginFront which pricing table to look up.
FieldTypeDescription
inputTokensintegerNumber of prompt/input tokens. Must be a whole integer, 0 or greater. Required for MarginFront to calculate LLM cost.
outputTokensintegerNumber of completion/output tokens. Must be a whole integer, 0 or greater. Required for MarginFront to calculate LLM cost.

For variable-quantity billing

FieldTypeDefaultDescription
quantityinteger1Number of billing units. Must be a whole integer, 0 or greater. Use for per-page, per-minute, per-image, per-SMS billing. For fractional units (e.g. 14.137 seconds of compute), round to a whole integer before sending.

Optional

FieldTypeDefaultDescription
usageDatestring (ISO 8601) or DatenowWhen the work happened. Use for back-filling historical events.
metadataobject{}Free-form key-value pairs. Stored but NOT used for billing or cost calculation.

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

StateEvent saved?Cost calculated?What it means
PROCESSEDYesYesThe event was received, the model was recognized, and the cost was calculated. Nothing for you to do.
MISSING_VOLUME_DATAYesNo (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_BACKFILLYesNo (cost is null)The event was received, but the model + modelProvider you sent isn’t in MarginFront’s pricing table yet. Cost stays blank until you map that model to a known one.
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, find the unrecognized model, and map it to a known one.
  • Or 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:
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

If you send a model + modelProvider combination that MarginFront doesn’t recognize (not in the pricing table), this happens:
  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. Map the unrecognized model to a known one. MarginFront creates a permanent mapping and retroactively calculates cost for every event that used that model.
  5. 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:
// 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

CodeEvent saved?What happenedWhat to do
NEEDS_COST_BACKFILLYesThe model+provider isn’t in the pricing table. Event saved with cost = null.Do NOT retry. Map the model in the dashboard or call POST /v1/events/map-model. Cost backfills automatically.
MISSING_VOLUME_DATAYesThe 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_ERRORNoSomething broke on MarginFront’s side.Safe to retry.
VALIDATION_ERRORNoA 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