How FiscaLLM works

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.

Architecture

YOUR SIDEFISCALLMYour backendAI call → provider, thenmeter.track() in the backgroundRevenueCat / your billingpurchases, renewals, refundsor your own revenue API callsYour backend, before a callmeter.check(userId)optional, fails openPOST /api/v1/eventsvalidate · price · dedupe→ ai_events + daily_user_cost (one transaction)POST /api/webhooks/rc/:appverify signature · dedupe · apply→ rc_events + subscribersPOST /api/v1/checkspent today vs the cap for this user→ allow or refusetoken countsno promptspaymentsuser idallow / refuseMetrics: cost joined to revenueper user, plan and trial→ dashboard · alerts · caps · weekly email

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.

1. Usage: what the SDK sends

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.

FieldNotes
idUUID made by the SDK. The same event retried has the same id.
userIdYour user, ideally the RevenueCat app user ID.
provider, modelFor example openai and gpt-5-mini.
inputTokens, outputTokensCached reads and cache writes are separate fields, so each is priced at its own rate.
units, unit, variantFor per-image, per-second, per-minute or per-character models.
costUsdOptional exact cost. Wins over the price table.
featureYour 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.

2. Pricing each event

The API authenticates the ingest key (stored as a hash), validates the batch, and prices every event on arrival:

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.

3. Revenue: RevenueCat, Stripe or your own

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:

EventEffect
Initial purchase, trialTrial started
Initial purchase, paidPaid; net revenue added
RenewalNet revenue added; a trial conversion is marked converted
Cancellation with a refundRefund subtracted
Expiration, product change, transferState 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.

Stripe (beta)

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 eventEffect
invoice.paidA purchase (first invoice) or renewal; the first payment after a trial marks the conversion
customer.subscription.created (trialing)Trial started
customer.subscription.updatedAuto-renew switched off is a cancellation
customer.subscription.deletedSubscription ended
charge.refundedOnly 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.

4. Joining cost to revenue

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.

5. What gets computed

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.

6. Alerts

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.

When something fails

FailureWhat happens
FiscaLLM is downThe SDK times out, retries once and drops the batch. Your app is unaffected; those events are missing.
Duplicate deliveryUsage and webhook events are deduplicated by id. Safe to retry.
Unknown modelStored without a cost and flagged as unpriced. Add a price and it is counted going forward.
User ID mismatchCost shows under free or unmatched; the match rate on Setup drops.
Bad payloadThe API rejects it with a 4xx and an error message; the SDK does not retry client errors.
Hosted monthly limit reachedOn 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 offSet 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-USDAcknowledged with 200 and ignored, so Stripe does not keep retrying. The response says why.

What leaves your backend

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.

Stack

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.

Read the setup docs →