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.

aggregationMonthly quantityUse for
sumSum of valueTokens, bytes, compute seconds
countNumber of eventsAPI calls, jobs
unique_countDistinct unique_valuesMonthly active users, seats

POST /api/v1/meters

FieldNotes
keyRequired. 1–64 chars: letters, digits, _ . -, must start with a letter or digit. Unique per tenant.
aggregationRequired. sum | count | unique_count
nameOptional display name (defaults to key).
stripe_event_nameOptional. 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": [ … ]}).

FieldNotes
meterMeter key or id. Optional only when your tenant has exactly one meter.
subscriptionRequired (alias customer). Who the usage is billed to, string ≤ 200 chars. Use your Stripe customer id if you plan to export to Stripe.
valueFinite number. Required for sum meters (negative values are allowed, e.g. corrections). Defaults to 1 for count and unique_count.
unique_valueString or number. Required for unique_count meters.
timestampISO-8601 string or unix seconds/milliseconds. Defaults to now. Decides the billing month (UTC).
idempotency_keyPer-event key (≤ 255 chars). Alternative to the header.
propertiesOptional JSON, stored with the event. Not used in aggregation.

Idempotency

Late and future events

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": "…"}}.

HTTPcodeWhen
400invalid_jsonBody isn't valid JSON
400empty_batch / batch_too_large0 events / more than 100
400invalid_eventEvent isn't an object
400meter_requiredNo meter and tenant has 0 or several meters
400subscription_requiredMissing/empty subscription, or > 200 chars
400value_required / invalid_valueSum meter without value / value not a finite number
400unique_value_required / invalid_unique_valueunique_count meter without unique_value / wrong type
400invalid_timestampUnparseable timestamp
400idempotency_key_required / invalid_idempotency_key / duplicate_key_in_batchSee Idempotency
400invalid_meter_key / invalid_aggregation / invalid_monthBad meter definition / month not YYYY-MM
401unauthorizedMissing, invalid or revoked API key
404meter_not_found / not_foundUnknown meter / unknown route
409idempotency_conflict / meter_existsKey reused with different payload / duplicate meter key
422event_too_old / event_in_futureOutside the 30-day late window / > 5 min ahead
500internalServer error; safe to retry with the same key

Other endpoints

GET /health returns {"ok": true, "service": "usage-lab", "time": "…"}, no auth needed.

Next: copy-paste examples in curl, Node and Python.