> ## Documentation Index
> Fetch the complete documentation index at: https://tmbv.me/llms.txt
> Use this file to discover all available pages before exploring further.

# Per-User SaaS Billing

> A practical guide to integrating Polar with Better Auth for per-user billing, covering the core model, minimal integration steps, access patterns, and common pitfalls.

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

```ts theme={null}
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

```tsx theme={null}
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

```ts theme={null}
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.

```ts theme={null}
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

```ts theme={null}
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.

```ts theme={null}
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.

```ts theme={null}
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

* [Better Auth Polar plugin](https://better-auth.com/docs/plugins/polar)
* [Polar BetterAuth adapter guide](https://polar.sh/docs/integrate/sdk/adapters/better-auth)
* [Polar Customer State guide](https://polar.sh/docs/integrate/customer-state)
* [Polar Feature Flag benefits](https://polar.sh/docs/features/benefits/feature-flags)
* [Better Auth secondary storage](https://better-auth.com/docs/concepts/database#secondary-storage)
* [Better Auth session management](https://better-auth.com/docs/concepts/session-management)
* [Better Auth rate limiting](https://better-auth.com/docs/concepts/rate-limit)
* [Polar webhook delivery guide](https://polar.sh/docs/integrate/webhooks/delivery)
* [Polar TypeScript SDK](https://polar.sh/docs/integrate/sdk/typescript)
* [Get Customer State by External ID](https://polar.sh/docs/api-reference/customers/state-external)
* [Get Checkout Session](https://polar.sh/docs/api-reference/checkouts/get-session)
* [Delete Customer by External ID](https://polar.sh/docs/api-reference/customers/delete-external)
