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:

IdentifierLocationMeaning
savedLinks.useconfig/capability.tsThe capability that the business API checks
saved_links_accessconfig/entitlements.tsThe entitlement that includes this capability
saved_links.monthlyconfig/products.tsThe 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 segmentSimplified ChineseTraditional 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.

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_time

A 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:

ScenarioExpected result
Request the saved-links API while signed outReturns 401
Sign in without the saved-links entitlementReturns 403
Cancel Checkout without completing paymentNo entitlement is granted
Complete a monthly payment and finish webhook processingThe user can create and read their own saved links
Manually open the payment success pageThis does not grant an entitlement
Set the subscription to cancel at the end of the billing periodVerify access against its validity period rather than treating scheduled cancellation as immediate expiration
The subscription has ended and its status has synchronizedThe monthly entitlement expires. The API returns 403 if no other valid grant source exists
Complete a one-time purchaseThe configured entitlement is granted, with no change to the business capability check
Receive the same payment event againCheck 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.