Polar
Payments and subscriptions with Polar and Better Auth.
Overview
Payment and subscription system with Better Auth's Polar plugin:
| Feature | Description |
|---|---|
| Checkout | Payment flows |
| Subscriptions | Recurring billing management |
| Portal | Customer self-service |
| Usage billing | Event-based metering |
| Webhooks | Real-time billing events |
Prerequisites
- Auth Server configured
- Polar account with products created at Polar Dashboard
- Environment variables configured
1. Install Dependencies
npm install @polar-sh/better-auth @polar-sh/sdk bufferpnpm add @polar-sh/better-auth @polar-sh/sdk bufferyarn add @polar-sh/better-auth @polar-sh/sdk bufferbun 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 { createPolarCustomerCaller } from './generated/polarCustomer.runtime';
import { createPolarSubscriptionCaller } from './generated/polarSubscription.runtime';
import { defineAuth } from './generated/auth';
export default defineAuth((ctx) => ({
// ... 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;
const caller = createPolarCustomerCaller(ctx);
await caller.updateUserPolarCustomerId({
customerId: payload.data.id, userId,
});
},
onSubscriptionCreated: async (payload) => {
if (!payload.data.customer.externalId) return;
const caller = createPolarSubscriptionCaller(ctx);
await caller.createSubscription({
subscription: convertToDatabaseSubscription(payload.data),
});
},
onSubscriptionUpdated: async (payload) => {
if (!payload.data.customer.externalId) return;
const caller = createPolarSubscriptionCaller(ctx);
await caller.updateSubscription({
subscription: convertToDatabaseSubscription(payload.data),
});
},
}),
],
}),
],
}));Customer Creation via Trigger
Create Polar customer asynchronously when user signs up:
import { defineAuth } from './generated/auth';
import { createPolarCustomerCaller } from './generated/polarCustomer.runtime';
export default defineAuth((ctx) => ({
// ... config
triggers: {
user: {
create: {
after: async (user, triggerCtx) => {
// Create Polar customer via caller from defineAuth closure ctx
const caller = createPolarCustomerCaller(ctx);
await caller.schedule.now.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 { boolean, convexTable, defineSchema, id, index, integer, json, text } from 'kitcn/orm';
// User table - add Polar customer ID
export const user = convexTable(
'user',
{
// ... existing fields
customerId: text(), // Polar customer ID
},
(t) => [index('customerId').on(t.customerId)]
);
// Subscriptions table - organization-based
export const subscriptions = convexTable(
'subscriptions',
{
subscriptionId: text().notNull(),
organizationId: text().notNull(),
userId: id('user').notNull(),
productId: text().notNull(),
priceId: text(),
status: text().notNull(), // 'active', 'canceled', 'trialing', 'past_due'
amount: integer(),
currency: text(),
recurringInterval: text(),
currentPeriodStart: text().notNull(),
currentPeriodEnd: text(),
cancelAtPeriodEnd: boolean().notNull(),
startedAt: text(),
endedAt: text(),
createdAt: text().notNull(),
modifiedAt: text(),
checkoutId: text(),
metadata: json<Record<string, unknown>>(),
customerCancellationReason: text(),
customerCancellationComment: text(),
},
(t) => [
index('subscriptionId').on(t.subscriptionId),
index('organizationId').on(t.organizationId),
index('organizationId_status').on(t.organizationId, t.status),
index('userId_status').on(t.userId, t.status),
]
);
export const tables = { user, subscriptions };
export default defineSchema(tables, { strict: false });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
Configure checkout:
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
The webhooks() plugin verifies Polar webhook signatures and routes events to typed handler callbacks. Pass it your POLAR_WEBHOOK_SECRET and implement any of the ~20 available event handlers.
For the full list of webhook events, see API Reference below.
Convex Functions
Function examples use the ORM (ctx.orm).
Customer Management
import '../lib/polar-polyfills';
import { CRPCError } from 'kitcn/server';
import { z } from 'zod';
import { privateAction, privateMutation } from '../lib/crpc';
import { getPolarClient } from '../lib/polar-client';
// Create Polar customer (called from user.create.after trigger)
export const createCustomer = privateAction
.input(
z.object({
email: z.string().email(),
name: z.string().optional(),
userId: z.string(),
})
)
.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;
});Build additional subscription management functions (create, update, cancel, resume) following this pattern. The key conventions:
- Use
privateMutationfor webhook-triggered writes (subscription create/update) - Use
privateQueryfor internal reads (get active subscription) - Use
authActionfor user-facing operations that call the Polar SDK (cancel/resume) - Use callers to bridge between actions and queries/mutations
See the example app for complete subscription CRUD implementations.
Customer Deletion Sync
Sync user deletion with Polar:
import { defineAuth } from './generated/auth';
export default defineAuth((ctx) => ({
user: {
deleteUser: {
enabled: true,
afterDelete: async (user) => {
const polar = getPolarClient();
await polar.customers.deleteExternal({
externalId: user.id,
});
},
},
},
}));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/webhooksCommon Patterns
Check Organization Subscription
const subscription = await ctx.orm.query.subscriptions.findFirst({
where: { organizationId, status: 'active' },
});
const isActive = subscription?.status === 'active';Check User Subscription
const subscription = await ctx.orm.query.subscriptions.findFirst({
where: { userId, status: 'active' },
});
const isPremium = !!subscription;API Reference
Webhook Events
All events are passed to webhooks({ secret, ...handlers }) inside the use: [] array of the polar() plugin.
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) => {},
}),Operations
| 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.