Add paid entitlements to a business feature
Connect business capabilities, plans, Stripe Checkout, and entitlement checks, then verify subscriptions and one-time purchases.
The previous two articles created a workspace and a personal saved-links API. This article makes the saved-links feature available only after purchase, covering the full flow: configure a plan → complete payment → receive entitlements → access the feature.
This article starts with a monthly subscription, then explains how to add a one-time purchase option in the final sections. Both options grant the same boolean entitlement. The business API checks whether the user has the capability, without directly checking which Stripe Price they purchased.
Before you start, complete the Stripe configuration and make sure your test payment credentials and webhook are working. All Price IDs in this article are placeholders. Replace them with real IDs from your own test environment.
1. Understand the three configuration definitions
This example uses three identifiers:
| Identifier | Location | Meaning |
|---|---|---|
savedLinks.use | config/capability.ts | The capability that the business API checks |
saved_links_access | config/entitlements.ts | The entitlement that includes this capability |
saved_links.monthly | config/products.ts | The product and plan that grant this entitlement |
Business logic checks capabilities, entitlements group capabilities together, and plans describe what a purchase grants. When you add annual billing or another way to sell the feature, you can grant the same entitlement without changing the business API.
This follows the role, capability, and entitlement model in Permissions and entitlements. The example uses a boolean capability and does not deduct usage counts. For quotas, see Design plans and entitlements.
2. Add display text
Merge recipes.savedLinks into the root object of each locale file. If recipes already exists at the root, merge its child entries.
First, add the following to locales/en.json:
"recipes": {
"savedLinks": {
"name": "Saved links",
"description": "Save and browse your personal links.",
"monthly": "Monthly access",
"month": "/month",
"oneTime": "One-time access"
}
}Then add the same structure to the other enabled locale files. For the current three languages, you can use the following text:
| Final key segment | Simplified Chinese | Traditional Chinese |
|---|---|---|
name | 收藏链接 | 收藏連結 |
description | 保存并浏览你的个人收藏链接。 | 儲存並瀏覽你的個人收藏連結。 |
monthly | 按月使用 | 按月使用 |
month | /月 | /月 |
oneTime | 一次性购买 | 一次性購買 |
The configuration below references these resource keys while retaining value for existing callers, such as configuration displays. Resource keys provide the UI translations. The English value should match the English resource.
3. Define the capability and entitlement
Merge the following savedLinks entry into the existing websiteCapabilityDefinitions object in config/capability.ts. The file already imports CapabilityType, so use that import.
savedLinks: {
use: {
type: CapabilityType.Boolean,
description: { value: 'Save and browse your personal links.', key: 'recipes.savedLinks.description' },
},
},Then merge the following saved_links_access entry into websiteEntitlementDefinitions in config/entitlements.ts:
saved_links_access: {
name: { value: 'Saved links', key: 'recipes.savedLinks.name' },
description: { value: 'Save and browse your personal links.', key: 'recipes.savedLinks.description' },
capabilities: ['savedLinks.use'],
},Keep the existing as const satisfies ... and type exports at the end of both files. The project infers available identifiers from these definitions, so misspelled keys should be caught during type checking.
4. Add a monthly product
To keep the example independent of the template's existing saavo_starter demo plans, merge the following saved_links entry into the root websiteProductDefinitions object in config/products.ts:
saved_links: {
name: { value: 'Saved links', key: 'recipes.savedLinks.name' },
description: { value: 'Save and browse your personal links.', key: 'recipes.savedLinks.description' },
plans: {
monthly: {
type: 'recurring',
interval: 'month',
intervalCount: 1,
priceId: 'price_saved_links_monthly_replace_me',
salePrice: '$9',
title: { value: 'Monthly access', key: 'recipes.savedLinks.monthly' },
billingLabel: { value: '/month', key: 'recipes.savedLinks.month' },
allowRepurchase: false,
access: {
entitlements: [{
target: 'saved_links_access',
config: {
kind: 'boolean',
priority: 100,
},
}],
},
},
},
},This example assumes that the corresponding Stripe Price charges $9 per month. The salePrice field is display text. The actual amount, currency, and billing interval come from the Price on the payment platform, and you need to align them manually.
Each Plan corresponds to one Price. To add annual billing later, add yearly under plans with its own annual Price, interval: 'year', and annual display price. Do not put prices for multiple billing intervals in the same Plan.
This example does not grant the general premium role because the saved-links feature depends only on its own entitlement. Purchasing another product should not automatically unlock saved links. Your product rules determine whether products share entitlements.
5. Make a test purchase
Restart the development server so the configuration changes take effect. Sign in to the local site and run the following in its browser console:
const response = await fetch('/api/payments/checkout/saved_links/monthly', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const result = await response.json();
console.log(response.status, result);
if (result.success && result.data.type === 'redirect') {
window.location.assign(result.data.url);
}This calls the existing Checkout API, so you do not need a new payment endpoint. The saved_links and monthly parameters must match the configuration keys. The browser does not submit an amount, a Price ID, or the entitlements to grant.
This console snippet is for verifying the purchase flow. The production purchase button should reuse the existing payment integration, handle loading, failures, and repeated clicks, and read its text from locale resources. Adding configuration does not automatically create a purchase section on the homepage that fits your product design. Check how the current purchase components read and display the product catalog.
After payment succeeds and you return to the site, wait for the webhook and subsequent event processing to finish before verifying entitlements. The success page is only the browser's return destination. Do not grant entitlements directly from that page.
6. Protect the saved-links endpoints
Add this import to src/api/saved-links/index.ts, which you created in the previous tutorial:
import { authz } from '@/authz';In both the creation and list handlers, add the following immediately after the failure handling for authenticatedGuard and before calling the business service:
if (!(await authz.hasEntitlementCapability(c, 'savedLinks.use'))) {
return c.json({
success: false,
code: gResultCode.authEntitlementDenied,
}, 403);
}This example treats both creation and retrieval as paid features. After a subscription expires, saved data remains in the database, but the API no longer allows it to be read. If your product allows users to keep viewing historical data, check entitlements only in the creation handler and update the page description and expected verification results accordingly.
The page can use the same check to display a purchase option, but the API check must remain. If other entry points can call this business operation, such as a background job or another API, apply an appropriate authorization check at each trusted entry point as well.
Paid entitlements do not replace resource ownership checks. Keep the user_id filter in saved-link queries. Paying users can still access only their own data.
7. Add a one-time purchase option
If your product needs one-time purchases, merge the following one_time entry into saved_links.plans and associate it with a one-time Price:
one_time: {
type: 'one-time',
priceId: 'price_saved_links_one_time_replace_me',
salePrice: '$99',
title: { value: 'One-time access', key: 'recipes.savedLinks.oneTime' },
allowRepurchase: false,
access: {
entitlements: [{
target: 'saved_links_access',
config: {
kind: 'boolean',
priority: 100,
},
}],
},
},Use this endpoint for the corresponding test:
POST /api/payments/checkout/saved_links/one_timeA one-time plan does not specify interval or intervalCount. It describes how payment is collected. Whether the entitlement remains valid long term depends on the grant configuration and subsequent business rules. Do not advertise lifetime access solely because a plan is one-time.
The allowRepurchase: false setting restricts repeat purchases after a purchase has completed. It does not prevent multiple Checkout Sessions from being created before payment completes. The UI still needs to prevent repeated clicks, and this field is not an idempotency guarantee for payment requests.
If you sell both monthly and one-time plans, decide whether users who have already made a one-time purchase should still see the subscription option. Implement that business rule consistently in the purchase UI and server-side purchase rules. This example does not add rules that make the plans mutually exclusive. You can launch with one option and add the second later.
8. Verify the complete flow
Use a test account and test payment environment to check each case:
| Scenario | Expected result |
|---|---|
| Request the saved-links API while signed out | Returns 401 |
| Sign in without the saved-links entitlement | Returns 403 |
| Cancel Checkout without completing payment | No entitlement is granted |
| Complete a monthly payment and finish webhook processing | The user can create and read their own saved links |
| Manually open the payment success page | This does not grant an entitlement |
| Set the subscription to cancel at the end of the billing period | Verify access against its validity period rather than treating scheduled cancellation as immediate expiration |
| The subscription has ended and its status has synchronized | The monthly entitlement expires. The API returns 403 if no other valid grant source exists |
| Complete a one-time purchase | The configured entitlement is granted, with no change to the business capability check |
| Receive the same payment event again | Check existing payment and event processing records to confirm there are no duplicate business results |
Recording a refund and revoking entitlements are separate business operations. The current flow does not guarantee that a refund automatically revokes all entitlements. Before release, review and test the handling against your refund rules. See Orders and payments.
If payment appears successful but the API still returns 403, check the Price's environment, webhook delivery, event processing records, the plan's access configuration, and the capability key, in that order. Do not bypass entitlement checks to hide synchronization problems.
After changing the configuration and API, run npm run lint, npm run typecheck, and npm run build, then perform the payment checks above. Type checking can catch configuration key errors. Testing actual payments is necessary to verify callbacks and the entitlement flow.