In short: your backend reports token counts, your billing system reports payments, and FiscaLLM joins the two on the user ID. Nothing sits in the path of your AI requests. This page is the technical version; the docs have the setup steps.
Two inputs, both one-way and asynchronous: usage events from your backend, and revenue events from RevenueCat (or from your own code). Everything else is computed from those two tables.
You call meter.trackOpenAI(res, { userId }) after the provider responds. The SDK reads the usage object from the response and queues an event. It never reads message content.
What is res? And what do we read from it?
res is the object your AI provider's SDK returned for the call you just made. FiscaLLM reads exactly two things from it: the model name and the token counts (res.usage).
It never reads the answer text, the prompt or any message content, and none of that is sent. Only the model name and the counts leave your backend.
| Field | Notes |
|---|---|
id | UUID made by the SDK. The same event retried has the same id. |
userId | Your user, ideally the RevenueCat app user ID. |
provider, model | For example openai and gpt-5-mini. |
inputTokens, outputTokens | Cached reads and cache writes are separate fields, so each is priced at its own rate. |
units, unit, variant | For per-image, per-second, per-minute or per-character models. |
costUsd | Optional exact cost. Wins over the price table. |
feature | Your label for the call site, such as chat. |
Events are batched (up to 100 per request) and sent in the background: through waitUntil on Workers and Vercel, on a two-second timer on a long-running server, or on meter.flush() in Lambda-style runtimes. A request has a short timeout and one retry, then the batch is dropped. Tracking never throws and never delays your response.
The API authenticates the ingest key (stored as a hash), validates the batch, and prices every event on arrival:
costUsd, that is the cost.units by the per-unit price for that variant.The event insert and the per-user daily rollup (daily_user_cost) happen in one transaction. The insert uses ON CONFLICT DO NOTHING on the event id, so a retried batch cannot count twice. The API answers 202.
RevenueCat posts every subscription event to a per-app webhook. The request must carry the secret we generated for that app, compared in constant time. Each event is deduplicated on its RevenueCat event id, stored raw, then applied to a subscribers row:
| Event | Effect |
|---|---|
| Initial purchase, trial | Trial started |
| Initial purchase, paid | Paid; net revenue added |
| Renewal | Net revenue added; a trial conversion is marked converted |
| Cancellation with a refund | Refund subtracted |
| Expiration, product change, transfer | State and plan updated; history moved on transfer |
Net revenue is price × (1 − tax − store commission), in USD, so margins are after the store takes its cut. Sandbox events are ignored unless you turn on test mode. Not on RevenueCat? Connect Stripe (below), or send the same kinds of events with the revenue API. Subscribers who paid before the webhook existed can be imported from RevenueCat once.
Add a Stripe webhook endpoint pointing at your app's FiscaLLM URL and paste its signing secret into the Setup page. We store the secret encrypted, and every request must carry a valid Stripe-Signature: an HMAC of the raw body with a timestamp, checked in constant time, with a five-minute window against replays. Verified events are turned into the same revenue events the revenue API takes, so they follow exactly the same path as RevenueCat events.
| Stripe event | Effect |
|---|---|
invoice.paid | A purchase (first invoice) or renewal; the first payment after a trial marks the conversion |
customer.subscription.created (trialing) | Trial started |
customer.subscription.updated | Auto-renew switched off is a cancellation |
customer.subscription.deleted | Subscription ended |
charge.refunded | Only the new part of the refund is subtracted, so partial refunds are not counted twice |
The user is read from metadata.user_id on the subscription or customer, or else the Stripe customer ID is used. What you keep is the payment minus tax minus an estimate of Stripe's fee (2.9% + $0.30), not the exact fee. USD only for now. See the docs.
The join key is the user ID. RevenueCat events carry aliases (anonymous IDs, merged accounts), and every alias is stored against one canonical ID. AI events are resolved through that map at query time, so a user who was anonymous and later logged in is one person.
Users with AI cost and no subscription are reported as free or unmatched. That bucket is expected to be large, since free users never produce a payment. The Setup page shows a match rate so you can tell a healthy bucket from a wrong user ID.
Dashboards read the daily rollup, not the raw events, so they stay fast as volume grows. Raw events are deleted after 90 days; the rollup is kept.
Per-user budgets, and a limit on tokens used in 5 minutes (to catch a runaway loop), are checked within seconds of usage arriving. The rest (a paying user losing money, cost share of revenue, unpriced models) are compared once a day. A breach sends an email, a Slack message or a webhook once, and the alert is recorded so it does not repeat. A Monday job sends the weekly margin report.
Budgets can also be enforced: mark one as Block, and your backend calls meter.check(userId) before an AI call. It answers from the cost already recorded, and fails open, so if FiscaLLM is slow or down the call goes through.
| Failure | What happens |
|---|---|
| FiscaLLM is down | The SDK times out, retries once and drops the batch. Your app is unaffected; those events are missing. |
| Duplicate delivery | Usage and webhook events are deduplicated by id. Safe to retry. |
| Unknown model | Stored without a cost and flagged as unpriced. Add a price and it is counted going forward. |
| User ID mismatch | Cost shows under free or unmatched; the match rate on Setup drops. |
| Bad payload | The API rejects it with a 4xx and an error message; the SDK does not retry client errors. |
| Hosted monthly limit reached | On the hosted service an app can send 100,000 events a month. After that the API answers 403 with a clear message until the 1st; the SDK prints it once. Self-hosted copies have no limit. |
| You want reporting off | Set FISCALLM_DISABLED=1 (or disabled: true). The SDK then does nothing at all: no events, no network calls, streams and responses pass through untouched. |
| Stripe event it does not use, or non-USD | Acknowledged with 200 and ignored, so Stripe does not keep retrying. The response says why. |
A user ID, a model name, token or unit counts, an optional feature label and a timestamp. No prompts, no responses, no files. Each app's data is scoped by app ID and can be deleted in one click. See the privacy policy.
TypeScript throughout: Next.js for the site and dashboard, Hono for the API, Postgres (Supabase) with Drizzle, hosted on Vercel. The TypeScript and Python SDKs have no dependencies and use only fetch.