Usage limits
Payments sells a thing outright: a product is bought, an entitlement is granted, and a gated collection or asset checks it. That answers has this person paid? — it does not answer how many have they had for free?
Usage limits answer the second one. It is the free tier: ten AI answers a day, three exports a month, one import an hour. You declare the allowance, and the platform counts it per person and refuses the request that goes over.
Every count lives in a private table in the project’s own D1 database, one row per person per limit. Usage limits provision that shared database when necessary without turning on Data’s app-facing record API.
Nothing checks it, and nothing counts it
Section titled “Nothing checks it, and nothing counts it”The important part of a meter is that your app does not enforce it. There is no spend endpoint,
no spend() in the meter import, no counter in a collection, and no comparison in your own code
— a count the browser can skip is not a limit at all, and in a Typillar app the browser is what
calls your endpoints.
Instead a limit names a door. Every unsafe request through that door spends one, before the work behind it runs. The refusal comes from the door itself.
Declaring an allowance
Section titled “Declaring an allowance”Allowances live in meter.json, next to data.json:
{ "limits": { "answers": { "door": "/api/_ai", "free": 10, "per": "day", "product": "prod_123" }, "exports": { "door": "/tasks/export", "free": 3, "per": "month" } }}| Field | Meaning |
|---|---|
door |
The path this limit meters. A platform door, or one of your own /tasks/ routes. |
free |
How many are included without buying anything, 0 to 1000000. |
per |
The window: hour, day, week or month. |
product |
Optional Stripe product id. Buying it lifts the limit. |
paid |
Optional. Buyers get this many instead of an unlimited number. |
A name is lowercase letters, digits and dashes. At most 20 limits.
Which doors can be metered
Section titled “Which doors can be metered”/api/_ai, /api/_render, /api/_video, /api/_email, /api/_connect, /api/_push,
/api/_files and /api/_data — plus any of your own /tasks/ routes, which is how you meter
something your app does itself rather than something the platform does for it.
A door is a path prefix, so /api/_ai meters every AI job while /api/_ai/draw meters only the
drawings, and /api/_data/reports meters writes to that one collection. Two limits may not cover
the same request: overlapping doors are refused when the app is built, because one request must
spend exactly one limit.
Only unsafe methods spend. GET, HEAD and OPTIONS are always free, so reading a metered
collection never costs anything.
Reading what is left
Section titled “Reading what is left”Counts live at /api/_meter, served from your app’s own origin — but the app does not write that
fetch. It imports meter, and the module calls it:
import { limits, refreshLimits, limitFor, meterRefusal, MeterError } from "meter";
// Every limit this app declares, counted for THIS caller — an array, not a wrapperconst all = await limits();// → [ { name: "exports", door: "/tasks/export", per: "month", used: 1, limit: 3,// remaining: 2, resetsAt: "2026-09-01T00:00:00.000Z", product: null }, … ]
// Or just the one you are about to showconst left = await limitFor("exports");show(`${left.remaining} of ${left.limit} exports left this month`);limits() answers the array itself — there is no wrapper around it — and it is read once and
cached, so calling it in ten components costs one request. refreshLimits() re-reads it, which is
what you call after anything metered, because the number you are showing has just changed.
limitFor(name) is null when nothing by that name is declared, which means a typo against your
own meter.json.
Reading a meter spends nothing, and a signed-out visitor gets their own counts rather than an
error. limit is null when the allowance is unlimited — a buyer of a product with no paid set
— and so is remaining: say “unlimited” in words rather than rendering a number.
The route behind the import, which is also what the app’s server.mjs speaks through
env.PLATFORM.fetch — it has no module graph, so it cannot import anything:
| Method & path | Body | Returns |
|---|---|---|
GET /api/_meter |
— | {"items":[{"name","door","per","used","limit","remaining","resetsAt","product"}]} for the caller |
Showing what is left is the point of a free tier. “2 of 3 exports left this month” next to the button is what makes the limit feel fair; a person who only discovers it by hitting it feels cheated.
What a person hits
Section titled “What a person hits”When the allowance is gone, the door answers — so the call you already wrote throws, from
whichever capability owns that door: a DataError, an AiError, a FilesError. meterRefusal()
is how you tell that one failure from every other:
import { write } from "ai";import { meterRefusal } from "meter";
try { await write(prompt);} catch (err) { const hit = meterRefusal(err); if (!hit) throw err; if (hit.product) showPricing(hit.product); else showComesBack(hit.name, hit.resetsAt);}It answers null for anything that is not a spent allowance, and otherwise the same fields a
limit always has — name, used, limit, remaining, resetsAt, product — plus message,
the server’s own words, and status.
hit.product is the fork, not the status code. A product means there is a way out: send them
to your buy button for that product id, and once entitledTo(hit.product) from
pay is true, call refreshLimits(). No product means they can only
wait until hit.resetsAt.
Do not branch on err.status yourself. A 429 also comes back from reading the meter too
often, and that one is a MeterError with nothing spent and nothing to buy — a status check
cannot tell the two apart, and meterRefusal() can, because it reads the refusal’s own body.
On the wire, a refusal with a product declared is 402:
{ "error": "you have used your 10 for this day", "limit": { "name": "answers", "used": 10, "limit": 10, "resetsAt": "2026-07-31T00:00:00.000Z" }, "product": "prod_123"}and without one it is 429:
{ "error": "you have used your 3 for this month — it comes back at 2026-08-01T00:00:00.000Z", "limit": { "name": "exports", "used": 3, "limit": 3, "resetsAt": "2026-08-01T00:00:00.000Z" }}Neither is retryable: never loop on one, and never show a raw error toast for one.
Who a person is
Section titled “Who a person is”A signed-in user, or an address when nobody is signed in. So an allowance is per account once Sign-in is on — which is what you want. With sign-in off, everybody behind one office router shares one allowance, so a meter is worth very little until your app has accounts.
A signed-in subject is stored as the user id. An address is not stored at all: it is hashed with the window it falls in, so the counter still tells two callers apart inside a window, the same caller cannot be followed from one window to the next, and no row in your database holds an IP address. Erasing a user takes their counts with it.
Staff are metered like anyone else.
Windows are calendar windows, in UTC
Section titled “Windows are calendar windows, in UTC”per: "day" ends at midnight UTC, not 24 hours after the first use. per: "month" ends on the
1st. This is why the response hands you resetsAt rather than a countdown.
Buying past it
Section titled “Buying past it”A product is a real Stripe product id from your own catalog, and it needs both
Payments and Sign-in on: the purchase has to
belong to the person whose limit it lifts. A limit that names a product you do not sell can never
be lifted, so the app refuses to build. Buying that product lifts the limit entirely unless you
set paid, which gives buyers a bigger allowance instead of an endless one.
The entitlement is the same one a gated collection checks, so one purchase can both unlock a feature and raise its ceiling.
What the console shows
Section titled “What the console shows”The Usage limits pane lists every allowance your app declares and, for the current window, how much has
been spent and by how many people. The allowances themselves live in your app’s meter.json — ask
for a free tier in chat and they appear here on the next deploy.