Recipes

Monthly allowance with an upgrade prompt

Give the free plan 100 uses a month and a paid plan more. Rule and report on every use in your backend, show what is left, and offer a larger plan when the allowance runs out.

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/react with 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:

The capability
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" }
The rule of each plan's first version
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:

exports.controller.ts
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

ExportRulingWhat happens
1st of the monthallowed, remaining 100, limit 100The export runs and one unit is reported
100thallowedThe export runs; the allowance is now used up
101strefused, reason quota_exhausted, remaining 0403; nothing runs and nothing is reported
First export after the upgrade to Proallowed, remaining 4,900, limit 5,000The 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:

ExportButton.tsx
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

  1. Sign up a test end user and subscribe their organization to Free with POST /public/v1/subscriptions/free.
  2. Export 100 times. UsageMeter shows the allowance used up; GET /public/v1/usage/summary with the same session shows used and remaining for report_exports, with period_start and period_end.
  3. Export once more, a few seconds after the last one. Expect 403 and the plans in place of the button.
  4. 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 with remaining 4,900.

Variations

  • Charge for usage beyond the allowance instead of refusing it. Set ALLOW_AND_BILL and a base_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.

Where to go from here

© 2026 ShipRev