Backend guides
Billing and entitlements
Stripe subscriptions as source of truth, feature-key entitlements that gate the API and UI by plan, and the admin screen that maps Stripe products to features. Includes a full walkthrough for adding a new entitlement.
On this page
Slicekit ships subscription billing that is Stripe-as-source-of-truth. You define products and prices in the Stripe dashboard; the app never holds a Plan aggregate. It stores only the subscription state it learns from Stripe webhooks, and it maps your own feature keys onto Stripe product ids. Those feature keys are the entitlements: they gate endpoints and UI by what the user’s plan grants.
This guide explains how the pieces fit, then walks the full loop of adding a new entitlement across the
backend, the admin screen, and the SPA. For the request pipeline that runs the entitlement filter see
authentication; for the slice anatomy see adding a vertical slice.
The agent-facing reference lives in docs/api/billing.md and docs/frontend/billing.md.
How it works
Three ideas carry the whole feature:
- Stripe owns the catalogue and the money. Products, prices, trials, coupons, invoices, and the
Customer Portal all live in Stripe. The app calls Stripe only through
IStripeGateway, so theStripe.*SDK never leaks into handlers. - Webhooks are the only writer of subscription state. Checkout and cancellation kick off a flow
in Stripe; the domain changes only when the matching webhook arrives and is verified. This keeps the
app’s
Subscriptionrows honest even if a user closes the tab mid-checkout. - Entitlements are computed, never stored per user. A user’s features are derived on demand by joining their active subscriptions to the product to feature-key mapping, then cached for two minutes.
The pieces
| Piece | Where | Role |
|---|---|---|
BillingAccount / Subscription | Slicekit.Core.Domain | One account per user; subscriptions are mutated only through the account aggregate. |
ProductEntitlement | Slicekit.Core.Domain | One row per (stripeProductId, featureKey). This is the map admins edit. |
FeatureKeys | Slicekit.Core.Billing.Contracts | The typed catalogue of every entitlement key and its kind. |
EntitlementsService | Slicekit.Core.Billing | Resolves a user’s features and limits, with a 2-minute HybridCache. |
IStripeGateway | Slicekit.Core.Billing.Stripe | The only seam to Stripe. |
StripeWebhookEndpoint | Slicekit.Api/.../Webhooks | Verifies the signature and hands off to idempotent handlers. |
billing feature | frontend/src/features/billing | Plan grid, current-plan card, admin mapping screen, entitlement hooks. |
The user flow
- Subscribe.
POST /billing/checkoutvalidates the requested price against the published catalogue, creates a Stripe Checkout session (with the configured trial and Stripe-managed promotion codes), and returns the hosted URL. The app refuses a second checkout while a subscription is already active; plan changes go through the portal. - Manage.
POST /billing/portalopens the Stripe Customer Portal where the user updates their card or changes plan. - Cancel.
POST /billing/cancelasks Stripe to cancel; the domain flips only when thecustomer.subscription.deleted/.updatedwebhook confirms it.
Each inbound webhook is verified, recorded once (the StripeWebhookEvent row is the idempotency
ledger), and dispatched to a typed handler. Transient faults retry; malformed payloads are recorded as
failed and never retried. The authoritative subscription is always re-fetched from Stripe rather than
trusting the event body.
Entitlements: flags and limits
An entitlement is a (featureKey, value?) pair. Every key declares a kind in FeatureKeys:
- Flag (
FeatureKind.Flag): a boolean. The user either has the feature or not (for examplepriority-support). - Limit (
FeatureKind.Limit): a numeric quota with a catalogueDefaultValue(the baseline when no plan grants it). Resolution takes the max across the user’s active grants, floored at the default, so a plan never drops a user below baseline.api-keys.limitis the shipped example (default 2).
EntitlementsService joins ProductEntitlement rows to the user’s subscriptions whose status is
Active, Trialing, or PastDue (past-due keeps access during Stripe’s grace/dunning window):
await entitlements.HasFeatureAsync(userId, FeatureKeys.PrioritySupport); // flag check
await entitlements.GetLimitAsync(userId, FeatureKeys.ApiKeyLimit); // resolved number
await entitlements.RequireWithinLimitAsync(userId, FeatureKeys.ApiKeyLimit, currentCount); // 409 if over
You enforce a flag on an endpoint with .RequireEntitlement(...), the entitlement-world parallel to
.RequirePermission(...):
routes.Reports().MapGet("/export", HandleAsync)
.RequireEntitlement(FeatureKeys.PrioritySupport); // 403 ForbiddenEntitlement if the plan lacks it
Permissions answer “is this user allowed to do this action” (role-based). Entitlements answer “does this user’s plan include this” (money-based). They compose: an endpoint can require both.
Add a feature entitlement
Say you are selling a “Custom branding” flag and a “5 / 25 / unlimited projects” limit. Here is the full loop.
1. Declare the key in the backend catalogue
FeatureKeys (api/src/Slicekit.Core/Billing/Contracts/FeatureKeys.cs) is the single source of truth.
Add the key and append it to All:
public static readonly FeatureKey CustomBranding = new("custom-branding");
public static readonly FeatureKey ProjectLimit =
new("project.limit", FeatureKind.Limit, DefaultValue: 5);
public static readonly IReadOnlyList<FeatureKey> All =
[
PrioritySupport,
ApiKeyLimit,
CustomBranding,
ProjectLimit,
];
Naming is lowercase, dotted for limits (project.limit). The catalogue drives validation: the admin
screen will only accept keys that exist here, and EntitlementsService.GetLimitAsync falls back to
DefaultValue for any user without a grant. No migration is needed; entitlement keys are owned by code,
and the ProductEntitlement table stores only (productId, key, value?).
2. Enforce it where the action happens
For a flag, gate the endpoint:
.RequireEntitlement(FeatureKeys.CustomBranding)
For a limit, check it inside the slice that performs the action, exactly like CreateApiKey does:
public sealed class CreateProjectCommandHandler(AppDbContext db, EntitlementsService entitlements)
{
public async Task<Result> HandleAsync(CreateProjectCommand command, CancellationToken ct)
{
var activeCount = await db.Projects.CountAsync(p => p.OwnerId == command.UserId, ct);
var withinLimit = await entitlements.RequireWithinLimitAsync(
command.UserId, FeatureKeys.ProjectLimit, activeCount, ct);
if (withinLimit.IsFailure) return withinLimit.Error; // LimitReachedError -> 409
// ... create the project
}
}
3. Mirror the key in the SPA
frontend/src/features/billing/feature-keys.ts is a hand-kept mirror of the backend catalogue. Add the
matching entries so the UI can label and gate them:
export const FEATURES: FeatureDef[] = [
{ key: 'priority-support', kind: 'flag', label: 'Priority support' },
{ key: 'api-keys.limit', kind: 'limit', label: 'API keys', defaultValue: 2 },
{ key: 'custom-branding', kind: 'flag', label: 'Custom branding' },
{ key: 'project.limit', kind: 'limit', label: 'Projects', defaultValue: 5 },
];
featureLabel('project.limit', 25) now renders “25 Projects” on the pricing grid and plan cards. Gate a
paid surface in the UI with the entitlement hook:
const { has } = useMyEntitlements();
return has('custom-branding') ? <BrandingSettings /> : <UpgradePrompt />;
The UI gate is a UX nicety; the API filter is the real boundary.
4. Map the key to a Stripe product (admin)
Entitlements are attached to Stripe products, not prices, so monthly and yearly prices of the same
plan share them. An admin does this at runtime, no deploy required (see below). In code the mapping is a
single ProductEntitlement row per (product, key), replaced wholesale by the
SetProductEntitlements command.
5. Verify
dotnet build api/slicekit.slnx # fails on a typo'd FeatureKeys.X reference
cd frontend && pnpm typecheck # fails if the mirror is missing a key you gate on
Then assign the key to a test product in /admin/billing, subscribe a test user via Stripe test mode,
and confirm GET /billing/entitlements returns the key (and the resolved limit value).
The admin screen
/admin/billing (permission Admin.BillingManageProductEntitlements) lists your live Stripe products
and, for each, renders the catalogue: a toggle per flag key and a number input per limit key. Saving
calls PUT /admin/billing/stripe-products/{id}/entitlements with the full desired set, which the
SetProductEntitlements handler validates against FeatureKeys and writes as ProductEntitlement
rows. This is how you wire “Pro grants custom branding and 25 projects” without touching code or
running a migration; the keys must already exist in the catalogue from step 1.
Admin changes are bounded by the 2-minute entitlement cache TTL rather than evicted per user, so a newly
mapped feature reaches existing subscribers within two minutes. A user’s own subscription changes
(start, update, cancel) evict their cache entry immediately via EntitlementsCacheEvictionHandler.
The read-only side of admin billing is GET /admin/users/{id}/billing (permission
Admin.BillingViewUser), which powers a summary card on the admin user-detail page with a deep link to
the customer in the Stripe dashboard. Refunds and forced cancellations are intentionally left to the
Stripe dashboard rather than rebuilt in-app.
Configuration
StripeSettings binds the Billing:Stripe section: ApiKey, WebhookSecret, PublishableKey,
ReturnUrlBase, DefaultCurrency, TrialDays (0 disables the trial), BodyRetentionDays, and
ProductMetadataKey. Billing is considered configured (and its endpoints become live) only when an API
key, a webhook secret, and an absolute return-url base are present. Supply real secrets via environment
variables
(Billing__Stripe__ApiKey, Billing__Stripe__WebhookSecret, …); the committed appsettings.json
ships placeholders only. See configuration and the settings pattern.
A Stripe account usually holds more products than you sell (test fixtures, archived experiments, other
apps). ProductMetadataKey (default slicekit_plan) filters the catalog to only products whose Stripe
metadata carries that key with value true: tag a product with slicekit_plan=true to publish it to the
pricing page, admin mapping screen, and checkout guard at once. Set the key to an empty string to list
every product instead.
Stored raw webhook bodies (which can contain customer PII) are redacted after BodyRetentionDays by a
background sweep; the idempotency rows themselves are kept.
Checklist
- Key added to
FeatureKeys(FlagorLimit+DefaultValue) and appended toAll. - Enforced:
.RequireEntitlement(...)for a flag,RequireWithinLimitAsync(...)for a limit. - Mirrored in
frontend/src/features/billing/feature-keys.ts(key, kind, label, default). - Mapped to the right Stripe product(s) in
/admin/billing. -
dotnet buildandpnpm typecheckpass;GET /billing/entitlementsshows the key for a subscriber.