Usage and metering
What your instance records about resource usage per workspace, how to read it, and what a gap in the record means.
#Usage and metering
Your instance keeps a usage ledger: a durable, per-workspace record of what each one consumed, hour by hour and day by day. It is separate from the live dashboards on System → Monitor, and it exists for a different reason.
The dashboards answer "what is happening right now" from Cloudflare's analytics, which keeps roughly ninety days of data and samples it under load. The ledger answers "what did this workspace use last March" — a question that has to still be answerable next year, and whose answer must stop changing once the period is over.
#What is recorded
Seventeen request-derived figures, plus messages, realtime and storage:
| Metric | What it counts |
|---|---|
requests, errors |
Requests served, and how many answered 4xx/5xx |
duration_ms, d1_duration_ms |
Time spent, in total and inside the database |
rows_read, rows_written, rows_returned, d1_queries |
Database work |
kv_reads, kv_writes, do_calls, r2_ops, subrequests |
Every other billable binding |
ai_calls, ai_tokens_in, ai_tokens_out |
Workers AI — the priciest binding per call |
ai_calls_unreported |
AI calls whose model returned no token counts |
messages_sent |
Messages delivered — email and in-app |
realtime_seconds |
Time WebSocket subscriptions were held open |
storage_bytes |
Stored file bytes, measured directly |
A note on realtime_seconds. It is recorded when a connection closes, which is the only moment its duration is knowable. A connection whose server goes away underneath it is never recorded, so this figure is a floor rather than an exact total — the same direction every other measurement here errs, and the one that cannot bill for time nobody used.
A note on the AI token figures. They count only what the model itself reported. Some Workers AI models return no token counts at all, and rather than estimate one from the length of the text — a plausible number that would quietly reach an invoice — those calls are counted in ai_calls_unreported instead. So if a period shows 500 ai_calls and 40 ai_calls_unreported, the token totals describe 460 of them. That is also how you tell an under-reporting model from genuinely low usage.
Everything except storage_bytes is a counter — two hours added together give the two-hour figure, and a month of days gives the month. storage_bytes is a gauge: a level measured at a moment. Adding up thirty daily readings of a 4 GB bucket gives 120 GB, which is arithmetically correct and obviously wrong. The API tells you which is which, so a client never has to guess.
#Reading it
The workspace you are signed in to:
curl -s -H "Authorization: Bearer $TOKEN" \
"https://your-instance/api/v1/system/usage?granularity=day&from=2026-08-01&to=2026-09-01"
Requires system/analytics: read. There is no parameter that points this at another workspace — it always answers for yours.
Operators (super-admins) get two more, for every workspace at once and for one in detail:
curl -s -H "Authorization: Bearer $TOKEN" \
"https://your-instance/api/v1/system/usage/tenants?granularity=day"
curl -s -H "Authorization: Bearer $TOKEN" \
"https://your-instance/api/v1/system/usage/tenants/<project-id>?granularity=hour"
granularity is hour or day; from and to accept a Unix timestamp or an ISO date. A range wider than 800 periods is refused rather than trimmed, because a trimmed answer looks exactly like a complete one. Ask for day instead.
#Reading a gap
This is the part worth understanding before you rely on a figure.
The ledger never writes a zero it did not measure. If the hourly job could not reach Cloudflare's analytics — no API token, no analytics scope, a transient failure — it records nothing for that hour rather than recording zeros. So every response carries a coverage block:
{ "periodsWithData": 18, "periodsInRange": 24, "sources": ["wae"] }
Eighteen of the twenty-four hours in the range were measured. The total is a total of those eighteen. Had the missing six been written as zeros, the same total would have looked like a complete measurement of a quiet day — and nothing in the response would have said otherwise.
sources tells you how each figure was obtained:
wae— measured and corrected for Cloudflare's analytics sampling.wae-raw— measured, but the sampling correction was unavailable. These figures are an undercount whenever your instance is busy enough to be sampled. If you see this, it is worth reporting.rollup-hour— a daily figure summed from that day's hourly figures.live-sum— counted directly out of the database, not from analytics. Storage only.
A period whose closed is false has not finished. Its figures will still move.
#When it fills in
The usage-rollups task on the hourly maintenance tick. Each run records the last few complete hours rather than only the one just finished, because analytics data arrives with a lag and re-recording an hour replaces it rather than adding to it. That also means the ledger survives a few missed ticks — but not many. If the tick stops for longer than six hours, those hours are lost for good, since the source they came from does not keep them either.
Hourly detail is kept for 35 days, which covers any billing month. Daily figures are kept indefinitely.
#If everything is empty
Two ordinary causes:
- No
CLOUDFLARE_API_TOKEN, or a token without the Account Analytics scope. Request metrics come from Cloudflare and there is no local substitute. Storage still records, because it is counted out of your own database. - The instance is new. The first figures appear after the first tick that follows a complete hour.
Check System → Monitor → Health for the usage-rollups task: a tick that could not measure says so there, rather than failing silently.
#Limits
An operator can set a limit on any of the counters above, in one of three modes:
monitor— record it and alert. Nothing is blocked. This is the default and the safe starting point.soft— the same, but the workspace is flagged as over. Still nothing is blocked.hard— requests that would spend the metric are refused with a 429 until the period resets.
Set them on Settings → Tenants, with the speedometer button on a workspace's row. The panel lists every metric that can be limited, and beside each box it shows what that workspace actually used — its busiest single day and its 30-day total — because those are the two numbers a sensible limit is chosen from. Leave a box empty for no limit on that metric.
If the ledger has no figure for a metric, the panel shows a dash rather than a zero. That distinction matters here more than anywhere else on the screen: a zero invites a limit set far too low, when the real situation is that nothing was measured.
Or over the API:
curl -s -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"usageLimits":{"requests":{"limit":1000000,"period":"month","mode":"monitor"}}}' \
"https://your-instance/api/v1/system/tenants/<project-id>"
Send "usageLimits": null to clear them and return the workspace to unlimited, which is where every workspace starts.
Anyone who can read usage can see where they stand:
curl -s -H "Authorization: Bearer $TOKEN" "https://your-instance/api/v1/system/usage/limits"
Each entry reports a state:
ok— measured, and under the limit.warning— measured, and at 80% or more.exceeded— measured, and at or over the limit. On ahardlimit this is where refusals start; on the others it is the signal to act.unknown— not measured. Not the same asok, and deliberately not shown asok. Nothing was recorded for this window, so the limit has not been tested. Check theusage-rollupstask.
period is day or month, and month means the calendar month — what a bill covers — not a rolling thirty days.
#Why you cannot set a storage limit here
Storage already has one. Each workspace's storage quota is enforced when a file is uploaded, and an upload over the quota is refused outright — the one limit on this page that actually blocks something. Setting a second storage number here would give your instance two answers to the same question, enforced in different places, drifting apart the first time either was edited. The limits view reports storage from the quota that does the enforcing, so the picture is still complete.
#Where you change the storage quota
Settings → Storage & Files, at the bottom of the page. It shows what is currently stored beside the ceiling, so you can see a quota that no longer matches reality rather than discovering it as a refused upload.
The default is 5 GB per workspace. That is small enough that a real import meets it, and the symptom is unhelpful: each file past the line is refused on its own, with nothing saying the workspace as a whole is full. If an import is failing partway through, check this number first.
Only a super admin can change it. system/tenants is deliberately absent from the role permissions, so no role grant can be given it — a workspace admin who could raise their own ceiling would not be limited by it. The box is visible to admins and read-only for them.
#What hard will and will not do
Three things are worth knowing before you set one.
It never blocks on a figure it does not have. A metric that could not be measured reads unknown, and unknown is never treated as over. If it were, an outage in the metering would become an outage in your instance — triggered by the very thing that stops you seeing why.
It lags by up to an hour. Enforcement reads the usage ledger, and the ledger is written hourly. A workspace that blows a daily limit at 10:05 is not refused until the 11:00 recording catches it. That always errs towards letting traffic through rather than stopping it wrongly, but it means hard is a brake on runaway usage, not a precise cap.
Password-reset mail is never blocked. A workspace over its message limit stops sending ordinary mail, but the instance's own messages — password resets, magic links, email verification — always go out. Otherwise a message limit would lock a workspace's users out of their own accounts with no way back in. Blocked ordinary mail is queued rather than failed, and sends itself when the period resets.
A blocked workspace can still read this page's endpoints, so it can always see why it is blocked. The Tenants screen and its endpoints stay reachable too, for the same reason pointed the other way: a hard limit on your instance's own workspace refuses its ordinary API traffic, and the screen that lifts the limit must not be among the things it refuses. Setting one there is allowed, and the panel says plainly what it will do before you save.
#Who can set them
Only an operator (super-admin). A workspace admin can read its own limits and cannot change them — a workspace that could raise its own ceiling is not limited.
#Being told, rather than having to look
When a metric first passes 80% of its limit — and again when it passes 100% — the workspace's administrators get a message in the app. There is nothing to configure and no email transport required; the message appears in the Message Center.
Each threshold is announced once per period. Crossing 80% today and 100% an hour later produces two messages, because both are worth knowing. Staying over the limit for the rest of the day produces no more. A new day or month starts the cycle again.
Two things are deliberately silent:
- A metric in the
unknownstate. Nothing was measured, so there is nothing to say — and on an instance where metering is not working, an hourly "we don't know" is how alerts stop being read. The gap is visible on the Ledger tab and in theusage-rollupstask instead. - Dropping back down. Going from over the limit to merely near it does not produce a message; the earlier one already said what mattered.
If a workspace has no administrators, the crossing is still recorded — it simply reached nobody, which is a different thing from not having happened, and the record says which.
#Statements
A statement is the per-period view you would hand to someone: a fixed month or day, one line per metric, in JSON or CSV.
curl -s -H "Authorization: Bearer $TOKEN" \
"https://your-instance/api/v1/system/usage/statement?period=2026-08"
curl -s -H "Authorization: Bearer $TOKEN" \
"https://your-instance/api/v1/system/usage/statement?period=2026-08&format=csv" -o august.csv
Operators can issue one for any workspace: /api/v1/system/usage/tenants/<project-id>/statement?period=2026-08.
period is a month (2026-08) or a day (2026-08-15), and is required. A range is not a period — "the last 30 days" means something different depending on when you asked, and the point of a statement is that two people can agree on what it covers.
You can ask for the current month. The response carries "closed": false and says so in its notes; those figures will still move.
#It is not an invoice
There are no prices on a statement, and that is deliberate rather than unfinished.
Putting one there would mean multiplying these quantities by a rate table transcribed by hand from Cloudflare's pricing page — a table nothing checks and that goes stale silently. Cloudflare's own billing API cannot fix it either: its figures have no per-Worker or per-database dimension, so it cannot say how much of one shared Worker's cost belongs to one workspace. A statement carrying a confident dollar figure on that basis is the kind that gets forwarded to a customer and later withdrawn.
So a statement tells you exactly what was used, how much of the period was measured, and how the figures were obtained. What that is worth is a question for whoever knows the contract.
#Dividing a shared bill
Some of what Cloudflare charges you for cannot be attributed to one workspace by asking Cloudflare. Worker invocations are counted per script and R2 operations per bucket — one script and one bucket serve every workspace on your instance — so those figures are the instance's, not any one workspace's.
The usual way to divide such a total is by each workspace's share of traffic. That is what this endpoint measures:
curl -s -H "Authorization: Bearer $TOKEN" \
"https://your-instance/api/v1/system/usage/apportionment?period=2026-08"
Operator-only, since the answer is about every workspace at once. It returns either shares, or a refusal with a reason:
{ "usable": false, "reason": "Only 2 of 31 elapsed day(s) were measured. …" }
That refusal is the feature. A percentage calculated from two measured days out of thirty-one is arithmetically fine and describes 6% of the evidence. If it cannot give you a share worth acting on, it says so, and your honest fallback is what it was before: the figure belongs to the instance rather than to any workspace.
Two details worth knowing when you read the shares:
- Traffic that resolved to no workspace stays in the total but receives no share. Health probes and requests to unrecognised hostnames are real usage that nobody can be billed for. Leaving them out of the total would inflate every workspace's percentage instead.
- A month in progress is judged on the days that have happened. Otherwise the current month would always be refused for missing days that are still in the future.
#Checking the estimate against the real bill
The cost figures elsewhere in the product are an estimate: usage multiplied by a price table copied by hand from Cloudflare's pricing page. Rates change, transcriptions slip, and nothing in the product noticed either. This endpoint is the check:
curl -s -H "Authorization: Bearer $TOKEN" \
"https://your-instance/api/v1/system/usage/reconciliation?period=2026-08"
Operator-only, and about the whole instance rather than any workspace. It compares what your ledger implies you should have been charged against what Cloudflare's billable-usage API says you actually were — so one call tests the price table and the ledger together.
It needs CLOUDFLARE_ACCOUNT_ID and a token carrying account-level billing read, set as CLOUDFLARE_BILLING_TOKEN.
That is deliberately a separate variable from CLOUDFLARE_API_TOKEN. Billing read is a different permission from the ones database provisioning needs, so a provisioning token will verify as active and still return 403 here — and replacing it with a billing token breaks provisioning. Set both, or set neither. (If your single token happens to carry billing, CLOUDFLARE_API_TOKEN is used as a fallback.)
Without usable credentials you get your estimate and a plain statement that nothing checked it:
{ "ok": false, "reason": "not_configured", "estimated": { "D1": 3.5, "Workers": 4 } }
#Reading the answer
Five outcomes look identical if you round them to a number, and each asks something different of you:
| What you see | What it means |
|---|---|
difference and ratio on a family |
A real comparison. A large ratio means the price table has drifted. |
billed: null, family in unbilledFamilies |
Cloudflare returned no cost for it. Costs are documented as not yet populated pending billing integration — this is expected today, and is not a bill of zero. |
estimated: null, family in unestimatedFamilies |
Cloudflare billed you for a product this instance prices nothing for. Real spend the estimate cannot see. See below. |
partial: true |
Some rows in a family carried no cost, so the billed figure is a partial sum and any difference against it understates the real bill. |
mappingSuspect: true |
Nothing matched on either side. Almost certainly our spelling of a Cloudflare product family, not a costing problem — check the names before investigating cost. |
#Spend the estimate cannot see
unestimatedFamilies is the one to read first, because it is the only outcome
here that makes the other numbers look better than the truth.
The totals compare only families measured on both sides. A product Cloudflare bills and this instance does not price drops out of both totals, so the report can show "estimated $10.60, billed $10.60" — perfect agreement — while several more dollars a month go unaccounted. The list names them so that agreement is never mistaken for coverage.
Measured on a real account on 15 August 2026, two families landed here: Durable Objects and Workers AI. Both are products a normal EmuView instance uses — Durable Objects back scheduled flows, rate limiting, realtime subscriptions, migrations and batch AI; Workers AI backs semantic search and the AI field types.
Neither can be estimated, and adding a price would not fix it:
- Cloudflare bills Durable Objects in GB-seconds — duration × memory. The ledger counts invocations. There is no way to turn a count of calls into a duration without knowing how long each object stayed alive.
- Cloudflare bills Workers AI in Neurons, at a rate that differs per model.
The ledger counts calls and tokens, and
ai_calls_unreportedexists because some models return no token counts at all.
So these two sit with r2_ops and storage_bytes: quantities the ledger holds
in a unit the bill is not denominated in. The honest output is estimated: null
and a name on this list, rather than a confident figure derived from the wrong
quantity.
This is settled, not outstanding. Measuring them was considered and rejected on 15 August 2026. A Durable Object can time its own request handler but not the idle window Cloudflare also bills, so any figure it produced would undercount by an unknown amount while being labelled GB-seconds. And neuron counts are not on the Workers AI binding at all — the only source is Cloudflare's own analytics, which would make this endpoint compare Cloudflare against Cloudflare and agree with itself by construction. Both would replace a visible gap with an invisible error.
If you need these numbers, Cloudflare's dashboard is the right place to read them; this endpoint's job is checking the estimate, and it cannot check what it cannot independently measure.
#Checking the family names
The product families are matched by name. As of 15 August 2026 those names are
verified against a real account for D1, R2, Workers and
Workers KV. Workers Analytics Engine is unconfirmed — it did not appear in
that sample, but writes below the free allowance produce no billable row, so
"wrong name" and "nothing chargeable" cannot be told apart from that evidence.
If Cloudflare renames a family, every family lands on one side and the report
reads as total drift when it is really a spelling mistake — which is what
mappingSuspect is telling you.
One command prints the names your account actually uses and diffs them against ours:
node scripts/cf-product-families.mjs
This needs an account that has used the products, not one that has been billed for them: the family name is on every row whether or not the cost fields are populated.
It also needs a self-serve (pay-as-you-go) account. This release of Cloudflare's billable-usage API covers self-serve only, with Enterprise support still to come — an Enterprise account is refused with 1171 billable-usage.api.resource.insufficient_permissions, which reads like a token problem and is not one. No token change fixes it. Fix RESOURCE_FAMILY in
workers/gateway/src/lib/analytics/reconciliation.ts with anything it reports as
mismatched, and re-run it.
estimated: null on a family means the ledger cannot produce an estimate for it. Two do not, deliberately: R2 operations are one counter here but billed as class A and class B at different rates, and stored bytes are one figure covering D1 and R2 together. Splitting either would be invention, so it reports nothing rather than a guess.
Everything here is quantities and money for the instance. To divide a total between workspaces, use the apportionment endpoint above.