Polar
Payments and subscriptions with Polar and Better Auth.
In this guide, we'll explore payments and subscriptions with better-convex and Polar. You'll learn to configure the Polar plugin, handle checkout flows, manage subscriptions, and process webhooks for billing events.
Overview
Payment and subscription system with Better Auth's Polar plugin:
| Feature | Description |
|---|---|
| Checkout | Seamless payment flows |
| Subscriptions | Recurring billing management |
| Portal | Customer self-service |
| Usage billing | Event-based metering |
| Webhooks | Real-time billing events |
Let's set up Polar integration step by step.
Prerequisites
- Auth Server configured
- Polar account with products created at Polar Dashboard
- Environment variables configured
1. Install Dependencies
bun add @polar-sh/better-auth @polar-sh/sdk buffer2. Server Configuration
Polyfills (REQUIRED)
Convex environment requires Buffer polyfill for Polar SDK:
import { Buffer as BufferPolyfill } from 'buffer';
globalThis.Buffer = BufferPolyfill;Polar Client Helper
import { Polar } from '@polar-sh/sdk';
export const getPolarClient = () =>
new Polar({
accessToken: process.env.POLAR_ACCESS_TOKEN!,
server:
process.env.POLAR_SERVER === 'production' ? 'production' : 'sandbox',
});Better Auth with Polar Plugin
// IMPORTANT: Import polyfills FIRST
import '../lib/polar-polyfills';
import { checkout, polar, portal, usage, webhooks } from '@polar-sh/better-auth';
import { Polar } from '@polar-sh/sdk';
import type { ActionCtx } from './_generated/server';
import { internal } from './_generated/api';
const createAuthOptions = (ctx: GenericCtx) =>
({
// ... existing config
plugins: [
polar({
client: new Polar({
accessToken: process.env.POLAR_ACCESS_TOKEN!,
server:
process.env.POLAR_SERVER === 'production' ? 'production' : 'sandbox',
}),
// Customer creation via scheduler (recommended for Convex)
// createCustomerOnSignUp: true, // Use trigger instead
use: [
checkout({
authenticatedUsersOnly: true,
products: [
{ productId: process.env.POLAR_PRODUCT_PREMIUM!, slug: 'premium' },
],
successUrl: `${process.env.SITE_URL}/success?checkout_id={CHECKOUT_ID}`,
theme: 'light',
}),
portal(),
usage(),
webhooks({
secret: process.env.POLAR_WEBHOOK_SECRET!,
onCustomerCreated: async (payload) => {
const userId = payload?.data.externalId;
if (!userId) return;
await (ctx as ActionCtx).runMutation(
internal.polarCustomer.updateUserPolarCustomerId,
{ customerId: payload.data.id, userId }
);
},
onSubscriptionCreated: async (payload) => {
if (!payload.data.customer.externalId) return;
await (ctx as ActionCtx).runMutation(
internal.polarSubscription.createSubscription,
{ subscription: convertToDatabaseSubscription(payload.data) }
);
},
onSubscriptionUpdated: async (payload) => {
if (!payload.data.customer.externalId) return;
await (ctx as ActionCtx).runMutation(
internal.polarSubscription.updateSubscription,
{ subscription: convertToDatabaseSubscription(payload.data) }
);
},
}),
],
}),
],
}) satisfies BetterAuthOptions;Customer Creation via Trigger
Create Polar customer asynchronously when user signs up:
export const authClient = createClient<DataModel, typeof schema>({
// ... config
triggers: {
user: {
onCreate: async (ctx, user) => {
// Create Polar customer via scheduler
await ctx.scheduler.runAfter(0, internal.polarCustomer.createCustomer, {
email: user.email,
name: user.name || user.username,
userId: user._id,
});
},
},
},
});3. Client Configuration
import { createAuthClient } from 'better-auth/react';
import { polarClient } from '@polar-sh/better-auth';
export const authClient = createAuthClient({
plugins: [polarClient()],
});4. Schema
import { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';
export default defineSchema({
// User table - add Polar customer ID
user: defineTable({
// ... existing fields
customerId: v.optional(v.string()), // Polar customer ID
}).index('customerId', ['customerId']),
// Subscriptions table - organization-based
subscriptions: defineTable({
subscriptionId: v.string(),
organizationId: v.string(),
userId: v.id('user'),
productId: v.string(),
priceId: v.optional(v.string()),
status: v.string(), // 'active', 'canceled', 'trialing', 'past_due'
amount: v.optional(v.number()),
currency: v.optional(v.string()),
recurringInterval: v.optional(v.string()),
currentPeriodStart: v.string(),
currentPeriodEnd: v.optional(v.string()),
cancelAtPeriodEnd: v.boolean(),
startedAt: v.optional(v.string()),
endedAt: v.optional(v.string()),
createdAt: v.string(),
modifiedAt: v.optional(v.string()),
checkoutId: v.optional(v.string()),
metadata: v.record(v.string(), v.any()),
customerCancellationReason: v.optional(v.string()),
customerCancellationComment: v.optional(v.string()),
})
.index('subscriptionId', ['subscriptionId'])
.index('organizationId', ['organizationId'])
.index('organizationId_status', ['organizationId', 'status']),
});import { v } from 'convex/values';
import { defineEnt, defineEntSchema } from 'convex-ents';
const schema = defineEntSchema({
// User table - add Polar customer ID
user: defineEnt({
// ... existing fields
})
.field('customerId', v.optional(v.string()), { index: true })
.edges('subscriptions', { to: 'subscriptions', ref: 'userId' }),
// Organization table - add subscription edge
organization: defineEnt({
// ... existing fields
}).edge('subscription', { to: 'subscriptions', ref: true }),
// Subscriptions table - organization-based
subscriptions: defineEnt({
amount: v.optional(v.union(v.number(), v.null())),
cancelAtPeriodEnd: v.boolean(),
checkoutId: v.optional(v.union(v.string(), v.null())),
createdAt: v.string(),
currency: v.optional(v.union(v.string(), v.null())),
currentPeriodEnd: v.optional(v.union(v.string(), v.null())),
currentPeriodStart: v.string(),
customerCancellationComment: v.optional(v.union(v.string(), v.null())),
customerCancellationReason: v.optional(v.union(v.string(), v.null())),
endedAt: v.optional(v.union(v.string(), v.null())),
metadata: v.record(v.string(), v.any()),
modifiedAt: v.optional(v.union(v.string(), v.null())),
priceId: v.optional(v.string()),
productId: v.string(),
recurringInterval: v.optional(v.union(v.string(), v.null())),
startedAt: v.optional(v.union(v.string(), v.null())),
status: v.string(),
})
.field('subscriptionId', v.string(), { unique: true })
.edge('organization', { to: 'organization', field: 'organizationId' })
.edge('user', { to: 'user', field: 'userId' })
.index('organizationId_status', ['organizationId', 'status']),
});5. Helper Functions
Subscription Conversion Helper
import type { Subscription } from '@polar-sh/sdk/models/components/subscription';
import type { WithoutSystemFields } from 'convex/server';
import type { Doc, Id } from '../functions/_generated/dataModel';
export const convertToDatabaseSubscription = (
subscription: Subscription
): WithoutSystemFields<Doc<'subscriptions'>> => {
// Extract organizationId from subscription metadata (referenceId)
const organizationId = subscription.metadata
?.referenceId as Id<'organization'>;
if (!organizationId) {
throw new Error(
'Subscription missing organizationId in metadata.referenceId'
);
}
return {
amount: subscription.amount,
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
checkoutId: subscription.checkoutId,
createdAt: subscription.createdAt.toISOString(),
currency: subscription.currency,
currentPeriodEnd: subscription.currentPeriodEnd?.toISOString() ?? null,
currentPeriodStart: subscription.currentPeriodStart.toISOString(),
customerCancellationComment: subscription.customerCancellationComment,
customerCancellationReason: subscription.customerCancellationReason,
endedAt: subscription.endedAt?.toISOString() ?? null,
metadata: subscription.metadata ?? {},
modifiedAt: subscription.modifiedAt?.toISOString() ?? null,
organizationId,
productId: subscription.productId,
recurringInterval: subscription.recurringInterval,
startedAt: subscription.startedAt?.toISOString() ?? null,
status: subscription.status,
subscriptionId: subscription.id,
// IMPORTANT: Use externalId, not metadata.userId
userId: subscription.customer.externalId as Id<'user'>,
};
};Checkout Plugin
Enable seamless checkout integration:
checkout({
products: [
{ productId: 'uuid-from-polar', slug: 'pro' },
{ productId: 'uuid-from-polar', slug: 'enterprise' },
],
successUrl: `${process.env.SITE_URL}/success?checkout_id={CHECKOUT_ID}`,
returnUrl: `${process.env.SITE_URL}`, // Optional back button
authenticatedUsersOnly: true,
theme: 'light', // or 'dark'
}),Client Checkout
// Using product slug
await authClient.checkout({
slug: 'pro',
referenceId: organizationId, // Links subscription to organization
});
// Using product ID directly
await authClient.checkout({
products: ['e651f46d-ac20-4f26-b769-ad088b123df2'],
referenceId: organizationId,
});Organization-Based Checkout
const handleSubscribe = async () => {
const activeOrganizationId = user.activeOrganization?.id;
if (!activeOrganizationId) {
toast.error('Please select an organization');
return;
}
try {
if (currentUser.plan) {
// User has plan - open portal to manage
await authClient.customer.portal();
} else {
// No plan - initiate checkout
await authClient.checkout({
slug: 'premium',
referenceId: activeOrganizationId,
});
}
} catch (error) {
console.error('Polar checkout error:', error);
toast.error('Failed to open checkout');
}
};Portal Plugin
Customer self-service portal for managing subscriptions:
Open Portal
await authClient.customer.portal();Customer State
const { data: customerState } = await authClient.customer.state();
// Contains:
// - Customer data
// - Active subscriptions
// - Granted benefits
// - Active meters with balanceList Benefits
const { data: benefits } = await authClient.customer.benefits.list({
query: { page: 1, limit: 10 },
});List Orders
const { data: orders } = await authClient.customer.orders.list({
query: {
page: 1,
limit: 10,
productBillingType: 'one_time', // or 'recurring'
},
});List Subscriptions
const { data: subscriptions } = await authClient.customer.subscriptions.list({
query: {
page: 1,
limit: 10,
active: true,
},
});Organization Subscriptions
const organizationId = (await authClient.organization.list())?.data?.[0]?.id;
const { data: subscriptions } = await authClient.customer.orders.list({
query: {
page: 1,
limit: 10,
active: true,
referenceId: organizationId,
},
});
const userShouldHaveAccess = subscriptions.some(
(sub) => sub.productId === process.env.NEXT_PUBLIC_POLAR_PRODUCT_PREMIUM
);Usage Plugin
Usage-based billing with event ingestion:
Event Ingestion
const { data: ingested } = await authClient.usage.ingestion({
event: 'file-uploads',
metadata: {
uploadedFiles: 12,
totalSizeBytes: 1024000,
},
});Customer Meters
const { data: customerMeters } = await authClient.usage.meters.list({
query: { page: 1, limit: 10 },
});
// Returns:
// - Customer information
// - Meter configuration
// - Consumed units, credited units, balanceWebhooks Plugin
Handle all Polar webhook events:
webhooks({
secret: process.env.POLAR_WEBHOOK_SECRET!,
// Checkout
onCheckoutCreated: (payload) => {},
onCheckoutUpdated: (payload) => {},
// Orders
onOrderCreated: (payload) => {},
onOrderPaid: (payload) => {},
onOrderRefunded: (payload) => {},
// Refunds
onRefundCreated: (payload) => {},
onRefundUpdated: (payload) => {},
// Subscriptions
onSubscriptionCreated: (payload) => {},
onSubscriptionUpdated: (payload) => {},
onSubscriptionActive: (payload) => {},
onSubscriptionCanceled: (payload) => {},
onSubscriptionRevoked: (payload) => {},
onSubscriptionUncanceled: (payload) => {},
// Products
onProductCreated: (payload) => {},
onProductUpdated: (payload) => {},
// Benefits
onBenefitCreated: (payload) => {},
onBenefitUpdated: (payload) => {},
onBenefitGrantCreated: (payload) => {},
onBenefitGrantUpdated: (payload) => {},
onBenefitGrantRevoked: (payload) => {},
// Customers
onCustomerCreated: (payload) => {},
onCustomerUpdated: (payload) => {},
onCustomerDeleted: (payload) => {},
onCustomerStateChanged: (payload) => {},
// Catch-all
onPayload: (payload) => {},
}),Convex Functions
Function examples use ctx.table (Convex Ents). For ctx.db, replace edge traversal with manual joins. See Ents for migration patterns.
Customer Management
import '../lib/polar-polyfills';
import { CRPCError } from 'better-convex/server';
import { zid } from 'convex-helpers/server/zod4';
import { z } from 'zod';
import { privateAction, privateMutation } from '../lib/crpc';
import { getPolarClient } from '../lib/polar-client';
// Create Polar customer (called from user.onCreate trigger)
export const createCustomer = privateAction
.input(
z.object({
email: z.string().email(),
name: z.string().optional(),
userId: zid('user'),
})
)
.output(z.null())
.action(async ({ input: args }) => {
const polar = getPolarClient();
try {
await polar.customers.create({
email: args.email,
externalId: args.userId, // Links Polar customer to Convex user
name: args.name,
});
} catch (error) {
console.error('Failed to create Polar customer:', error);
}
return null;
});
// Link Polar customer ID to user (called from webhook)
export const updateUserPolarCustomerId = privateMutation
.input(
z.object({
customerId: z.string(),
userId: zid('user'),
})
)
.output(z.null())
.mutation(async ({ ctx, input: args }) => {
const user = await ctx.table('user').getX(args.userId);
// Check for duplicate customer IDs
const existingUser = await ctx
.table('user')
.get('customerId', args.customerId);
if (existingUser && existingUser._id !== args.userId) {
throw new CRPCError({
code: 'CONFLICT',
message: `Another user already has Polar customer ID ${args.customerId}`,
});
}
await user.patch({ customerId: args.customerId });
return null;
});Subscription Management
import '../lib/polar-polyfills';
import { CRPCError } from 'better-convex/server';
import { zid } from 'convex-helpers/server/zod4';
import { z } from 'zod';
import { authAction, privateMutation, privateQuery } from '../lib/crpc';
import { getPolarClient } from '../lib/polar-client';
import { internal } from './_generated/api';
// Create subscription (called from webhook)
export const createSubscription = privateMutation
.input(z.object({ subscription: subscriptionSchema }))
.output(z.null())
.mutation(async ({ ctx, input: args }) => {
const existing = await ctx
.table('subscriptions')
.get('subscriptionId', args.subscription.subscriptionId);
if (existing) {
throw new CRPCError({
code: 'CONFLICT',
message: `Subscription ${args.subscription.subscriptionId} already exists`,
});
}
await ctx.table('subscriptions').insert(args.subscription);
return null;
});
// Update subscription (called from webhook)
export const updateSubscription = privateMutation
.input(z.object({ subscription: subscriptionSchema }))
.output(z.object({ updated: z.boolean() }))
.mutation(async ({ ctx, input: args }) => {
const existing = await ctx
.table('subscriptions')
.get('subscriptionId', args.subscription.subscriptionId);
if (!existing) {
return { updated: false };
}
await existing.patch(args.subscription);
return { updated: true };
});
// Get active subscription for user
export const getActiveSubscription = privateQuery
.input(z.object({ userId: zid('user') }))
.output(z.object({ subscriptionId: z.string() }).nullable())
.query(async ({ ctx, input: args }) => {
const subscription = await ctx
.table('subscriptions')
.filter((q) => q.eq(q.field('userId'), args.userId))
.filter((q) => q.eq(q.field('status'), 'active'))
.first();
if (!subscription) return null;
return { subscriptionId: subscription.subscriptionId };
});
// Cancel subscription (user action)
export const cancelSubscription = authAction
.output(z.object({ success: z.boolean() }))
.action(async ({ ctx }) => {
const polar = getPolarClient();
const subscription = await ctx.runQuery(
internal.polarSubscription.getActiveSubscription,
{ userId: ctx.userId! }
);
if (!subscription) {
throw new CRPCError({
code: 'PRECONDITION_FAILED',
message: 'No active subscription found',
});
}
await polar.subscriptions.update({
id: subscription.subscriptionId,
subscriptionUpdate: { cancelAtPeriodEnd: true },
});
return { success: true };
});
// Resume subscription (user action)
export const resumeSubscription = authAction
.output(z.object({ success: z.boolean() }))
.action(async ({ ctx }) => {
const polar = getPolarClient();
const subscription = await ctx.runQuery(
internal.polarSubscription.getActiveSubscription,
{ userId: ctx.userId! }
);
if (!subscription) {
throw new CRPCError({
code: 'PRECONDITION_FAILED',
message: 'No active subscription found',
});
}
await polar.subscriptions.update({
id: subscription.subscriptionId,
subscriptionUpdate: { cancelAtPeriodEnd: false },
});
return { success: true };
});Customer Deletion Sync
Sync user deletion with Polar:
const createAuthOptions = (ctx: GenericCtx) =>
({
user: {
deleteUser: {
enabled: true,
afterDelete: async (user) => {
const polar = getPolarClient();
await polar.customers.deleteExternal({
externalId: user.id,
});
},
},
},
}) satisfies BetterAuthOptions;Environment Variables
# Polar API Configuration
POLAR_SERVER="sandbox" # 'production' | 'sandbox'
POLAR_ACCESS_TOKEN="polar_at_..." # Organization access token
POLAR_WEBHOOK_SECRET="whsec_..." # Webhook signature secret
# Polar Product IDs (from Polar dashboard)
POLAR_PRODUCT_PREMIUM="uuid-here" # Premium subscription productLocal Development with Ngrok
Polar webhooks require a public URL. Use ngrok to expose your local server:
Setup
- Install ngrok: macOS setup guide
- Reserve a free static domain in the ngrok dashboard
- Add scripts to
package.json:
{
"scripts": {
"dev": "concurrently 'next dev' 'bun ngrok'",
"ngrok": "ngrok http --url=your-domain.ngrok-free.app 3000"
}
}- Configure the webhook URL in Polar Dashboard → Settings → Webhooks:
https://your-domain.ngrok-free.app/api/auth/polar/webhookCommon Patterns
Check Organization Subscription
const subscription = await ctx
.table('subscriptions', 'organizationId_status', (q) =>
q.eq('organizationId', organizationId).eq('status', 'active')
)
.first();
const isActive = subscription?.status === 'active';Check User Subscription
const subscription = await ctx
.table('subscriptions')
.filter((q) => q.eq(q.field('userId'), userId))
.filter((q) => q.eq(q.field('status'), 'active'))
.first();
const isPremium = !!subscription;API Reference
| Operation | Method | Type |
|---|---|---|
| Checkout | authClient.checkout | Client |
| Customer portal | authClient.customer.portal | Client |
| Customer state | authClient.customer.state | Client |
| List benefits | authClient.customer.benefits.list | Client |
| List orders | authClient.customer.orders.list | Client |
| List subscriptions | authClient.customer.subscriptions.list | Client |
| Event ingestion | authClient.usage.ingestion | Client |
| List meters | authClient.usage.meters.list | Client |
| Create customer | internal.polarCustomer.createCustomer | Internal action |
| Link customer ID | internal.polarCustomer.updateUserPolarCustomerId | Internal mutation |
| Create subscription | internal.polarSubscription.createSubscription | Internal mutation |
| Update subscription | internal.polarSubscription.updateSubscription | Internal mutation |
| Cancel subscription | Convex action | User action |
| Resume subscription | Convex action | User action |
Use Better Auth client API for checkout and portal operations. Use internal Convex functions for webhook handlers. Use user actions for subscription management.