Add a meter and quota
A meter is the single measurement shared by quota enforcement, customer analytics, and operator diagnostics.
Development recipes · Usage and quotas
1. Choose the semantics
Select the behavior before writing code:
| Choice | Use when |
|---|---|
Counter | accepted units accumulate, such as API requests |
Gauge | current quantity can rise and fall, such as bytes stored |
ReservableCounter | admission happens before fallible work, such as an upload |
BillingPeriod | paid usage resets with projected Stripe boundaries, and monthly in UTC while FreeFallback applies |
CalendarMonth | usage resets monthly in UTC |
Never | lifetime/current state does not periodically reset |
Choose Sum, Current, or Maximum aggregation to match the commercial promise.
2. Register the meter
Add it to Saas.Meters in MyApp/appsettings.json:
{
"Key": "widgets.created",
"DisplayName": "Widgets created",
"UnitName": "widget",
"DefaultEnforcement": "HardLimit",
"Kind": "Counter",
"Reset": "BillingPeriod",
"Aggregation": "Sum",
"AllowCustomerBreakdown": true,
"AllowUserBreakdown": true
}Keys and semantics are durable. Changing what an existing meter means corrupts historical interpretation; add a new key instead.

3. Add plan allowances
Add the meter and included units to empty-state plans.json, or edit and publish plan drafts from the Plans tab at /admin/plans. null allowance means unlimited. Choose HardLimit, SoftLimit, or TrackOnly deliberately.

4. Record simple accepted work
For a single transactional operation, call ISaasManager.RecordUsage with organization, subscription, user, positive units, and a stable idempotency key. Record only after the product mutation can commit consistently, and use the same database transaction when possible.
RecordUsage runs in your transaction when you call it inside one, and it's your transaction that's run again after a deadlock. Use db.RunInTransaction() instead of db.OpenTransaction() to retry it, and keep anything outside the database, such as sending email, after it commits, as the transaction can run more than once.
The public example endpoint is POST /saas/usage, protected by api.access.

5. Reserve fallible work
For uploads or provider work that may fail after admission:
var reservation = manager.ReserveUsage(
Db, workspace, subscription, userId,
"widgets.created", units, operationId);
try
{
var actualUnits = await PerformWorkAsync();
manager.SettleUsage(Db, workspace, subscription, userId,
reservation.Id, actualUnits);
}
catch
{
manager.ReleaseUsage(Db, workspace, subscription, userId,
reservation.Id);
throw;
}Expired pending reservations are released by a recurring job. Reuse the original business-operation key on retries.
6. Verify policy edges
Always test first acceptance, idempotent replay, exact limit, over-limit rejection, unlimited allowance, soft/track-only behavior, override precedence, period rollover, reservation settle/release/expiry, and concurrent admission. Add PostgreSQL concurrency coverage when changing the aggregate algorithm.