Setup takes about ten minutes: create an app, report AI usage from your backend, and connect RevenueCat.
FISCALLM_KEY.The Setup page shows when the first event and the first webhook arrive, and how many of your AI users it could match to RevenueCat.
The FiscaLLM setup skill tells a coding assistant how to find your AI calls, add the SDK, pass the RevenueCat user ID through and leave the keys to you. In Claude Code, install it in your project and ask it to "set up FiscaLLM":
mkdir -p .claude/skills/fiscallm-setup && curl -fsSL https://fiscallm.com/skill.md -o .claude/skills/fiscallm-setup/SKILL.mdIn Cursor, Copilot or another assistant, paste:
Read https://fiscallm.com/skill.md and follow it to set up FiscaLLM in this project.npm install fiscallmZero dependencies. Runs on Node 20+, Cloudflare Workers, Vercel, Supabase Edge Functions (Deno), Bun and Firebase. It never throws, never blocks your response, and sends only token counts: prompts and responses never leave your backend.
import { createMeter } from "fiscallm";
const meter = createMeter({
apiKey: env.FISCALLM_KEY,
waitUntil, // optional, see "Serverless platforms"
});
// userId: the user's RevenueCat app user ID
const res = await openai.chat.completions.create({ model, messages });
meter.trackOpenAI(res, { userId, feature: "chat" });| Call | Pass |
|---|---|
meter.trackOpenAI(res, ctx) | A Chat Completions or Responses API result, from the SDK or await r.json() |
meter.trackAnthropic(res, ctx) | A Messages API result |
meter.trackGemini(res, ctx) | A generateContent result, from @google/genai or REST |
meter.track(event) | Anything else: tokens, or an exact costUsd for images and voice |
ctx is { userId, feature?, provider?, model? }. feature is your own label ("chat", "summary") to break cost down by. For OpenAI-compatible APIs such as Groq, OpenRouter or DeepSeek, use trackOpenAI(res, { userId, provider: "groq" }).
meter.track({
userId,
provider: "fal",
model: "flux-pro",
costUsd: 0.05, // for calls not priced per token
feature: "avatar",
});Wrap the stream; chunks pass through unchanged and usage is recorded when it ends. OpenAI Chat Completions only reports usage with stream_options: { include_usage: true }.
const stream = meter.trackStream(
await openai.chat.completions.create({
model, messages, stream: true,
stream_options: { include_usage: true },
}),
{ userId },
);
for await (const chunk of stream) { /* ... */ }If your backend proxies a provider's raw server-sent events straight to the app, wrap the response instead:
const upstream = await fetch("https://api.anthropic.com/v1/messages", init);
return meter.trackSSE(upstream, { userId });Events are batched and sent in the background. On serverless platforms, tell the SDK how to keep the function alive until they are sent:
| Cloudflare Workers | createMeter({ apiKey, waitUntil: ctx.waitUntil.bind(ctx) }) |
| Vercel | import { waitUntil } from "@vercel/functions", then pass waitUntil |
| Supabase Edge Functions | waitUntil: (p) => EdgeRuntime.waitUntil(p) |
| Firebase, AWS Lambda | await meter.flush() before returning |
| Long-running Node server | Nothing: events are sent every two seconds |
Each request has a two-second timeout and one retry. If FiscaLLM is unreachable, events are dropped and your app is unaffected.
pip install fiscallmSame helpers and guarantees as the TypeScript SDK, with no dependencies, on Python 3.9+. Tracking only queues; a background thread sends.
import os
from fiscallm import Meter
meter = Meter(api_key=os.environ["FISCALLM_KEY"]) # one per process
res = client.chat.completions.create(model=model, messages=messages)
meter.track_openai(res, user_id=user_id, feature="chat")
# also: track_anthropic, track_gemini, track(...)
# Streams, sync or async: iterate the returned stream
stream = meter.track_stream(response_stream, user_id=user_id)
# AWS Lambda, Cloud Functions: before returning
meter.flush() # or: await meter.aflush()The userId you send must be the user's RevenueCat app user ID. That is how AI cost is joined to revenue.
Purchases.shared.appUserID (iOS), Purchases.sharedInstance.appUserID (Android) or the equivalent from the app to your backend with each request.Users with AI cost but no RevenueCat record appear as "free or unmatched". Free users never reach RevenueCat, so some unmatched cost is expected. A low match rate on the Setup page usually means the IDs differ.
Revenue is net: price × (1 − tax − store commission), as RevenueCat reports them. Refunds subtract on the day they happen.
RevenueCat sends only new events. To bring in subscribers who paid before you connected, use Settings → Import past subscribers: paste your RevenueCat project ID and a v2 secret key with read-only access to customer information, and FiscaLLM imports their purchases, renewals and trials from RevenueCat's API. Purchases the webhook already delivered are skipped, so nothing counts twice. The key is stored encrypted only while the import runs.
Cost is calculated on our side from the providers' public list prices, including cached tokens, cache writes and long-prompt pricing, and the price that applied on the day of each call. If you pay less, add a discount for a provider or your own prices for a model under Settings → Your AI prices. Models without a price are listed there too; their cost counts as zero until you add one or send costUsd.
For backends not written in TypeScript, send events directly:
POST https://fiscallm.com/api/v1/events
Authorization: Bearer <FISCALLM_KEY>
Content-Type: application/json
{
"events": [{
"id": "9b2c6f0e-3f7c-4d8a-9a51-2f0a4f1c7e21", // a UUID you generate; retries reuse it
"userId": "rc-app-user-id",
"ts": "2026-10-17T12:00:00Z",
"provider": "openai",
"model": "gpt-5-mini",
"inputTokens": 1200, // excluding cache reads and writes
"outputTokens": 300,
"cachedTokens": 0,
"cacheWriteTokens": 0,
"costUsd": 0.0012, // optional: overrides our price
"feature": "chat" // optional
}]
}Up to 100 events per request. The response is 202 with { accepted, duplicates, unpriced }. Events with anid already received are counted as duplicates and ignored, so retrying is safe. Timestamps may be up to 90 days old.