Skip to main content

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.
{
  "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)

FieldTypeDescription
customerExternalIdstringYour 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.
agentCodestringA 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.
signalNamestringThe 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)

FieldTypeDescription
modelstringThe 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".
modelProviderstringThe 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)

FieldTypeDescription
servicesarray (≥ 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:
FieldTypeRequiredDescription
modelstringYesSame shape as the top-level model for this service.
modelProviderstringYesSame shape as the top-level modelProvider (lowercase).
inputTokensinteger (≥ 0)LLM onlyInput tokens for this LLM service. Must be a whole integer.
outputTokensinteger (≥ 0)LLM onlyOutput tokens for this LLM service. Must be a whole integer.
quantityinteger (≥ 0)non-LLM onlyBilling units for this non-LLM service. Must be a whole integer. For fractional units (e.g. 14.137 seconds of compute), round before sending.

Optional fields

FieldTypeDefaultDescription
inputTokensinteger (≥ 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.
outputTokensinteger (≥ 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.
quantityinteger (≥ 0)1Single-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. Must be a whole integer; round fractional units before sending.
usageDateISO 8601 stringnowWhen the usage actually happened. Use this for back-filling historical events.
metadataobject{}Custom key-value pairs. Stored but not interpreted.

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:
{
  "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:
{
  "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.
{
  "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):
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):
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):
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):
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):
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.
{
  "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)

FieldDescription
customerExternalIdEchoed from your request
agentCodeEchoed from your request
signalNameEchoed from your request
model / modelProviderEchoed from your request
inputTokens / outputTokensEchoed from your request
quantityEchoed (or 1 if you didn’t send it)
totalCostUsdCalculated cost in USD (string with 10 decimal places)
eventIdUUID of the signal_events row — use this for lookups
rawEventIdUUID of the raw_ingest_events audit row
timestampWhen the event was processed

Success entry fields (multi-service)

FieldDescription
customerExternalIdEchoed
agentCodeEchoed
signalNameEchoed
servicesArray. One entry per service that contributed. Each entry has model, modelProvider, volume fields, usageCost, eventStatus.
totalCostUsdRolled-up parent cost (sum of resolved services[].usageCost). String with 10 decimal places.
eventIdUUID of the parent signal_events row
rawEventIdUUID of the audit row
timestampWhen 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

FieldDescription
recordThe original record you sent (echoed back so you can identify it). For multi-service, this includes the services[] array you sent.
codeWhy 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).
storedtrue = event is saved in the system (don’t retry). false = event was NOT saved (safe to retry).
eventIdUUID of the parent signal_events row (only present when stored: true)
rawEventIdUUID of the raw audit row (present for most failures)
errorHuman-readable description of what happened. For multi-service with multiple failing services, messages are joined with ” | ”.
servicesStatusMulti-service only. Array of {model, modelProvider, eventStatus} per service so you can see which specific services need fixing.

Error codes explained

CodeStored?What happenedWhat to do
NEEDS_COST_BACKFILLYesThe model+provider combination isn’t in the pricing table. The event is saved with cost: null.Do NOT retry. Go to the MarginFront dashboard → Usage Events → “Needs attention” and map the model to a known one. Once mapped, cost is backfilled automatically and all future events with that model auto-resolve.
INTERNAL_ERRORNoSomething 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_ERRORNoBad 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):
ParamTypeDefaultDescription
startDateISO 860130 days agoFilter events after this date
endDateISO 8601nowFilter events before this date
Response:
{
  "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:
{
  "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:
{
  "sourceModel": "my-custom-llm",
  "sourceProvider": "custom",
  "targetModel": "gpt-4o",
  "targetProvider": "openai"
}
Response:
{
  "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.

Query parameters

All optional. Without any, you get the most recent 20 events for your org.
ParamTypeDefaultDescription
pageinteger (≥ 1)1Page number.
limitinteger (1-100)20Results per page. Capped at 100.
customerIdUUIDFilter to events for one customer. Use MarginFront’s internal customer UUID (not your externalId).
agentIdUUIDFilter to events for one agent.
signalIdUUIDFilter to events for one signal.
startDateISO 8601 stringOnly events on or after this timestamp.
endDateISO 8601 stringOnly events on or before this timestamp.

Example

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)

{
  "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 "<model>/<dimension>" (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["<model>/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:
ValueMeaning
"PROCESSED"Cost calculated and stored. Ready to be included in invoices.
"NEEDS_COST_BACKFILL"Event stored but cost is null — the model+provider wasn’t found in the pricing table. Use GET /v1/events/needs-cost-backfill to find these and POST /v1/events/map-model to resolve.
"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 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.

Common HTTP errors

StatusCause
400 Bad RequestThe 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 UnauthorizedAPI key is missing or invalid.
403 ForbiddenYou 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 for full documentation. Quick example:
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.