This is a hands-on kata, not a think-piece. The goal: take a raw AI usage event - a chunk of Claude tokens, a tool call, a credit burn - and turn it into a stable, auditable invoice line using UsageBox, in about 30 minutes, without standing up a billing database of your own. Every call below is a real endpoint on the live API, copied from the running service. By the end you will have idempotent ingest, a month-to-date total that is cheap to read, and the raw event trail behind every charge.
If you have read why a plain SQL usage_events table quietly breaks, this is the other half: what it looks like to just use a store that holds the billing invariants for you.
Before you start: three keys, not one
Every record you send is addressed with ingest keys you chose when you created the objects in the dashboard: a subscription (who is being billed), a product_item (what they bought), and a meter (what you are counting). Your API key goes in the x-api-key header. Take all four from your own account - GET /api/v1/subscriptions, /api/v1/meters and /api/v1/api_keys return exactly what you need - because keys copied out of documentation are the single most common reason a batch is accepted and meters nothing.
Step 1: send your first usage event
Usage goes in as records. Each one carries the three ingest keys, a numeric value, and an optional ISO timestamp (defaults to now). One request can carry up to 1,000 of them:
curl -X POST https://api.usagebox.com/api/v1/usage \
-H "x-api-key: $USAGEBOX_KEY" \
-H "Content-Type: application/json" \
-d '[{
"subscription": "acme-prod",
"product_item": "assistant-seats",
"meter": "claude-tokens",
"value": 18450,
"timestamp": "2026-06-16T10:14:05Z"
}]'
The response tells you what landed:
{
"message": "Usage batch accepted",
"request_id": "req_...",
"records": 1,
"processed": 1,
"skipped": 0,
"received_at": "2026-06-16T10:14:06.128Z"
}
Accepted batches are processed inline, so the record is in your rollups by the time the call returns. There is no queue to wait on.
Step 2: read processed, never just the status code
This is the step people skip and then lose a month of data to. Validation is all-or-nothing on shape: a malformed batch is rejected with a 422 and nothing is written. But a batch that is shaped correctly and carries an ingest key that does not exist on your account is accepted and silently meters nothing. That is why the response says so out loud:
{
"message": "Usage batch accepted but NO records were recorded. See skipped_details.",
"records": 2,
"processed": 0,
"skipped": 2,
"skipped_details": [ ... ]
}
A 200 with processed: 0 is a failure wearing a success costume. Assert on processed in your collector's tests, and log skipped_details - it names the key that did not resolve. If you ever wonder why a customer's usage is missing, this is where the answer is.
Step 3: make retries safe (the part that actually matters)
Usage collectors retry. Networks drop acks. So the real test is what happens when the same batch arrives twice. Send an Idempotency-Key header carrying a key you derive from the batch itself:
curl -X POST https://api.usagebox.com/api/v1/usage \
-H "x-api-key: $USAGEBOX_KEY" \
-H "Idempotency-Key: collector-2026-06-16T10:14Z-acme" \
-H "Content-Type: application/json" \
-d '[{ "subscription": "acme-prod", "product_item": "assistant-seats", "meter": "claude-tokens", "value": 18450 }]'
Replay that exact request and the records already committed are not counted again. Replay the same key with a different body and you do not get a silent overwrite - you get a 409 Conflict, which almost always means a buggy collector reusing one key for different usage. It surfaces in your logs instead of in a billing dispute three weeks later.
If part of a batch fails to commit, the response carries retry_with_same_idempotency_key: true and a hint. Follow it literally: replaying with the same key retries only the records that did not land, while a fresh key would double-count the ones that did.
Step 4: the number that goes on the invoice
Send a few hundred more records through the month, then ask for the total. Rollups are computed as the records land, aggregated per calendar month and per meter, so the read is cheap and never contends with ingestion - no SUM() over millions of rows under a lock at billing time:
curl "https://api.usagebox.com/api/v1/usage/rollups?month=2026-06&meter=claude-tokens" \
-H "x-api-key: $USAGEBOX_KEY"
{
"month": "2026-06",
"rollups": [{
"month": "2026-06",
"subscription_key": "acme-prod",
"product_item_key": "assistant-seats",
"meter_key": "claude-tokens",
"meter_unit": "tokens",
"aggregation": "sum",
"value": 18412900,
"last_record_at": "2026-06-30T23:51:02.004Z"
}]
}
Omit month and you get the current UTC month, which is your live month-to-date figure. Add subscription to scope it to one customer. The aggregation field is the one the meter was defined with, so the number means what your meter says it means.
Step 5: the evidence behind a disputed line
A customer disputes the June charge. "Trust our total" is not an answer - you need to show what they actually did. The raw records are readable back, newest first:
curl "https://api.usagebox.com/api/v1/usage?subscription=acme-prod&limit=200" \
-H "x-api-key: $USAGEBOX_KEY"
Each row comes back with the identifiers you sent it under - subscription_key, product_item_id, meter_id, the value and the timestamp - so the identifier you ingest with is the identifier you get back. limit defaults to 50 and caps at 200.
Production notes before you ship it
- Assert on
processed. Not the status code, notrecords. It is the only field that means "this was metered". - Derive the idempotency key from the batch (collector id plus time window plus customer), so a retry naturally reproduces it and a genuinely new batch cannot collide.
- Use your own ingest keys. Read them from
/api/v1/subscriptionsand/api/v1/metersrather than typing them; a key that does not resolve is skipped, not rejected. - Batch, but not too hard. Up to 1,000 records per request, validated all-or-nothing, so one malformed row fails the batch rather than corrupting half of it.
- No lock-in. Your raw records read back out through the same API. The trail is yours.
Kata FAQ
Do I have to run the database myself? No. UsageBox hosts the metering engine; you send records and read totals over HTTP. The durable store, the rollups and crash recovery are handled for you.
What happens if my collector sends a batch twice? With an Idempotency-Key, the second copy does not double-count. Without one, it is new usage - so send the header.
Why did my batch return 200 and change nothing? An ingest key in it did not resolve on your account. Read processed and skipped_details; the message says so explicitly.
Can I get my data out? Yes. Raw records read back through GET /api/v1/usage, rollups through /api/v1/usage/rollups.
What you just avoided building
In five steps you got idempotent ingest, a durable write path, cheap month-to-date totals per meter, and a readable raw trail behind every figure. Built in-house, that is a write-ahead log, crash recovery, dedupe-under-retry, and two aggregation paths you have to keep consistent - a real database project, not a weekend usage_events table. That is the same realization driving the 2026 metering acquisition wave: Stripe bought Metronome and Adyen bought Orb because metering is strategic infrastructure that is hard to get right and expensive to get wrong.
This is Kata #1 of four. Continue the series: Kata #2, live spend caps on real-time usage, Kata #3, reconcile a vendor bill against your meter, and Kata #4, per-customer per-model cost with dimensions.
Keep reading: idempotency and late events in depth, why metering needs its own database, and the usage-based billing guide.