Backfilling late usage events

Usage doesn't always arrive on time. This guide covers what to do when it shows up late: how to replay it safely, which window it has to land in, and how to fix a month whose invoice has already gone out.

Keys themselves (choosing them, conflicts, retries) are covered in deduping usage events with idempotency keys. This page is about time.

Why events arrive late

Rule 1: replay with the original key and the original timestamp

A backfill is only safe if the meter can tell which events it already has. Send each replayed event with the same idempotency key the live path used (message id, job id, provider response id) and its original timestamp. Usage Lab then returns 201 for events it missed and 200 with "duplicate": true for ones it already counted. Nothing is counted twice, so you can replay a whole overlapping range rather than working out the exact gap.

Don't stamp a replayed event with "now". Usage Lab puts each event in the month of its timestamp, so a replay without the original timestamp lands in the wrong month. If the original was sent without a timestamp, replay it without one too: the timestamp is only part of the duplicate check when you send one.

# Replay one late event with its ORIGINAL key and timestamp
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: job_7781" \
  -d '{"meter":"llm_tokens","subscription":"cus_123","value":500,"timestamp":"2026-10-02T14:05:00Z"}'
# 201 {"event":{"status":"created",...,"month":"2026-10"},"duplicate":false}
# replay again, same payload -> 200 {"event":{"status":"duplicate",...},"duplicate":true}
# older than 30 days -> 422 {"error":{"code":"event_too_old","message":"timestamp is older than the 30-day late-event window"}}

Try a 40-day-late event in the playground. It runs in your browser and shows the exact 422 response.

Rule 2: know the late window

Usage Lab accepts events up to 30 days old and up to 5 minutes in the future. Anything older returns 422 event_too_old; anything further ahead returns 422 event_in_future. Accepted late events go into the month they happened, and that month's rollup is recomputed from raw events, so the number simply corrects itself.

Batches (up to 100 events, as a JSON array or {"events": [...]}) are all-or-nothing on validation: one event past the window rejects the whole request with 422 and none of it is written. Split out old events before sending, as in this Node example:

// Backfill a replayed queue in batches of up to 100, keys from your own data.
const API = "https://usage-lab.marc-cf2.workers.dev/api/v1/usage";
const DAY = 86400000, WINDOW = 30 * DAY, SAFETY = DAY; // stay a day inside the window

async function backfill(events) {           // events: [{id, customer, tokens, at}]
  const fresh = [], tooOld = [];
  for (const e of events) (Date.parse(e.at) > Date.now() - WINDOW + SAFETY ? fresh : tooOld).push(e);

  for (let i = 0; i < fresh.length; i += 100) {
    const body = { events: fresh.slice(i, i + 100).map(e => ({
      meter: "llm_tokens", subscription: e.customer, value: e.tokens,
      timestamp: e.at,          // the time it happened, not now
      idempotency_key: e.id,    // the same key the live path used
    })) };
    const res = await fetch(API, { method: "POST", body: JSON.stringify(body),
      headers: { Authorization: `Bearer ${process.env.USAGE_LAB_KEY}`, "Content-Type": "application/json" } });
    if (res.status === 422) throw new Error("batch rejected; nothing in it was written: " + await res.text());
    if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`); // retry 5xx/429 with the SAME body
    const { summary, results } = await res.json(); // {created, duplicate, conflict}
    for (const r of results) if (r.status === "conflict") console.warn("key reused with a different payload", r);
    console.log(summary);
  }
  return tooOld; // handle these as an adjustment, below
}

Rule 3: past the window, adjust, don't rewrite

Once an event is older than the window, or the invoice for its month has been finalized, don't try to put it back into that month. Record it as an adjustment in the current period, with a clear reason, and if needed issue a credit in your biller. Customers see a correction on their next invoice instead of a changed past invoice, and your ledger keeps an audit trail.

# Past the window: record a correction in the CURRENT month instead
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: adj_cus_123_2026-08_tokens" \
  -d '{"meter":"llm_tokens","subscription":"cus_123","value":500,
       "properties":{"kind":"adjustment","for_month":"2026-08","reason":"queue replay after outage"}}'
# no timestamp -> counted now, in the open month. Use a negative value for an over-count on a sum meter.

Use a deterministic key for the adjustment (customer + month + meter) so re-running the fix can't apply it twice.

Invoice timing: accepted is not the same as billed

Stripe's limits are worth knowing even if your meter accepts more. Its meter event API reference says the timestamp "Must be within the past 35 calendar days or up to 5 minutes in the future." Its recording usage guide adds: "The 35-day window determines whether Stripe accepts a meter event. It doesn't guarantee that Stripe can include the usage on an invoice. Report usage for a subscription service period before its invoice finalizes."

On dedupe, the same reference says of identifier: "Stripe enforces uniqueness within a rolling period of at least 24 hours." That covers quick retries, not a replay a week later, so a long-lived ledger key on your side still matters. To undo a single meter event, Stripe offers a meter event adjustment of type cancel by identifier; its reference notes "Time period cancellation is not supported yet."

Other billers draw the line differently. Orb's backfill and amend events guide describes a default reporting grace period: "your system can report events to the Orb API up to 12 hours after the timestamp", configurable account-wide. For older data Orb has a backfill API that "enables adding or replacing past events, even those that are older than the ingestion grace period."

Usage Lab's 30-day window is shorter than Stripe's 35 days, so an event Usage Lab accepts is still inside Stripe's window when exported. Stripe export is in dry-run mode today.

Checklist

The Idempotency-Key header Usage Lab uses follows the IETF httpapi draft, which describes it as a header field that "can be used to make non-idempotent HTTP methods such as POST or PATCH fault-tolerant" (the draft has since expired).

Sources

Sources checked 2026-10-14.