Leave Localhost logoLeave LocalhostDocs
Billing

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

  1. A billing event arrives (webhook from Polar/Stripe/Lemon Squeezy, or a manual grant).
  2. 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
  3. syncBillingGrants() creates or updates rows in the billing_grants table.
  4. 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 expiresAt

Grant Properties

Each grant row has:

FieldDescription
capabilityKeyThe capability this grant provides
sourceUnique source identifier (e.g. stripe:subscription:sub_123)
sourceType"subscription", "one_time", or "manual"
providerWhich provider created it
expiresAtWhen the grant expires (null for lifetime)
revokedAtWhen the grant was revoked (null if active)

A grant is active when:

  • revokedAt is null, AND
  • expiresAt is null (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 lastEventId and lastEventTimestamp.
  • 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:

KeyPurpose
feature.proUnlocks Pro-tier features
workspace.members.inviteAllows inviting members
workspace.members.limit.10Caps workspace at 10 members
workspace.members.limit.unlimitedNo member cap
billing.portalAccess 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

On this page