Entitlements and Grants
The billing system uses a grant-based entitlement model. Instead of checking subscription status or provider-specific fields, the app checks for capabilities.
The billing system uses a grant-based entitlement model. Instead of
checking subscription status or provider-specific fields, the app checks for
capabilities — abstract tokens like feature.pro or
workspace.members.invite that are granted and revoked by billing events.
How Grants Work
- A billing event arrives (webhook from Polar/Stripe/Lemon Squeezy, or a manual grant).
- The event handler calls
syncBillingGrants()with:- The organization ID
- The plan key
- An action (
"grant"or"revoke") - The capability keys from the plan's catalog entry
syncBillingGrants()creates or updates rows in thebilling_grantstable.- When a permission check runs,
resolveOrganizationCapabilities()collects all active grants for the organization and returns the set of capabilities.
Grant Lifecycle
Webhook Event → syncBillingGrants("grant") → billing_grants rows created
(active, with expiresAt)
Subscription canceled → syncBillingGrants("revoke") → revokedAt set on rows
Subscription renewed → new webhook → syncBillingGrants("grant") → rows
refreshed with new expiresAtGrant Properties
Each grant row has:
| Field | Description |
|---|---|
capabilityKey | The capability this grant provides |
source | Unique source identifier (e.g. stripe:subscription:sub_123) |
sourceType | "subscription", "one_time", or "manual" |
provider | Which provider created it |
expiresAt | When the grant expires (null for lifetime) |
revokedAt | When the grant was revoked (null if active) |
A grant is active when:
revokedAtisnull, ANDexpiresAtisnull(lifetime) or in the future
Idempotency
Webhook events can arrive out of order or be retried. The system handles this
with the billing_event_sources table:
- Each source tracks the
lastEventIdandlastEventTimestamp. - Duplicate events (same ID) are ignored.
- Stale events (older timestamp) are ignored.
- The handler returns
"ignored_duplicate"or"ignored_stale"without modifying grants.
Capability Keys
Capabilities are defined in permissions/capabilities.ts:
| Key | Purpose |
|---|---|
feature.pro | Unlocks Pro-tier features |
workspace.members.invite | Allows inviting members |
workspace.members.limit.10 | Caps workspace at 10 members |
workspace.members.limit.unlimited | No member cap |
billing.portal | Access to billing management portal |
usage.ai.generate | (Reserved for AI features) |
Checking Capabilities
In a Convex function, capabilities are checked through the permission system:
// Permission with capability requirement
const actor = await requireAppPermission(ctx, {
permission: "feature.pro.use",
});
// This checks: role has feature:["pro.use"] AND org has "feature.pro" capability
// Direct capability check
const { capabilities } = actor;
if (capabilities.has("feature.pro")) {
// Pro features available
}Member Limits
The getMemberLimitFromCapabilities() helper reads the member limit from
capabilities:
const limit = getMemberLimitFromCapabilities(actor.capabilities);
// Returns 10, "unlimited", or null (no invite capability)The memberLimitNotExceeded resource policy uses this to block invitations
when the limit is reached.
Next Reads
- Manual Grants — granting capabilities without a payment provider.
- Billing Catalog — plan definitions.
- Webhooks — how webhooks trigger grants.