Skip to content

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.

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.

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.

/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.

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 wrapper
const 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 show
const 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.

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.

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.

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.

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.

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.