Overview
Stripe billing integration with feature gating, subscriptions, and usage tracking
The billing system integrates Stripe ↗ for payment processing through UseAutumn ↗. UseAutumn handles subscriptions, feature gating, and usage metering while you maintain full control through simple TypeScript APIs.
Pro Tip
Define pricing tiers and features in autumn.config.ts, then enforce access with ctx.check() and ctx.track() in your Convex functions. UseAutumn manages Stripe products and customer sync automatically—no custom webhooks required.
Architecture
The billing system consists of three integrated layers:
Configuration Layer
autumn.config.ts defines your pricing tiers, features, and limits using the atmn SDK. This is your single source of truth.
import { feature, product, featureItem, priceItem } from "atmn";
// Define features
export const dashboard = feature({
id: "dashboard",
name: "Dashboard Access",
type: "boolean",
});
export const apiCalls = feature({
id: "api_calls",
name: "API Calls",
type: "single_use",
});
// Define products
export const paid = product({
id: "starterapp-demo-paid",
name: "Paid",
items: [
featureItem({
feature_id: apiCalls.id,
included_usage: 10_000,
interval: "month",
}),
featureItem({
feature_id: dashboard.id,
included_usage: 1,
}),
priceItem({
price: 10,
interval: "month",
}),
],
});Convex Integration Layer
convex/autumn.ts initializes the UseAutumn plugin and exports billing functions. All billing operations happen through Convex actions with automatic auth context.
import { Autumn } from "@useautumn/convex";
import { components } from "./_generated/api";
export const autumn = new Autumn(components.autumn, {
secretKey: process.env.AUTUMN_SECRET_KEY,
identify: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return null;
const user = ctx.db ? await ctx.db.get(identity.subject) : null;
const organizationId = user?.activeOrganizationId ?? null;
if (!organizationId) {
return {
customerId: identity.subject,
customerData: {
name: identity.name ?? user?.name ?? undefined,
email: identity.email ?? user?.email ?? undefined,
},
};
}
return {
customerId: organizationId,
customerData: {
name: user?.activeOrganizationName ?? identity.name ?? user?.name ?? undefined,
email: identity.email ?? user?.email ?? undefined,
},
};
},
});
export const { check, track } = autumn.api();Client Integration Layer
packages/app-shell/src/providers/autumn-provider.tsx connects React components to billing state. The provider wires UseAutumn's React hooks to your Convex backend.
import { useServices } from '~/lib/providers/services-context';
const { billing } = useServices();
const { customer } = billing.useCustomer();
// Trigger checkout
const result = await billing.checkout({
productId: 'starterapp-demo-paid',
successUrl: window.location.origin + '/dashboard/billing',
});
if (result.error) {
throw new Error(result.error.message);
}
if (result.url) {
window.location.assign(result.url);
}Pro Tip
Use useBillingService() when reading billing data in React. It returns { service, placeholder } so you can fall back gracefully if billing providers are unavailable.
Core Concepts
Direct userId Mapping
BetterAuth userId = Autumn customerId. No complex identity resolution.
Server-Side Gating Only
Never check features client-side. All access control happens in Convex actions.
Single Source of Truth
autumn.config.ts defines everything. Push changes with npx atmn push.
Zero Stripe Imports
Never import stripe directly. UseAutumn handles all Stripe operations.
Configuration Workflow
Always Push After Changes
Billing APIs won't reflect your changes until you push the configuration to UseAutumn's servers.
Every time you modify autumn.config.ts, run:
npx atmn push
This synchronizes your local configuration with UseAutumn's infrastructure. The push command:
- Validates your feature and product definitions
- Creates or updates Stripe products and prices
- Enables your Convex functions to enforce the new limits
# 1. Modify autumn.config.ts
# 2. Push configuration
npx atmn push
# 3. Test billing in development
pnpm dev# 1. Push from CI/CD after merging
npx atmn push --env production
# 2. Deploy Convex schema changes
npx convex deploy --prod
# 3. Monitor rollout
# UseAutumn propagates changes within secondsIdentity Mapping
The integration uses direct userId mapping for simplicity:
// In convex/autumn.ts identify function
return {
customerId: user.subject, // BetterAuth userId
customerData: {
name: user.name,
email: user.email,
},
};
This means:
- Server: Use
ctx.viewerIddirectly withctx.check()in Convex actions - Client: UseAutumn automatically resolves the current user via Convex auth
- Minimal state: The
billingCustomerstable caches Autumn customer IDs for fast lookups, but sync happens automatically
Documentation Structure
Feature Gating
Control access based on subscription plans. Server-side checks in Convex actions and Next.js pages.
Subscriptions
Recurring billing with subscription tiers. Checkout flows and billing portal integration.
Usage-Based Billing
Track consumption and charge based on actual usage. Credits, metering, and overage patterns.
One-Time Payments
Process single purchases without subscriptions. Digital products and add-ons.
AI Patterns
LLM-optimized billing context. How AI assistants understand and implement billing features.
Quick Start
Configure Products
Edit autumn.config.ts to define your pricing tiers and features.
export const premium = product({
id: "starterapp-demo-premium",
name: "Premium",
items: [
featureItem({
feature_id: apiCalls.id,
included_usage: 50_000,
interval: "month",
}),
priceItem({ price: 25, interval: "month" }),
],
});Push Configuration
npx atmn pushGate a Feature
import { userAction } from "./_helpers/builders";
export const sendMessage = userAction({
args: { body: v.string() },
handler: async (ctx, args) => {
// Check feature access
const result = await ctx.check({ featureId: "messages" });
if (!result.data?.allowed) {
throw new AppError("PLAN_REQUIRED", "Upgrade to send messages");
}
// Track usage
await ctx.track({ featureId: "messages", value: 1 });
// Perform the action
return { success: true };
},
});Trigger Checkout
'use client';
import { useBillingService } from '@workspace/app-shell/lib/hooks/use-billing';
export function UpgradeButton() {
const { service: billing, placeholder } = useBillingService();
if (placeholder) {
return null; // render a placeholder state instead
}
return (
<button
onClick={async () => {
const result = await billing.checkout({
productId: 'starterapp-demo-premium',
successUrl: `${window.location.origin}/dashboard`,
});
if (result.error) {
throw new Error(result.error.message);
}
if (result.url) {
window.location.assign(result.url);
}
}}
>
Upgrade to Premium
</button>
);
}