Usage Lab API docs
Quickstart and reference for the Usage Lab v1 API. Base URL: https://usage-lab.marc-cf2.workers.dev. All requests and responses are JSON.
Status: early access. API keys are issued manually (no self-serve signup yet). Stripe export is dry-run only.
Quickstart
1. Create a meter. 2. Send events with an idempotency key. 3. Read the monthly rollup.
export USAGE_LAB_KEY=ul_live_... # your API key
# 1. Create a sum meter for LLM tokens
curl -X POST https://usage-lab.marc-cf2.workers.dev/api/v1/meters \
-H "Authorization: Bearer $USAGE_LAB_KEY" -H "Content-Type: application/json" \
-d '{"key":"tokens","aggregation":"sum","name":"LLM tokens"}'
# 2. Record usage (retry-safe: same Idempotency-Key = counted once)
curl -X POST https://usage-lab.marc-cf2.workers.dev/api/v1/usage \
-H "Authorization: Bearer $USAGE_LAB_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: req_8f2c1" \
-d '{"meter":"tokens","subscription":"cus_123","value":1500}'
# 3. Monthly quantity per subscription
curl "https://usage-lab.marc-cf2.workers.dev/api/v1/meters/tokens/rollups?month=2026-10" \
-H "Authorization: Bearer $USAGE_LAB_KEY"
Authentication
Every /api/v1/* request needs an API key, sent either way:
Authorization: Bearer ul_live_...
# or
X-Api-Key: ul_live_...
Keys are stored hashed; one key belongs to one tenant. A missing or invalid key returns 401:
{
"error": {
"code": "unauthorized",
"message": "Missing API key (Authorization: Bearer <key> or X-Api-Key)"
}
}
An unknown or revoked key returns the same code with message "Invalid or revoked API key".
Meters
A meter defines what you count and how it aggregates per subscription per month.
| aggregation | Monthly quantity | Use for |
|---|---|---|
sum | Sum of value | Tokens, bytes, compute seconds |
count | Number of events | API calls, jobs |
unique_count | Distinct unique_values | Monthly active users, seats |
POST /api/v1/meters
| Field | Notes |
|---|---|
key | Required. 1–64 chars: letters, digits, _ . -, must start with a letter or digit. Unique per tenant. |
aggregation | Required. sum | count | unique_count |
name | Optional display name (defaults to key). |
stripe_event_name | Optional. Stripe Billing Meter event name for the (coming soon) export. |
201:
{
"meter": {
"id": "mtr_…", "key": "tokens", "name": "LLM tokens",
"aggregation": "sum", "stripe_event_name": null,
"created_at": "2026-10-13T12:00:00.000Z"
}
}
A duplicate key returns 409 meter_exists.
GET /api/v1/meters
Returns {"meters": [ … ]} in creation order.
POST /api/v1/usage
Ingest one event (a JSON object) or a batch of up to 100 (a JSON array, or {"events": [ … ]}).
| Field | Notes |
|---|---|
meter | Meter key or id. Optional only when your tenant has exactly one meter. |
subscription | Required (alias customer). Who the usage is billed to, string ≤ 200 chars. Use your Stripe customer id if you plan to export to Stripe. |
value | Finite number. Required for sum meters (negative values are allowed, e.g. corrections). Defaults to 1 for count and unique_count. |
unique_value | String or number. Required for unique_count meters. |
timestamp | ISO-8601 string or unix seconds/milliseconds. Defaults to now. Decides the billing month (UTC). |
idempotency_key | Per-event key (≤ 255 chars). Alternative to the header. |
properties | Optional JSON, stored with the event. Not used in aggregation. |
Idempotency
- Every event needs a key: the
Idempotency-Keyheader, oridempotency_keyon the event. Missing →400 idempotency_key_required. - In a batch, an event without its own key gets
<header>:<index>(e.g.batch_77:0,batch_77:1). - Keys are scoped to your tenant and kept indefinitely in v1.
- Same key + same payload →
200with"duplicate": trueand the original event id. Nothing is counted twice. - Same key + different payload →
409 idempotency_conflict; the original event is kept. - The payload compared is meter, subscription, value, unique_value and timestamp (only if you sent one).
propertiesis not compared. A retry that omitstimestampis still a duplicate. - The same key twice inside one request →
400 duplicate_key_in_batch.
Late and future events
- Events with a
timestampup to 30 days in the past are accepted and land in the month they occurred. - Older →
422 event_too_old. More than 5 minutes in the future →422 event_in_future.
Single event response
201 when created:
{
"event": {
"status": "created",
"id": "evt_…",
"idempotency_key": "req_8f2c1",
"meter": "tokens",
"month": "2026-10"
},
"duplicate": false
}
The same request again returns 200 with "status": "duplicate", the original id, and "duplicate": true. A conflicting reuse returns 409:
{
"error": {
"code": "idempotency_conflict",
"message": "idempotency key reused with a different payload; original kept",
"original_event_id": "evt_…"
}
}
Batch response
Batches are validated all-or-nothing: if any event fails validation, the whole request is rejected and the message names the event (e.g. events[3]: value is required for sum meters). A valid batch returns 200 with a per-event status; conflicts are reported in-line rather than as a 409:
{
"results": [
{"status": "created", "id": "evt_…", "idempotency_key": "batch_77:0", "meter": "tokens", "month": "2026-10"},
{"status": "duplicate", "id": "evt_…", "idempotency_key": "batch_77:1", "meter": "tokens", "month": "2026-10"},
{"status": "conflict", "id": "evt_…", "idempotency_key": "batch_77:2",
"error": "idempotency key reused with a different payload; original kept"}
],
"summary": {"created": 1, "duplicate": 1, "conflict": 1}
}
GET /api/v1/meters/:meter/rollups
:meter is the meter key or id. Query: month=YYYY-MM (UTC; defaults to the current month), optional subscription=… to filter to one subscription. Rollups are recomputed from raw events on every read, so late events are always included.
{
"meter": {"id": "mtr_…", "key": "tokens", "aggregation": "sum", …},
"month": "2026-10",
"computed_at": "2026-10-13T12:05:00.000Z",
"total_quantity": 1500,
"rollups": [
{
"subscription": "cus_123",
"quantity": 1500,
"event_count": 1,
"exported_quantity": 0,
"pending_export_quantity": 1500,
"exported_at": null
}
]
}
Stripe export coming soon
POST /api/v1/meters/:meter/rollups/export?month=YYYY-MM exists but runs in dry-run mode: it returns the Stripe Billing Meter requests it would send and sends nothing. exported_quantity does not change. The design pushes the delta (quantity − exported_quantity) so late usage tops up instead of double-billing. Live push is not available yet; don't rely on it for invoicing.
Errors
All errors have the shape {"error": {"code": "…", "message": "…"}}.
| HTTP | code | When |
|---|---|---|
| 400 | invalid_json | Body isn't valid JSON |
| 400 | empty_batch / batch_too_large | 0 events / more than 100 |
| 400 | invalid_event | Event isn't an object |
| 400 | meter_required | No meter and tenant has 0 or several meters |
| 400 | subscription_required | Missing/empty subscription, or > 200 chars |
| 400 | value_required / invalid_value | Sum meter without value / value not a finite number |
| 400 | unique_value_required / invalid_unique_value | unique_count meter without unique_value / wrong type |
| 400 | invalid_timestamp | Unparseable timestamp |
| 400 | idempotency_key_required / invalid_idempotency_key / duplicate_key_in_batch | See Idempotency |
| 400 | invalid_meter_key / invalid_aggregation / invalid_month | Bad meter definition / month not YYYY-MM |
| 401 | unauthorized | Missing, invalid or revoked API key |
| 404 | meter_not_found / not_found | Unknown meter / unknown route |
| 409 | idempotency_conflict / meter_exists | Key reused with different payload / duplicate meter key |
| 422 | event_too_old / event_in_future | Outside the 30-day late window / > 5 min ahead |
| 500 | internal | Server error; safe to retry with the same key |
Other endpoints
GET /health returns {"ok": true, "service": "usage-lab", "time": "…"}, no auth needed.