In short: decide what each plan gets (for example 10 AI messages a day on free, 500 a month on Pro), count every AI call per user on your backend, check the count before each call, and return how much is left so the app can show it. When the allowance is used up, answer with the paywall instead of the AI. The counting is easy. Knowing each user's plan at that moment, and resetting at the right time, is where most home-built versions go wrong.
A limit does two jobs. It caps what a free user can cost you, and it is the moment a free user sees a reason to pay. Both need the same three things: the user's plan, their usage in the current period, and the number left.
| Count | Good for | Watch out for |
|---|---|---|
| AI calls (messages, generations) | Anything you show the user: "3 messages left today" | Calls differ in cost. A long conversation or a big document costs many times a short question |
| Dollars of AI cost | Fair-use limits on paid plans; apps where one request can be very large | Users cannot reason about it, so do not show it as is. Needs a price for every model you use |
| Tokens | Rarely the right choice | Input, output and cached tokens have different prices, so a token count is neither a cost nor something users understand |
If one message in your app makes several model calls (a tool-using agent, a retry, a summary step), count the user-visible action yourself or limit by dollars. Otherwise "20 messages" quietly becomes 6.
| Period | Typical use |
|---|---|
| In total (never resets) | "5 free tries, then subscribe." The strongest push to pay, and the cheapest free tier |
| Per day | Free plans that should build a habit; a safety cap during a free trial, when cost is real and revenue is $0 |
| Per month | Paid plans. Matches how people think about a subscription |
Pick one clock for resets and say so in the app. A UTC day is the simplest to build and explain ("resets at 00:00 UTC"). A rolling 24 hours feels fairer but means storing every call's time. For monthly limits, a calendar month is simple; the user's billing period is more exact but changes with every upgrade, pause and renewal.
The app knows the plan (RevenueCat's SDK returns the active entitlements), but the app can't be trusted to enforce a limit. Your backend needs its own answer:
INITIAL_PURCHASE, RENEWAL, PRODUCT_CHANGE, CANCELLATION and EXPIRATION are the events that change it.customer.subscription.created, updated and deleted, and map the price to a plan. trialing and active have access; past_due is your call.Use the same user ID in billing and in your AI tracking. With RevenueCat that means logging users in with your own ID, or sending the anonymous ID to your backend; see the RevenueCat guide.
The smallest version is one table and two statements:
CREATE TABLE ai_usage (
user_id text, day date, calls int NOT NULL DEFAULT 0, cost_usd numeric NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, day)
);
-- before the call
SELECT coalesce(sum(calls), 0) FROM ai_usage WHERE user_id = $1 AND day = current_date;
-- after the call
INSERT INTO ai_usage (user_id, day, calls, cost_usd) VALUES ($1, current_date, 1, $2)
ON CONFLICT (user_id, day) DO UPDATE
SET calls = ai_usage.calls + 1, cost_usd = ai_usage.cost_usd + excluded.cost_usd;One row per user per day covers every period: today is one row, this month is a sum over at most 31, and a total is a sum over all of them. No reset job is needed, because a new day is simply a new row.
Then answer the app with enough to draw the right screen:
// allowed
200 { "reply": "…", "remaining": 3, "resetsAt": "2026-11-12T00:00:00Z" }
// used up: the app opens the paywall
402 { "paywall": true, "plan": "free", "resetsAt": "2026-11-12T00:00:00Z" }Show the remaining number before the user runs out ("3 left today"), not only at zero. People upgrade more readily when the limit was visible all along than when it appears as an error.
FiscaLLM is open source and does the parts above: it receives the RevenueCat or Stripe events, keeps each user's plan, counts AI calls and prices them, and answers one question from your backend.
import { createMeter } from "fiscallm";
const meter = createMeter({ apiKey: process.env.FISCALLM_KEY });
const { allow, plan, limits } = await meter.check(userId);
if (!allow) return respond(402, { paywall: true, plan, resetsAt: limits[0]?.resetsAt });
const reply = await openai.chat.completions.create({ ... });
meter.trackOpenAI(reply, { userId }); // counted and priced
return { reply, remaining: limits[0]?.remaining }; // "3 left today"The limits themselves are settings, not code: on the app's Limits page you add rows like free, 10 AI calls a day; trial, 30 a day; pro_yearly, $8 of AI a month. Changing a limit needs no deploy and no app release. The same page lists the users who are at a limit right now, and the rest of the dashboard shows what each plan's AI costs against what it earns, so you can tell whether a limit is too tight or too generous.
Create an app (free, no card), read the reference, or run it yourself.
Show users a number of messages, because they understand it. Protect yourself with dollars, because one long message can cost as much as fifty short ones. Many apps use both: a visible message count for the free plan and a dollar fair-use limit for paid plans.
No. Anything checked in the app can be bypassed by calling your API directly. Count and check on the backend, and send the remaining number to the app only for display.
Either store the plan from RevenueCat's webhooks, or ask RevenueCat's REST API for the subscriber when needed. FiscaLLM does the first for you: it receives the webhooks and answers with the user's plan and remaining allowance in one call.
Do not cut them off silently. Common choices are a slower or cheaper model for the rest of the period, an offer to move to a higher plan, or a pack of extra usage. Tell them when the allowance resets.
Multiply the limit by the cost of one call. 10 calls a day at $0.002 each is at most $0.60 per active free user a month. The free LLM pricing calculator on this site works it out for any model.