<!-- section: Platform · status: building · source: docs/platform/02-usage-and-billing.md -->

> How services report what they used, why gauges and counters need different plumbing, and how limits are enforced.


# Usage and billing

Services report. The platform prices. Neither knows the other's job — a service
has no idea what a gigabyte costs, and the platform has no idea what a gigabyte
is — and keeping it that way is what lets a rate change without a deploy and a
service be added without touching the billing path.

## The one interface

Every service implements `meter`, and it takes a window:

```ts
meter(projectId: string, window: { since: Date; until: Date }): Promise<UsageReading[]>
```

`until` is exclusive, so two consecutive windows cannot both claim the boundary.

The nightly job walks the project list, calls `meter` on every running service,
and prices what comes back **by metric name**:

```ts
const RATES = {
  "files.storage":    storageCostCents,
  "files.bandwidth":  bandwidthCostCents,
  "files.requests":   requestCostCents,
  "files.transforms": transformCostCents,
  "auth.users":       userCostCents,
};
```

That string is the whole join between the two halves, which is a real risk: a
rename on either side prices nothing, charges nobody, and looks exactly like a
quiet month. Two things guard it. A metric reported with no rate is logged as an
error and counted in the job's result rather than silently treated as free, and a
unit test asserts the two lists still match in both directions.

**This replaced a billing job that imported the files service directly** and ran
the rate functions inline. It worked because there was one service, and the
second would have been added the same way.

## Two kinds of usage

Conflating these is the single most expensive mistake available here.

### Gauges

A **current value**: storage bytes, seats, active users.

A missed reading costs nothing, because the next one is absolute and
self-correcting. There is no accumulation to get wrong, so a gauge carries no
idempotency key — there is nothing a repeat could double.

```ts
{ metric: "files.storage", kind: "gauge", value: 8_402_113, unit: "bytes" }
```

Storage is reported **averaged across the window**, not as today's total. A
customer who held a terabyte for one hour did not hold a terabyte for the day.

### Counters

A **total accumulated inside the window**: bandwidth served, requests handled,
renditions built.

Every counter carries an idempotency key. A report counted twice is money taken
twice, and retries are normal rather than exceptional.

```ts
{
  metric: "files.bandwidth",
  kind: "counter",
  value: 1_048_576,
  unit: "bytes",
  idempotencyKey: "files.bandwidth:proj_WXm71C:2026-08-16",
}
```

The files service used to report bandwidth as a *gauge*, which is exactly the
conflation this section warns about.

## What files reports

| Metric | Kind | Measured from |
|---|---|---|
| `files.storage` | gauge | The daily snapshot's **billable** bytes — a walk of the bucket, so renditions count, floored per file |
| `files.bandwidth` | counter | CloudFront's own access logs |
| `files.requests` | counter | The same logs |
| `files.transforms` | counter | The day-over-day *rise* in variant objects |

Two of those need explaining.

**Storage is billed on the billable figure, not the occupied one.** The per-file
minimum is what makes a million tiny files pay for the per-object overhead they
actually cost; charging the raw sum gives it away.

**Renditions are counted by their rise, because the generator tells nobody.**
Variants are built on demand and written straight to the bucket, so the only
record that work happened is that there are more objects than yesterday. Only
rises count — a day that deleted variants produces a fall, and billing a negative
transform makes no sense.

That makes a project's *first counted reading* a trap: it is a standing total,
not a day's work. It becomes the baseline and is charged nothing, which
under-bills exactly one period, once. Getting the condition wrong here billed a
dev project 139 renditions built over a fortnight as a single day.

## What auth reports

| Metric | Kind | Measured from |
|---|---|---|
| `auth.users` | gauge | `count(*)` of the project's own `user` table, taken daily and cached |

One metric, and a gauge: accounts stored. A counter would charge for the same person
again every month, which is not what storing them costs.

**Stored users, not monthly active ones**, and the reason is that this should be
boring. Activity billing needs a definition of "active", a window, and a guarantee that
whatever rows the definition reads survive long enough to be counted — three moving
parts, each able to change a customer's bill without anybody touching the pricing. A
count of rows has none of them: it is the same number today and tomorrow, and a customer
can reproduce it with one query against their own data.

Worth reconsidering if a customer or a competitor gives a reason. Not before.

The trade it makes is that a stored-user bill has no natural decay — dormant accounts
accrue forever unless somebody removes them. That is why deleting a user is part of the
API rather than a later nicety: a customer can remove accounts from their own server,
and the count falls at the next nightly count. Without that, this pricing model would
only ever ratchet up.

The count is cached rather than taken live, because the users live in a database per
project and those scale to zero: counting live would start a compute per project every
time anybody asked, including a console page load. The `auth-users` job wakes each one
once a night; `meter` reads the figure. A count more than 36 hours old is reported in
the job's result, because a silently failing counter *under*-bills and nobody complains
about that direction.

### The $1 project minimum

A project with auth provisioned is charged `max($1, users × $0.02)` a month.

A database per project is what makes one customer's users structurally unreachable from
another's. It is also a Postgres compute that costs us something at zero users, so
usage-only pricing loses money on every quiet project — and quiet projects are the
majority of any signup list.

A **floor**, not a base fee: a project with $4 of users pays $4, not $5. At these rates
the two cross at 50 users.

It is framed as money rather than as a quantity on purpose. This was briefly implemented
as "every project counts as at least 50 users", which produces the same figure and asks
a customer to believe in fifty people who do not exist. A minimum charge is the thing
that is actually true.

**A project without auth provisioned pays nothing**, and the mechanism is worth knowing
because getting it wrong would charge the minimum to every project on the platform. The
minimum keys on a service reporting *any* reading at all, so auth returns an empty array
— not a zero — for a project it has no database for. "This project has no users" and
"this project has no auth" are different answers, and only one owes us a dollar.
Suspended projects report nothing for the same reason.

**The $1 is the least certain number in the pricing file.** It rests on what a
mostly-idle Neon project really costs on our plan, which is still an open question.
Measure it before this bills a real account.

## Monthly rates against a daily charge

A rate is quoted either **per month** or **per event**, and the difference decides
whether it is prorated:

| Basis | Metrics | Prorated |
|---|---|---|
| monthly | `files.storage`, `auth.users`, the project minimum | Yes — by the window's share of the calendar month |
| event | `files.bandwidth`, `files.requests`, `files.transforms` | No — a counter's value is already the window's total |

**This was missing, and it was a 30× overcharge.** Storage is published at $0.15 per
GB-*month*; the charging job runs *daily* and charged the full monthly figure every
night. A project holding one gigabyte paid about $4.50 a month against a rate that says
fifteen cents.

Nothing caught it because the gauge/counter split does not describe it. A gauge answers
"how much was held" — a property of the reading. This answers "over what period is the
price quoted" — a property of the rate. They looked like the same distinction and are
not.

Proration is against the **actual length of the calendar month**, not a flat 30, so a
full month of daily charges sums to exactly the monthly rate rather than 31/30 of it in
January and 28/30 in February. A test asserts that sum for four different month
lengths, including a leap February.

## Charging## Charging

Daily rather than monthly. A month of unbilled usage on a prepaid balance is a
month of work nobody has paid for, which defeats the point of prepaid.

The idempotency key is the organization and the day, so a retried run collides
instead of billing twice — which is what happens the first time the job times out
halfway through. Costs are summed **per organization**, not per project: a
customer with staging and production should see one line.

Deleted projects are excluded. They are charged up to the day they were deleted
by the run that covered it, and billing one afterwards is how a cancelled
customer gets one more invoice.

## Enforcement

The platform decides the numbers, the service compares against them. Defaults
are 100 GB and a million files per project, overridable per project in the
`limits` column, and `on_exceeded` chooses what happens at the ceiling:

- `block` — refuse further work. Correct for self-serve, where the alternative is
  an unbounded bill nobody agreed to.
- `allow_and_flag` — serve it, record it, and tell a human. Correct for a
  negotiated customer, where cutting service mid-launch costs more than the
  overage.

Checked on writes only. A read cannot make a project more expensive, so making
every list call wait on a quota lookup pays for a check that can never fire.

The entitlement is cached five minutes per process, so it is a ceiling rather
than an emergency stop; the [rate limit](/docs/platform/operating) is the thing
that acts immediately. Running out of credit blocks writes and leaves reads
alone — a customer should still be able to fetch what they already paid to store.

## What the console shows

The billing page reads the same `usageFor` the nightly job does, so the number a
customer sees and the number they are charged are computed once. It used to run
the rate functions itself, which meant two paths that would drift the first time
either changed.

## Reconciliation

The credit balance is a cached sum of an append-only ledger, and anything derived
drifts — a crash between the insert and the update, a hand-edited row. Rebuilding
the sum is cheap and the answer is either "agrees" or a number somebody needs to
look at.

## Logs are not usage

Usage is a handful of numbers per project per day. Logs are orders of magnitude
larger, carry retention and privacy obligations, and scale with a customer's
traffic rather than with revenue.

Services own their logs and expose a query interface. The platform provides a
unified **viewer**, not unified **storage** — otherwise it quietly becomes a log
aggregation product that nobody chose to build.
