Skip to main content

Documentation Index

Fetch the complete documentation index at: https://tmbv.me/llms.txt

Use this file to discover all available pages before exploring further.

This guide covers the common per-user SaaS billing path. If you bill organizations or seats with referenceId, the entity you authorize is different, and this page is not the right pattern. The core model is:
  • Better Auth owns the auth + billing glue: customer creation, checkout, portal methods, and webhook signature verification.
  • Polar owns current billing truth: read Customer State when you need to know what the user should have access to.
  • Your backend owns authorization: loaders, actions, API routes, and mutations should decide access on the server.
  • Redis / KV is infrastructure, not truth: use it for sessions, rate limits, and optional cache layers, not as the canonical source of billing state.
A useful consequence of this setup is that you usually do not need:
  • a separate user -> polarCustomerId mapping table
  • a shadow subscriptions table
  • a priceId column on user
With createCustomerOnSignUp: true, the Better Auth / Polar integration associates the customer to your Better Auth user.id through Polar externalId, so you can read billing state directly by your own user ID.

Minimal Integration

1. Configure Better Auth on the server

import { betterAuth } from 'better-auth';
import { checkout, polar, portal, webhooks } from '@polar-sh/better-auth';
import { Polar } from '@polar-sh/sdk';

import { handleCustomerStateChanged } from '../billing/webhooks';

export const polarClient = new Polar({
	accessToken: process.env.POLAR_ACCESS_TOKEN!,
	server: process.env.NODE_ENV === 'production' ? 'production' : 'sandbox',
});

export const auth = betterAuth({
	// ... your Better Auth session / database config
	plugins: [
		polar({
			client: polarClient,
			createCustomerOnSignUp: true,
			use: [
				checkout({
					products: [{ productId: '123-456-789', slug: 'pro' }],
					successUrl: '/billing/success?checkout_id={CHECKOUT_ID}',
					returnUrl: '/settings/billing',
					authenticatedUsersOnly: true,
				}),
				portal({
					returnUrl: '/settings/billing',
				}),
				webhooks({
					secret: process.env.POLAR_WEBHOOK_SECRET!,
					onCustomerStateChanged: handleCustomerStateChanged,
				}),
			],
		}),
	],
});
What this buys you:
  • In steady state, the Polar customer is addressable by Better Auth user.id through Polar externalId.
  • Checkout sessions automatically bind to the signed-in user when authenticatedUsersOnly: true.
  • The client gets portal methods under authClient.customer.*.
  • Webhook signatures are verified before your handler runs.
Two small but important notes:
  • Sandbox and production are fully separate in Polar. Tokens, products, discounts, and customers do not cross environments.
  • In the current adapter implementation, customer creation and externalId assignment happen in separate signup hooks. Treat externalId = user.id as the resulting association, not as an atomic guarantee on the very first customer create call.
  • Assuming you mount Better Auth at the default /api/auth/*, the current Polar BetterAuth adapter guide documents the webhook endpoint as /api/auth/polar/webhooks. If you customize the Better Auth base path, derive the webhook route from that mount.
  • Polar’s API docs use snake_case, but the TypeScript SDK converts fields to camelCase. That is why this page uses SDK names like externalId, grantedBenefits, and externalCustomerId.
If you always read Polar live and do not keep any app-side cache, you can omit onCustomerStateChanged entirely.

2. Start checkout from the Better Auth client

import { createAuthClient } from 'better-auth/react';
import { polarClient } from '@polar-sh/better-auth/client';

export const authClient = createAuthClient({
	plugins: [polarClient()],
});

export function UpgradeButton() {
	return (
		<button onClick={() => void authClient.checkout({ slug: 'pro' })}>
			Upgrade to Pro
		</button>
	);
}
Use authClient.checkout({ slug: 'pro' }) directly. You can also open the hosted customer portal with authClient.customer.portal(), and you can fetch customer state on the client with authClient.customer.state() when you need to render plan badges or billing UI. Still, the frontend should never be the thing that finally decides access.

3. Read Customer State on the server

import { Polar, type CustomerState } from '@polar-sh/sdk';

export async function getBillingStateForUser(
	polarClient: Polar,
	userId: string
): Promise<CustomerState | null> {
	try {
		return await polarClient.customers.getStateExternal({
			externalId: userId,
		});
	} catch (error: unknown) {
		const status =
			typeof error === 'object' && error !== null && 'status' in error
				? (error as { status?: number }).status
				: undefined;

		if (status === 404 || status === 422) {
			return null;
		}

		throw error;
	}
}
Use one helper for “read current billing truth.” That keeps the rest of the app unaware of whether the data came from a live Polar read, a short-lived cache, or a cache-miss fallback.

Model Access Around Benefits, Not “Any Subscription”

This is the biggest practical fix to the original write-up. activeSubscriptions.length > 0 is only safe if you truly mean “any active subscription at all grants the same paid access.” In many real apps that stops being true quickly:
  • you may have multiple paid tiers
  • you may have a free recurring plan
  • you may have one-time purchases that should still grant access
  • you may want access to follow benefits, not products
A stronger pattern is to attach a Feature Flag benefit to the paid product and check for that benefit in Customer State.
import { type CustomerState } from '@polar-sh/sdk';

import { polarClient } from '../auth/server';
import { getBillingStateForUser } from './state';

const PRO_BENEFIT_ID = process.env.POLAR_PRO_BENEFIT_ID!;

type GrantedBenefit = CustomerState['grantedBenefits'][number];

export function hasBenefit(
	state: CustomerState | null,
	matches: (benefit: GrantedBenefit) => boolean
) {
	return state?.grantedBenefits?.some(matches) ?? false;
}

export function hasProAccess(state: CustomerState | null) {
	return hasBenefit(state, (benefit) => benefit.benefitId === PRO_BENEFIT_ID);
}

export async function requireProUser(userId: string) {
	const state = await getBillingStateForUser(polarClient, userId);

	if (!hasProAccess(state)) {
		throw new Error('Pro plan required');
	}

	return state;
}
If you want friendlier plan keys than raw IDs, put metadata like slug: pro on the benefit and match against that instead. Polar now includes benefit metadata in Customer State, so that works cleanly. For coarse checks, matching activeSubscriptions[*].productId is also fine. For actual feature gating, benefits are the cleaner contract.

Keep /billing/success as UX, Not Authorization

import { polarClient } from '../auth/server';
import { requireUser } from '../auth/require-user';
import { hasProAccess } from '../billing/access';
import { getBillingStateForUser } from '../billing/state';

function redirect(path: string, request: Request) {
	return Response.redirect(new URL(path, request.url), 302);
}

export async function billingSuccess(request: Request) {
	const user = await requireUser(request);
	const checkoutId = new URL(request.url).searchParams.get('checkout_id');

	if (!checkoutId) {
		return redirect('/settings/billing', request);
	}

	const checkout = await polarClient.checkouts
		.get({ id: checkoutId })
		.catch((error: { status?: number }) => {
			if (error.status === 404 || error.status === 422) {
				return null;
			}

			throw error;
		});

	if (!checkout || checkout.externalCustomerId !== user.id) {
		return redirect('/settings/billing', request);
	}

	if (checkout.status === 'open' || checkout.status === 'confirmed') {
		return redirect('/billing/finishing-setup', request);
	}

	if (checkout.status !== 'succeeded') {
		return redirect('/settings/billing', request);
	}

	const state = await getBillingStateForUser(polarClient, user.id);

	if (hasProAccess(state)) {
		return redirect('/app', request);
	}

	return redirect('/billing/finishing-setup', request);
}
Why this route exists:
  • Confirm the checkout belongs to the signed-in user.
  • Treat confirmed as “payment flow still settling,” not as success. Terminal non-success states like expired or failed should fall back to regular billing UX, not unlock access.
  • Redirect optimistically once Customer State actually shows the access you care about.
Why this route does not exist:
  • It is not the final source of truth for gated routes.
  • It is not a replacement for checking Customer State on the backend.

Where Upstash Redis Fits

If you already use Upstash Redis as Better Auth secondaryStorage, that is a good place for:
  • sessions
  • verification records
  • rate-limit counters
  • optional short-lived billing caches
But it is not the billing source of truth. In a setup like the one behind this write-up, you may see keys such as:
  • billing:catalog
  • billing:state:*
  • rl:billing:*
Treat those as cache and rate-limit state, not as the contract your app authorizes from.
import { betterAuth } from 'better-auth';

export const auth = betterAuth({
	secondaryStorage: redisSecondaryStorage,
	rateLimit: {
		storage: 'secondary-storage',
	},
	session: {
		// Optional. Without this, sessions move to secondary storage by default
		storeSessionInDatabase: true,
	},
	plugins: [
		// ...
	],
});
The practical rule is simple:
  • Polar Customer State is where you decide access.
  • Redis is where you make that access check cheaper or faster.
If Redis is stale or empty, your app should still be able to recover from a live Polar read.

Webhooks Are for Cache Invalidation and Side Effects

If you keep app-side cached billing state, customer.state_changed is the right event to refresh or invalidate it. Do not rebuild billing truth from a pile of subscription.*, order.*, and benefit_* events when Polar already gives you Customer State directly. A good webhook handler for this integration is:
  • idempotent
  • fast
  • safe to retry
  • mostly about cache invalidation, denormalized read models, and audit side effects
If your webhook handler does expensive work, queue it and return quickly. Polar retries failed deliveries up to 10 times with exponential backoff, times out webhook requests after 10 seconds, and recommends responding within about 2 seconds.

Sync Customer Deletion Explicitly

The current Polar BetterAuth adapter guide documents this pattern explicitly. If you let users delete their Better Auth account, explicitly decide what happens to the matching Polar customer.
import { betterAuth } from 'better-auth';

import { polarClient } from '../auth/server';

export const auth = betterAuth({
	user: {
		deleteUser: {
			enabled: true,
			afterDelete: async (user) => {
				await polarClient.customers.deleteExternal({
					externalId: user.id,
					anonymize: true,
				});
			},
		},
	},
	plugins: [
		// ... your Polar plugin config
	],
});
The adapter docs show the same afterDelete hook pattern without anonymize; whether you set anonymize: true is your policy choice. Two policy choices live here:
  • whether deleting the app user should also delete the Polar customer immediately
  • whether you want anonymize: true for PII handling, or whether your retention policy requires something else
Make this explicit. Do not leave it as an accidental side effect.

What Your App Still Owns

  • Benefit / entitlement mapping
  • Server-side authorization checks
  • Optional cache shape and invalidation policy
  • Success-page UX
  • Deletion and retention policy
  • Monitoring and alerting
The integration stays simple when billing truth, app policy, and performance optimizations are kept separate.

Pitfalls

  • Do not rebuild billing truth from individual events when Polar already gives you Customer State.
  • Do not use activeSubscriptions.length > 0 as a universal “paid user” check unless every qualifying plan really means the same access.
  • Do not treat confirmed checkout status as payment success; only succeeded is final.
  • Do not hardcode a webhook URL without considering your Better Auth base path. For the default Better Auth mount, the current Polar BetterAuth adapter guide documents /api/auth/polar/webhooks.
  • Do not read Redis billing keys as source-of-truth business state.
  • Do not let the frontend be the final authority for access decisions.
  • Do not use webhook timestamps as blanket replay rejection; legitimate retries and redeliveries happen.
  • Do not put heavy work directly in the webhook request path.
  • Do not assume deleting a customer anonymizes PII by default; anonymize: true is opt-in.

Further Reading