This recipe meters one action, a report export. Free includes 100 exports a month and Pro 5,000. When you are done:
- each export is ruled on before it runs and counted after it ran;
- the 101st export of the month on Free is refused with
quota_exhausted, and your frontend shows the end user what they used and the plans that include more; - the allowance starts again at the next period, and an upgrade to Pro raises it at once, without a restart of your backend.
Before you start
- An application with the Server SDK started in your backend; with NestJS, the module registered as in NestJS.
@shiprev/reactwith the provider and the forwarding path, as in React.- Allow self sign-up turned on, under Settings, Project, Customer sign-in. It is off by default, and until it is on no end user can create an account, not even an invited one; see Who may create an account.
1. Define the capability and the allowances
Create a COUNT capability with the overage policy Prompt to upgrade, and give each plan a rule with its monthly allowance. In the operator console this is Products & Plans; with the Management API:
POST /mgt/v1/projects/{projectId}/capabilities
Authorization: Bearer <operator session token>
Content-Type: application/json
{ "key": "report_exports", "name": "Report exports", "type": "COUNT", "overage_policy": "PROMPT_UPGRADE" }PUT /mgt/v1/projects/{projectId}/plans/{planId}/versions/1/rules
Authorization: Bearer <operator session token>
Content-Type: application/json
{ "rules": [{ "capability_id": "<capability>", "allowance": 100, "reset_interval": "MONTHLY" }] }Use "allowance": 5000 for Pro, then publish both versions with POST /mgt/v1/projects/{projectId}/plans/{planId}/versions/1/publish. The plans themselves are created as in Gate a feature by plan.
PROMPT_UPGRADE refuses beyond the allowance exactly as BLOCK does; the name records that your application answers the refusal with an upgrade offer. The capability has no overage price (base_unit_price defaults to 0), so nothing beyond the allowance can be paid for from a wallet; see Overage policies.
MONTHLY makes the allowance start again every month, counted from the day the subscription became active. The period and the reset are described in The period of an allowance.
2. Rule before the export, report after it
The export uses one unit of the allowance, so it is a write with quantity: 1. With NestJS, the capability guard rules before the handler, and the handler reports the unit once the work is done:
import { Controller, HttpCode, Post, Req, UseGuards } from '@nestjs/common';
import { ShipRevClient, type TokenClaims } from '@shiprev/server-sdk-node';
import {
InjectShipRevClient,
RequireCapability,
ShipRevAuthGuard,
ShipRevCapabilityGuard,
} from '@shiprev/server-sdk-node/nest';
import { randomUUID } from 'node:crypto';
@Controller('api/exports')
@UseGuards(ShipRevAuthGuard, ShipRevCapabilityGuard)
export class ExportsController {
constructor(@InjectShipRevClient() private readonly shiprev: ShipRevClient) {}
@Post()
@HttpCode(200)
@RequireCapability('report_exports', 'write', 1)
async export(@Req() request: { authClaims: TokenClaims }) {
const file = await runExport(request.authClaims.organization_id);
await this.shiprev.reportUsage({
usage_event_id: randomUUID(),
organization_id: request.authClaims.organization_id,
capability_key: 'report_exports',
quantity: 1,
occurred_at: new Date().toISOString(),
});
return file;
}
}Report only what ran: an export that failed before producing anything should not be reported. usage_event_id is your unique ID for the event; if you retry a report, reuse it and it counts once.
The guard answers the 101st export with 403 and the message Capability report_exports not allowed: quota_exhausted. To give your frontend a reason field it can test, rule in the handler instead, as the EntitlementsService in NestJS does; it answers 403 with { "reason": "quota_exhausted", "capability_key": "report_exports" }.
What the end user's exports look like
| Export | Ruling | What happens |
|---|---|---|
| 1st of the month | allowed, remaining 100, limit 100 | The export runs and one unit is reported |
| 100th | allowed | The export runs; the allowance is now used up |
| 101st | refused, reason quota_exhausted, remaining 0 | 403; nothing runs and nothing is reported |
| First export after the upgrade to Pro | allowed, remaining 4,900, limit 5,000 | The 100 used this period stay counted against the new allowance |
remaining is what was left before the call. A ruling reads the allowance and reserves nothing, and usage reaches the region in batches every few seconds, so requests made in quick succession can all be allowed against the same remaining. With 95 exports used, 20 exports sent at the same moment can all be allowed. All 20 are counted, but only 5 fit in the allowance: used stops at 100, UsageMeter shows 100 of 100, and the 15 beyond it are recorded as overage left unpaid, because the capability has no overage price. The next export is refused with quota_exhausted. After an upgrade to Pro, remaining is 4,900: the 15 are not counted against the new allowance. A ruling holds nothing explains why. Where an overrun must not happen, reserve the unit instead; see Reserve, confirm and release.
3. Show what is left and offer the upgrade
UsageMeter shows, per capability, what is used out of the allowance in the current period. When your backend answers quota_exhausted, swap the action for the meter and the plans:
import { PricingTable, UsageMeter } from '@shiprev/react';
import { useState } from 'react';
import { api } from './api'; // calls your backend with the end user's access token
export function ExportButton() {
const [limitReached, setLimitReached] = useState(false);
const [exports, setExports] = useState(0);
async function exportReport() {
const response = await api('/api/exports', { method: 'POST' });
if (response.ok) setExports((n) => n + 1);
else if (response.status === 403 && (await response.json()).reason === 'quota_exhausted') setLimitReached(true);
}
if (limitReached) {
return (
<section>
<p>You have used this month's exports. Pick a larger plan to keep exporting.</p>
<UsageMeter capabilityKeys={['report_exports']} />
<PricingTable successUrl="/exports?upgraded=1" cancelUrl="/exports" />
</section>
);
}
return (
<>
<button onClick={exportReport}>Export</button>
<UsageMeter key={exports} capabilityKeys={['report_exports']} />
</>
);
}UsageMeter reads the usage summary when it mounts; changing its key after each export makes it read again. The api helper is in React. The figures are the region's: they move once the reported usage has been counted, a few seconds after the export.
4. Try it
- Sign up a test end user and subscribe their organization to Free with
POST /public/v1/subscriptions/free. - Export 100 times.
UsageMetershows the allowance used up;GET /public/v1/usage/summarywith the same session showsusedandremainingforreport_exports, withperiod_startandperiod_end. - Export once more, a few seconds after the last one. Expect
403and the plans in place of the button. - Move the organization to Pro, through checkout in Stripe's test mode, or by hand with
POST /mgt/v1/projects/{projectId}/subscriptions/{subscriptionId}/change-plan. The next export is allowed withremaining4,900.
Variations
- Charge for usage beyond the allowance instead of refusing it. Set
ALLOW_AND_BILLand abase_unit_price; calls beyond the allowance are then allowed while the organization's wallet covers them. See Overage policies. - A one-off allowance, such as a trial. Use
"reset_interval": "NEVER"; the allowance is granted once and never starts again. - Exports of several rows at once. Rule and report with the number of rows as the quantity, ruling in the handler where the quantity comes from the request; see Protect routes.