This recipe makes one feature, advanced reports, available on a paid plan and not on the free one. When you are done:
- an end user on Free who calls the advanced reports endpoint gets a
403, and your frontend offers the plans instead of the feature; - the same end user, once their organization is on Pro, gets the feature on the next request, without a restart or a deploy of your backend.
Nothing in your code names a plan. Your backend asks whether the caller's organization has the capability advanced_reports; which plans grant it is decided in the catalog, and can change without touching code.
Before you start
- An application with the Server SDK started in your backend, as in Connect your backend, and its module registered if you use NestJS, as in NestJS.
- For the frontend part,
@shiprev/reactset up 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 two plans
In the operator console, under Products & Plans, create a capability on the Capabilities tab, then two plans on the Plans tab. Or do the same with the Management API and an operator session:
| What | Request | Body |
|---|---|---|
| A product | POST /mgt/v1/projects/{projectId}/products | { "name": "Reports" } |
| The capability | POST /mgt/v1/projects/{projectId}/capabilities | { "key": "advanced_reports", "name": "Advanced reports", "type": "BOOLEAN" } |
| Plan Free | POST /mgt/v1/projects/{projectId}/plans | { "product_id": "<product>", "name": "Free", "currency": "USD", "price_amount": 0, "billing_interval": "MONTHLY" } |
| Plan Pro | POST /mgt/v1/projects/{projectId}/plans | the same with "name": "Pro" and "price_amount": 20 |
| Grant the feature in Pro | PUT /mgt/v1/projects/{projectId}/plans/{planId}/versions/1/rules | { "rules": [{ "capability_id": "<capability>", "allowance": 1, "reset_interval": "NEVER" }] } |
| Publish both | POST /mgt/v1/projects/{projectId}/plans/{planId}/versions/1/publish | none |
Free gets no rule for advanced_reports; that is what keeps the feature out of it. Creating a plan creates its version 1 as a draft, which is why the rules and the publish address version 1.
A feature that is on or off still needs an allowance in the rule. Your backend asks with quantity: 0, which is allowed whatever is left, so the number does not limit anything; 1 is what the console proposes for a BOOLEAN capability. type only labels the capability; rulings treat both types alike (Capabilities).
To let end users start on Free without a card, set no_card_required on the Free plan with PATCH /mgt/v1/projects/{projectId}/plans/{planId} { "no_card_required": true }; see Subscribe without a card.
2. Refuse the feature in your backend
Rule on the capability before the work, for the organization in the verified session token. With NestJS, name the requirement on the route:
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import { type TokenClaims } from '@shiprev/server-sdk-node';
import { RequireCapability, ShipRevAuthGuard, ShipRevCapabilityGuard } from '@shiprev/server-sdk-node/nest';
@Controller('api/advanced-reports')
@UseGuards(ShipRevAuthGuard, ShipRevCapabilityGuard)
export class AdvancedReportsController {
@Get()
@RequireCapability('advanced_reports', 'read', 0) // on the route, not the controller: see NestJS
list(@Req() request: { authClaims: TokenClaims }) {
return buildAdvancedReports(request.authClaims.organization_id);
}
}The capability guard answers a refusal with 403 before the handler runs. Without NestJS, make the same ruling yourself:
const ruling = await shiprev.adjudicateAsync({
organization_id: claims.organization_id, // from verifyToken(), never from the request
capability_key: 'advanced_reports',
quantity: 0,
operation: 'read',
});
if (!ruling.allowed) return reply(403, { reason: ruling.reason });quantity: 0 asks only whether the capability is granted and uses nothing; 'read' keeps the feature working from the last snapshot while your application's region is unreachable. If the feature changes data or costs you money, rule with 'write' instead and decide what to do while offline; see Read or write.
What an organization on Free gets back:
| Method | allowed | reason | source |
|---|---|---|---|
adjudicate() | false | capability_not_granted | client_default |
adjudicateAsync() | false | capability_not_found | authoritative |
adjudicate() refuses from memory because the organization's snapshot has no entry for the capability; adjudicateAsync() asks the region, which confirms that the plan does not include it. Both are refusals of the same kind; Rulings lists how to answer each reason.
3. Show the feature, or the way to it, in your frontend
Hiding a feature is for display; the ruling on the route in step 2 is what enforces it. Let your backend tell the frontend which features the organization has, from rulings with quantity: 0:
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import { ShipRevClient, type TokenClaims } from '@shiprev/server-sdk-node';
import { InjectShipRevClient, ShipRevAuthGuard } from '@shiprev/server-sdk-node/nest';
@Controller('api/features')
@UseGuards(ShipRevAuthGuard)
export class FeaturesController {
constructor(@InjectShipRevClient() private readonly shiprev: ShipRevClient) {}
@Get()
features(@Req() request: { authClaims: TokenClaims }) {
const has = (capability_key: string) =>
this.shiprev.adjudicate({ organization_id: request.authClaims.organization_id, capability_key, quantity: 0, operation: 'read' })
.allowed;
return { advanced_reports: has('advanced_reports') };
}
}In the frontend, show the feature when it is granted and the plans when it is not. PricingTable lists your published plans with what each grants, marks the current one, and starts checkout or a plan change when the end user picks another:
import { PricingTable } from '@shiprev/react';
export function AdvancedReports({ features }: { features: { advanced_reports: boolean } }) {
if (features.advanced_reports) return <AdvancedReportsView />;
return (
<section>
<p>Advanced reports are part of Pro.</p>
<PricingTable successUrl="/reports?upgraded=1" cancelUrl="/reports" />
</section>
);
}How choosing a plan behaves, with and without an active subscription, is in Show it in your frontend.
4. Try it
Rehearse with a test end user before real customers see it:
- Sign up a test end user on your brand domain. Their organization is created with them as owner.
- Subscribe it to Free with
POST /public/v1/subscriptions/free, the end user's session and the Freeplan_version_idfromGET /public/v1/catalog/plans. In@shiprev/react0.1.0-beta.0,PricingTablesends every first subscription to checkout, so make this call from your own code or your sign-up flow. - Call your endpoint with the end user's access token. Expect
403, and{ "advanced_reports": false }from/api/features. - Move the organization to Pro. With a payment connection in test mode, choose Pro in
PricingTableand pay with a Stripe test card. Without one, checkout is refused withPAYMENT_PROVIDER_NOT_CONFIGURED, and you can move the subscription by hand withPOST /mgt/v1/projects/{projectId}/subscriptions/{subscriptionId}/change-planand{ "plan_version_id": "<Pro version>" }. - Call your endpoint again, without restarting anything. It answers
200: the region pushed the organization's new entitlements to your process, and the ruling isallowedwithsourcerealtime.
Variations
- Several features in one paid plan. Give each feature its own capability and a rule in each plan that includes it. Your code keeps asking per capability; which plan includes what stays in the catalog.
- A paid feature with a monthly quota. Use a
COUNTcapability and rule with the quantity the call uses; see Monthly allowance with an upgrade prompt. - Move the feature to another plan later. Publish a new version of each plan concerned with the rule added or removed. Organizations get the change when they subscribe to or move to the new version; see Change a price without touching code.