# Usage Metering Best Practices

Publish accurate, deduplicated usage at scale and catch drift before it reaches an invoice.

<!-- docs-source -->
Source: https://learning.monetizekit.app/docs/guides/metering
<!-- /docs-source -->

Metered billing is only as trustworthy as the events feeding it. A single duplicated event overcharges a customer; a single dropped event undercharges you. This guide covers the full lifecycle of a usage event — idempotent submission, batching for high-frequency meters, threshold-based UX, and the reconciliation habits that catch drift before it reaches an invoice. See the [Entitlement Evaluation Patterns guide](/docs/guides/entitlement-patterns) for how a metered event resolves into a RECORDED/DEGRADE/REQUIRE_TOP_UP verdict on the caller side.

## 1. Submit events idempotently

Every `POST /usage/events` call requires an `Idempotency-Key` header. MonetizeKit deduplicates identical keys within a workspace for 24 hours — replayed requests (client timeouts, proxy retries, at-least-once queue delivery) return the original response instead of recording a second event. This mirrors the same idempotent-processing discipline the [Webhooks guide](/docs/guides/webhooks) recommends for inbound deliveries.

```bash
curl -s -X POST \
  -H "Authorization: Bearer $MONETIZEKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: req:cust_dev_1001:req_8f2c1a" \
  -d '{"customerId":"cust_dev_1001","meterId":"meter_api_requests","value":1}' \
  "$MONETIZEKIT_BASE_URL/usage/events"
# 201 Created -> {"id":"usage_evt_9012","customerId":"cust_dev_1001","meterId":"meter_api_requests","eventName":"api_request","value":1,"status":"success","occurredAt":"2026-06-14T00:00:00.000Z","createdAt":"2026-06-14T00:00:00.000Z","duplicate":false,"degraded":false,"requiresTopup":false}
```

Every recorded event carries two timestamps. `occurredAt` is the **event time** — when the metered activity actually happened. It defaults to now, and you may set it in the request body (an ISO 8601 timestamp, no more than 120 days in the past) to import history or replay a window you missed during an outage. `createdAt` is the **ingestion time** — when MonetizeKit accepted the event. It is always stamped server-side and cannot be set by callers, so a replayed backlog stays visible as ingestion lag instead of masquerading as real-time traffic.

```javascript
// Derive the key from something already unique to the source action —
// never Math.random() or Date.now(), which won't survive a retry.
const idempotencyKey = `req:${customerId}:${requestId}`;

const result = await mk.usage.submit({
  customerId,
  meterId: "meter_api_requests",
  value: 1,
  idempotencyKey,
});
// result.status === "success"; check result.degraded / result.requiresTopup
// for soft/hard budget signals (see "Design around budget policies" below).
```

```python
idempotency_key = f"req:{customer_id}:{request_id}"

result = mk.usage.submit(
    customer_id=customer_id,
    meter_id="meter_api_requests",
    value=1,
    idempotency_key=idempotency_key,
)
```

> [!TIP] Derive keys, don't generate them
>
> Build the idempotency key from data that is already unique to the triggering action — a request ID, a queue message ID, a `(userId, action, timestamp-bucket)` tuple. A key generated fresh on every attempt (e.g. a random UUID per call) defeats deduplication entirely, because a retried call generates a *different* key.

## 2. Batch high-frequency meters

For meters that increment many times per second (token counts, API request counts), one HTTP call per event is wasteful. `POST /usage/events/batch` accepts up to **500 events in one request**. Each item takes the same fields as a single event plus a required per-item `idempotencyKey`, and may carry its own `occurredAt` — which also makes this the sanctioned catch-up path after an outage: replay the missed window with each event's original time and duplicates converge instead of double-counting.

The batch endpoint answers `200 OK` with a `summary` and one entry in `results` per submitted event, **in request order**. Each result's `status` is `created`, `duplicate`, or `error`; accepted items also carry the stored event's `eventId`. A failing item (unknown customer, `deny`-policy budget exceeded, `occurredAt` out of range) never fails its siblings — its `error` carries the same `code` a single-event request would return, and you retry only those items with the same keys. The full event object per item (the same shape a single `POST /usage/events` returns) is opt-in: add `?include=events` when you need it, and leave it off for routine flushes — at 500 events it is roughly 200 KB of copies of what you just sent.

Budgets are applied per `(customer, meter, subjectId)` group within the batch — events without a `subjectId` form their own group per customer and meter. A group that no budget policy would touch is written in one statement, and a threshold crossed inside it fires a single `usage.threshold` webhook whose `value` is that group's accepted total. A group whose total *would* trigger a policy is processed in request order, so exactly the events that fit under a `deny` limit are accepted and the rest come back as `402 BUDGET_EXCEEDED` items.

```bash
curl -s -X POST \
  -H "Authorization: Bearer $MONETIZEKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"events":[
    {"customerId":"cust_dev_1001","meterId":"meter_api_requests","value":1,"idempotencyKey":"req:cust_dev_1001:req_8f2c1a"},
    {"customerId":"cust_dev_1001","meterId":"meter_api_requests","value":1,"idempotencyKey":"req:cust_dev_1001:req_8f2c1b","occurredAt":"2026-06-14T00:00:00.000Z"}
  ]}' \
  "$MONETIZEKIT_BASE_URL/usage/events/batch"
# 200 OK -> {"summary":{"total":2,"created":1,"duplicate":1,"errors":0},
#            "results":[{"index":0,"idempotencyKey":"req:cust_dev_1001:req_8f2c1a","status":"duplicate","eventId":"evt_9013"},
#                       {"index":1,"idempotencyKey":"req:cust_dev_1001:req_8f2c1b","status":"created","eventId":"evt_9014"}]}
# Add ?include=events to the URL to get each stored event object back as "event".
```

Buffer events in-process and flush on a timer or size threshold, keeping each buffered event's own idempotency key so a crashed flush can safely retry. The SDK's `mk.usage.submit` is a single-event call; flush the buffer to the batch endpoint over HTTP.

```javascript
// Buffer events in-process and flush them as one batch request —
// far cheaper than one HTTP round trip per event for high-frequency meters.
class UsageBuffer {
  constructor({ baseUrl, apiKey, flushIntervalMs = 5_000, maxBatchSize = 500 }) {
    this.baseUrl = baseUrl;
    this.apiKey = apiKey;
    this.buffer = [];
    this.maxBatchSize = maxBatchSize; // the endpoint caps a batch at 500 events
    setInterval(() => this.flush(), flushIntervalMs);
  }

  record(customerId, meterId, value, idempotencyKey) {
    // Capture event time now; it is preserved even if the flush is delayed.
    const occurredAt = new Date().toISOString();
    this.buffer.push({ customerId, meterId, value, idempotencyKey, occurredAt });
    if (this.buffer.length >= this.maxBatchSize) void this.flush();
  }

  async flush() {
    if (this.buffer.length === 0) return;
    const events = this.buffer.splice(0, this.maxBatchSize);
    const response = await fetch(`${this.baseUrl}/usage/events/batch`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${this.apiKey}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ events }),
    });
    const { results } = await response.json();
    // Re-queue only the items that failed; their keys make the retry safe.
    const failed = results.filter((result) => result.status === "error");
    this.buffer.unshift(...failed.map((result) => events[result.index]));
  }
}
```

## 3. Design around budget policies and thresholds

A budget has two independent settings that are easy to conflate: a **policy** (`deny`, `degrade`, or `require_topup`) that decides what happens to *this* request once accepting it would push usage past the budget's hard limit, and a separate **soft/hard threshold** pair (an arbitrary value you configure per budget, not a fixed 80%/100%) that fires an advisory `usage.threshold` webhook the moment either is first crossed — regardless of policy. Subscribe to that webhook (verified the same way as any other event — see the [Webhooks guide](/docs/guides/webhooks) ) to notify customers proactively instead of polling `GET /usage/{customerId}/{meterId}`.

| Policy | Behavior when the hard limit would be exceeded | Response |
| --- | --- | --- |
| `deny` | Event is rejected before being recorded — no usage row is written. | `402 BUDGET_EXCEEDED` |
| `degrade` | Event is still recorded; response flags the request so you can serve a reduced tier. | `201 Created — { "degraded": true, ... }` |
| `require_topup` | Event is still recorded; response flags that a credit top-up is needed. | `201 Created — { "requiresTopup": true, ... }` |

> [!NOTE] Threshold crossing is advisory, not enforcement
>
> The `usage.threshold` webhook fires whenever any budget's configured `softThreshold` or `hardThreshold` is crossed, independent of that budget's policy — including on budgets with no enforcement effect at all. Use it to drive notifications; use the response fields in the table above (or a `deny` rejection) to drive actual feature-gating decisions.

## 4. Reconcile on a schedule

Idempotency prevents duplicate submissions, but it can't catch events you never sent — a crashed worker before the buffer flushed, a silently-swallowed exception in an event handler. Run a periodic job comparing MonetizeKit's reported totals against your own source of truth (application logs, a data warehouse) and alert on drift beyond a small tolerance. If a customer disputes a specific event, the [Audit and Trace guide](/docs/guides/audit-trace) covers how to isolate it by request ID.

```sql
-- Nightly job: compare MonetizeKit's counter against your own source of truth
SELECT
  m.customer_id,
  m.meter_id,
  m.reported_total,
  s.actual_total,
  (m.reported_total - s.actual_total) AS drift
FROM monetizekit_usage_snapshot m
JOIN internal_usage_source s
  ON m.customer_id = s.customer_id AND m.meter_id = s.meter_id
WHERE ABS(m.reported_total - s.actual_total) > 5; -- flag drift above tolerance
```

> [!NOTE] Treat drift as a leading indicator
>
> Small, consistent drift (a handful of events per day) usually means a race condition in your batching logic. Sudden, large drift after a deploy almost always means a broken idempotency key derivation or a change to which events are instrumented at all — investigate deploys first.

## Common pitfalls

### Random idempotency keys

A key that changes on every retry attempt provides zero deduplication. Derive it from the triggering action, not the HTTP call.

### Fire-and-forget with no error handling

A `201 Created` means the event was recorded, not billed — invoicing settles later. Log failures from `POST /usage/events` and retry with the same idempotency key — don't drop them silently.

### Losing the buffer on crash

In-process buffers lose unflushed events on a crash or deploy. For meters where every event matters financially, back the buffer with a durable queue rather than an in-memory array.

### No reconciliation job

Idempotency catches duplicates, not gaps. Without a periodic drift check, missing events go unnoticed until a customer disputes an invoice.

## FAQ

### How long is an idempotency key remembered?

24 hours, scoped per workspace. A retry after that window will be recorded as a new event, so make sure your retry logic has a much shorter window than that.

### Can I submit negative usage to correct an overcount?

No — `value` must be a positive number; the API rejects zero and negative values with a `400`. Events are also immutable once accepted, so there is no self-serve correction endpoint today. Prevent overcounts up front with a correct idempotency key, and escalate a confirmed overcount through support for a manual adjustment.

### What happens if I exceed the API rate limit while batching?

You'll receive a `429` whose message states how many seconds to wait, and `X-RateLimit-Reset` tells you exactly when the window resets — there is no `Retry-After` header. Back off until that reset time rather than retrying immediately; a smaller `maxBatchSize` reduces how often you hit the limit in the first place.

## Related guides

### Entitlement Evaluation Patterns

How metered entitlements resolve into REQUIRE_TOP_UP and RECORDED verdicts.

[Open guide](/docs/guides/entitlement-patterns)

### Events & Webhook Explorer

Payload shape for usage.threshold and every other event type.

[Open guide](/docs/guides/events)

### Audit and Trace

Correlate a disputed usage event back to its request ID.

[Open guide](/docs/guides/audit-trace)