> ## Documentation Index
> Fetch the complete documentation index at: https://docs.marginfront.com/llms.txt
> Use this file to discover all available pages before exploring further.

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

### 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 " \| ".                                                                                              |
| `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.

### 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 `"<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:

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

***

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