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 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 recommends for inbound deliveries.
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.
// 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).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,)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.
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.
// 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 ) 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, ... } |
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 covers how to isolate it by request ID.
-- Nightly job: compare MonetizeKit's counter against your own source of truthSELECT m.customer_id, m.meter_id, m.reported_total, s.actual_total, (m.reported_total - s.actual_total) AS driftFROM monetizekit_usage_snapshot mJOIN internal_usage_source s ON m.customer_id = s.customer_id AND m.meter_id = s.meter_idWHERE ABS(m.reported_total - s.actual_total) > 5; -- flag drift above toleranceSmall, 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.
Events & Webhook Explorer
Payload shape for usage.threshold and every other event type.
Audit and Trace
Correlate a disputed usage event back to its request ID.