Skip to main content
This article is not financial or legal advice.Check with your own counsel / DPO for your exact jurisdiction.
Breaking Bad Huell

TL;DR

Customer State

This is your only billing source of truth, and you always sync it by external ID into a KV or DB via one syncPolarStateToKVByExternalId function.
Everything flows from that:
  • BetterAuth uses your user.id as the Polar Customer externalId
  • Webhooks call syncPolarStateToKVByExternalId(externalId)
  • /billing/success calls syncPolarStateToKVByExternalId(user.id)
  • Your app checks access by reading CustomerState from KV and applying explicit rules
Once this is in place, all of your billing logic becomes:
// Pseudocode
const state = await kv.get(`polar:state:${user.id}`);

if (isActiveSubscriber(state)) {
	// allow access
} else {
	// block, redirect, or show upgrade CTA
}
The flow from a bird’s eye view looks like… BetterAuth’s Polar plugins handle all the glue for customers, checkout, portal, usage, and webhooks so you focus on entitlements and UX, not low level billing plumbing.

Deep Dive

Main Idea

Never reconstruct billing state locally.
Polar exposes Customer State, which returns the following in one object:
  • All customer data
  • Active subscriptions
  • Granted benefits
  • Active meters with current balances
…and a customer.state_changed webhook whenever that state changes.
Do not derive is subscribed by interpreting individual events.Always re-fetch Customer State and cache it in KV/DB.Gate app access off that KV’d state using explicit entitlement rules.
Webhooks and /billing/success never mutate billing state directly — they only call syncPolarStateToKVByExternalId.Your app defines the policy: which products / benefits / meters unlock which features.The front-end never decides access on its own; it only displays what the backend already decided.

Key Concepts

Customer

  • A Polar Customer represents a buyer.
  • We look it up by external ID, which is your BetterAuth user.id.
  • The Polar SDK provides following methods:
    • polar.customers.getExternal({ externalId })
    • polar.customers.getStateExternal({ externalId })
BetterAuth’s Polar plugin can auto-create a Polar Customer on user signup with createCustomerOnSignUp: true, using the BetterAuth user as the external ID.

Checkout Session

  • A checkout session creates orders and subscriptions for a Customer.
  • You must bind the session to your user with external_customer_id.
  • With BetterAuth, the checkout plugin does this for you under the hood — you call authClient.checkout(...) from the client and it creates a Polar checkout tied to the authenticated user.

Customer State

  • Canonical structure we treat as truth:
    • customer
    • active subscriptions
    • granted benefits
    • active meters
  • Fetched via the SDK:
const state = await polar.customers.getStateExternal({ externalId });

Webhooks

  • Polar uses standard webhooks with signature verification and retries.
  • BetterAuth’s webhooks plugin exposes typed handlers like onCustomerStateChanged, onOrderPaid, onSubscriptionUpdated, etc., plus a catch-all onPayload.
  • We wire these handlers to call syncPolarStateToKVByExternalId.

Portal, benefits & meters

  • The BetterAuth portal plugin adds client methods like authClient.customer.state(), authClient.customer.benefits.list, authClient.customer.subscriptions.list, etc., built on Polar’s Customer Portal APIs.
  • Customer State includes active meters and balances for usage-based billing.
  • The usage plugin exposes authClient.usage.ingest() and authClient.usage.meters.list() for metered events and customer meters.

End-To-End Flow

A typical Subscribe to Pro flow looks like this:

Front-End

User clicks Upgrade to Pro.
  • authClient.checkout({ slug: 'pro' }) is called from the client.

BetterAuth Server

  • checkout plugin receives the request, ensures the user is authenticated (authenticatedUsersOnly: true), and creates a Polar Checkout with:
    • external_customer_id = user.id
    • success_url = "/billing/success?checkout_id={CHECKOUT_ID}"
  • BetterAuth responds with the hosted checkout URL.

User

  • Completes checkout on Polar, then is redirected to /billing/success?checkout_id=chk_....

Back-End @ `/billing/success`

  • Authenticates the user via BetterAuth.
  • Optionally verifies that checkout_id belongs to this user (hardening).
  • Calls syncPolarStateToKVByExternalId(user.id).
  • Uses isActiveSubscriber(state) to decide whether to:
    • Redirect into your app, or…
    • Show a /billing/finishing-setup screen while webhooks catch up.

Back-End (Webhooks via BetterAuth)

  • Polar delivers webhooks to /polar/webhooks (BetterAuth’s webhook endpoint).
  • onCustomerStateChanged (and optionally onOrderPaid, onSubscriptionUpdated, etc.) call syncPolarStateToKVByExternalId(externalId).
  • Handlers stay thin; heavy logic runs in the sync function.

Front-End / API

  • App routes and APIs call requireSubscribedUser(user.id) (or similar) to require a valid subscription/benefit.
  • Optionally, the UI also calls authClient.customer.state() to show billing details, but not to decide access.
Voilà! No local reconstruction of billing state; you always rehydrate from Polar’s Customer State and derive entitlements via your own explicit rules.

Integration

Prerequisites

You should already have:
  • TypeScript
  • A JS/TS backend (serverless, monolith, edge, whatever)
  • BetterAuth configured on the server
  • A Polar Organization Access Token (OAT) on the server
  • A KV or DB (Redis, Upstash, SQL, etc.) to cache Customer State
  • server: 'sandbox' for dev/sandbox Polar environments.
We’ll assume:
  • BetterAuth’s Polar plugin is installed: better-auth, @polar-sh/better-auth, @polar-sh/sdk.
  • Your BetterAuth user ID (user.id) is the external ID used for Polar Customers.

BetterAuth on the Server

Configure BetterAuth once and re-use everywhere.
server.ts
// auth/server.ts
import { betterAuth } from 'better-auth';
import {
	polar,
	checkout,
	portal,
	usage,
	webhooks,
} from '@polar-sh/better-auth';
import { Polar } from '@polar-sh/sdk';

export const polarSdk = new Polar({
	accessToken: process.env.POLAR_ACCESS_TOKEN!,
	// 'sandbox' in dev, 'production' in prod
	server: process.env.POLAR_ENV === 'production' ? 'production' : 'sandbox',
});

export const auth = betterAuth({
	// ... your core BetterAuth config (session, user model, etc.)
	plugins: [
		polar({
			client: polarSdk,
			createCustomerOnSignUp: true, // auto Polar Customer per user
			// Optionally attach extra metadata on customer creation:
			// getCustomerCreateParams: ({ user }, request) => ({
			//   metadata: { appUserId: user.id },
			// }),
			use: [
				checkout({
					products: [
						{
							productId: '123-456-789', // Polar Product ID
							slug: 'pro', // Used on client: authClient.checkout({ slug: 'pro' })
						},
					],
					successUrl: '/billing/success?checkout_id={CHECKOUT_ID}',
					authenticatedUsersOnly: true,
				}),
				portal(),
				usage(),
				webhooks({
					secret: process.env.POLAR_WEBHOOK_SECRET!,
					// We'll fill these in later:
					// onCustomerStateChanged: async (payload, ctx) => { ... },
					// onOrderPaid: async (payload, ctx) => { ... },
					// onPayload: async (payload, ctx) => { ... },
				}),
			],
		}),
	],
});
This follows the recommended Polar + BetterAuth configuration: one Polar SDK client, createCustomerOnSignUp, and the Polar plugins wired under use.

BetterAuth on the Client

Here, you only need the BetterAuth client and the Polar client plugin.
Framework Options: BetterAuth provides framework-specific clients. e.g.
  • React: better-auth/react
  • Next.js: better-auth/react
  • Svelte: better-auth/svelte
  • Vanilla/Framework-agnostic: better-auth/client
All provide the same API; choose based on your framework for optimal integration. The baseURL parameter is optional and defaults to the same domain.
Client Setup
auth-client.ts
// lib/auth-client.ts
import { createAuthClient } from 'better-auth/react';
import { polarClient } from '@polar-sh/better-auth';

export const authClient = createAuthClient({
	plugins: [polarClient()],
});
Starting a Checkout
  • React
  • Next.js
  • Svelte
  • Vanilla JS
CheckoutButton.tsx
// components/CheckoutButton.tsx
import { authClient } from '../lib/auth-client';

export function CheckoutButton() {
	async function startProCheckout() {
		await authClient.checkout(
			{
				slug: 'pro', // must match server-side checkout config
				// Optional org/team reference:
				// referenceId: organizationId,
			},
			{
				onRequest: (ctx) => {
					// show loading state
				},
				onSuccess: (ctx) => {
					// Navigate to Polar checkout
					window.location.href = ctx.data.url;
				},
				onError: (ctx) => {
					console.error('Failed to start checkout', ctx.error);
					// Handle error - show toast, etc.
				},
			}
		);
	}

	return (
		<button onClick={startProCheckout}>
			Upgrade to Pro
		</button>
	);
}
The checkout plugin wires this through to Polar, binding the checkout to the authenticated BetterAuth user and using the configured successUrl.

A Single Sync Function

syncPolarStateToKVByExternalId()
Always fetch Customer State by external ID and cache it.
sync.ts
// billing/sync.ts
import type { Polar, CustomerState } from '@polar-sh/sdk';

// Re-export the CustomerState type from the SDK for use in your app
export type { CustomerState };

export interface KV<T> {
	get(key: string): Promise<T | null>;
	set(key: string, value: T, opts?: { ex?: number }): Promise<void>;
	delete?(key: string): Promise<void>;
}

// TTL balances freshness vs API rate limits
// Longer TTL (1h+) reduces API calls; webhooks provide real-time updates
const STATE_TTL_SECONDS = 60 * 60; // 1 hour

export async function syncPolarStateToKVByExternalId(
	polar: Polar,
	kv: KV<CustomerState | null>,
	externalId: string
): Promise<CustomerState | null> {
	try {
		const state = await polar.customers.getStateExternal({ externalId });
		await kv.set(`polar:state:${externalId}`, state, {
			ex: STATE_TTL_SECONDS,
		});

		return state;
	} catch (error: unknown) {
		// Different runtimes / SDK versions may expose status in different places.
		// Type-safe error property access
		const status =
			error && typeof error === 'object' && 'status' in error
				? (error as { status: number }).status
				: error && typeof error === 'object' && 'statusCode' in error
				? (error as { statusCode: number }).statusCode
				: error && typeof error === 'object' && 'response' in error
				? (error as { response?: { status?: number } }).response?.status
				: undefined;

		if (status === 404) {
			// Customer doesn't exist yet - cache null to avoid repeated 404s
			await kv.set(`polar:state:${externalId}`, null, {
				ex: STATE_TTL_SECONDS,
			});
			return null;
		}

		if (status === 401 || status === 403) {
			console.error('[sync] Polar access token invalid or expired', {
				externalId,
				status,
				error: error instanceof Error ? { message: error.message, stack: error.stack } : error,
			});
			// Alert ops team - this requires immediate attention
			throw new Error('Polar authentication failed - check access token');
		}

		if (status === 429) {
			console.error('[sync] Polar rate limit exceeded', {
				externalId,
				status,
				error: error instanceof Error ? { message: error.message } : error,
			});
			// Consider implementing exponential backoff or queuing
			throw error;
		}

		// Network or other errors - let caller handle retry logic
		console.error('[sync] Failed to sync customer state', { externalId, error });
		throw error;
	}
}
We always fetch Customer State via external ID using polar.customers.getStateExternal({ externalId }), not internal Polar IDs.
You’ll reuse this function in:
  • /billing/success
  • Webhook handlers (onCustomerStateChanged, onOrderPaid, etc.)
  • Any manual admin triggers or backfills.

Success Handler

/billing/success
This route gives users instant feedback after checkout and eagerly syncs Customer State.
success.ts
// routes/billing/success.ts
import { auth } from '../auth/server';
import { polarSdk } from '../auth/server';
import { syncPolarStateToKVByExternalId } from '../billing/sync';
import { isActiveSubscriber } from '../billing/access';
import { kv } from '../kv'; // your KV/DB adapter

/**
 * Pseudonymise user ID for safer logging.
 * Example: log a short, stable identifier derived from userId instead of raw PII.
 *
 * NOTE: This is still personal data under GDPR if you can link it back.
 * Use a real hash in production and keep keys separate.
 */
async function hashUserId(userId: string): Promise<string> {
	const data = new TextEncoder().encode(userId);
	const digest = await crypto.subtle.digest('SHA-256', data);
	const bytes = Array.from(new Uint8Array(digest));
	const hex = bytes.map((b) => b.toString(16).padStart(2, '0')).join('');
	return hex.slice(0, 8);
}

export async function handleBillingSuccess(req: Request): Promise<Response> {
	// 1. Auth user using BetterAuth
	const session = await auth.getSession(req);
	const user = session?.user;
	if (!user) {
		return new Response('Unauthorized', { status: 401 });
	}

	const url = new URL(req.url);
	const checkoutId = url.searchParams.get('checkout_id');

	// 2. (Recommended) Verify checkout belongs to this user
	if (checkoutId) {
		// Validate checkout ID format (Polar uses 'chk_' prefix)
		if (!checkoutId.startsWith('chk_')) {
			console.warn('[billing-success] Invalid checkout ID format', { checkoutId });
			return new Response('Invalid checkout ID format', { status: 400 });
		}

		try {
			const checkout = await polarSdk.checkouts.get({ id: checkoutId });

			// Verify checkout has a customer
			if (!checkout.customer?.externalId) {
				console.warn('[billing-success] Checkout missing customer', checkoutId);
				return new Response('Checkout has no customer', { status: 400 });
			}

			// Verify checkout belongs to authenticated user
			if (checkout.customer.externalId !== user.id) {
				console.warn('[billing-success] Checkout ownership mismatch', {
					checkoutId,
					expectedHash: hashUserId(user.id),
					actualHash: hashUserId(checkout.customer.externalId),
				});
				return new Response('Checkout belongs to different user', {
					status: 403,
				});
			}

			// Verify checkout was successful
			if (checkout.status !== 'succeeded' && checkout.status !== 'confirmed') {
				console.warn('[billing-success] Checkout not completed', {
					checkoutId,
					status: checkout.status,
				});
				return new Response('Checkout not completed', { status: 400 });
			}
		} catch (err) {
			console.error('[billing-success] Checkout verification failed', err);
			// Fail closed: don't proceed if we can't verify the checkout
			return new Response('Failed to verify checkout', { status: 500 });
		}
	}

	// 3. Eagerly sync Customer State
	let state: CustomerState | null;
	try {
		state = await syncPolarStateToKVByExternalId(polarSdk, kv, user.id);
	} catch (error) {
		console.error('[billing-success] Failed to sync state', {
			userIdHash: hashUserId(user.id),
			error,
		});
		// Polar API unreachable - redirect to finishing-setup page
		// which will poll for subscription status
		const baseUrl = process.env.BASE_URL || req.headers.get('origin') || 'http://localhost:3000';
		return Response.redirect(`${baseUrl}/billing/finishing-setup`, 302);
	}

	// 4. Route based on entitlements
	const baseUrl = process.env.BASE_URL || req.headers.get('origin') || 'http://localhost:3000';

	if (isActiveSubscriber(state)) {
		return Response.redirect(`${baseUrl}/app`, 302);
	}

	// Race with webhooks; show a safe "finishing setup" page
	// This page should poll for subscription status
	return Response.redirect(`${baseUrl}/billing/finishing-setup`, 302);
}
Access is never decided by checkout_id. It’s decided by Customer State + your entitlement rules.Checkout verification is just a guardrail against obviously erroneous /success hits (e.g. someone pasting the URL).If state isn’t updated yet, the /billing/finishing-setup UX gives webhooks time to catch up.

Webhooks via BetterAuth

Configure Polar to send webhooks to the BetterAuth endpoint, e.g. /polar/webhooks, and set POLAR_WEBHOOK_SECRET accordingly. Extend your BetterAuth config:
server.ts
// auth/server.ts (continued)
import { syncPolarStateToKVByExternalId } from '../billing/sync';
import { kv } from '../kv';

export const auth = betterAuth({
	// ...
	plugins: [
		polar({
			client: polarSdk,
			createCustomerOnSignUp: true,
			use: [
				checkout({
					/* ... */
				}),
				portal(),
				usage(),
				webhooks({
					secret: process.env.POLAR_WEBHOOK_SECRET!,
					async onCustomerStateChanged(event, ctx) {
						// Replay attack protection: Reject events older than 5 minutes
						const eventTime = new Date(event.createdAt);
						const age = Date.now() - eventTime.getTime();
						const MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes

						if (age > MAX_AGE_MS) {
							console.warn('[webhook] Event too old, ignoring', {
								eventId: event.id,
								ageSeconds: Math.round(age / 1000),
							});
							return; // Don't process old events
						}

						const externalId = event.data.customer.externalId;
						if (!externalId) {
							console.warn('[webhook] No externalId in customer.state_changed', {
								eventId: event.id,
							});
							return; // Not an error - customer might not have external ID yet
						}

						// Idempotency: Check if we've already processed this event
						const processedKey = `polar:webhook:processed:${event.id}`;
						const alreadyProcessed = await kv.get(processedKey);
						if (alreadyProcessed) {
							console.log('[webhook] Event already processed, skipping', {
								eventId: event.id,
								externalId,
							});
							return;
						}

						try {
							await syncPolarStateToKVByExternalId(polarSdk, kv, externalId);

							// Mark event as processed (store for 24 hours)
							await kv.set(processedKey, true, { ex: 86400 });

							console.log('[webhook] Synced customer state', {
								eventId: event.id,
								externalId,
							});
						} catch (err) {
							console.error('[webhook] Sync failed for customer.state_changed', {
								eventId: event.id,
								externalId,
								error: err,
							});
							// Optionally: push to dead-letter queue for manual retry
							// await dlq.push({ eventId: event.id, externalId, error: err });

							// Re-throw to let Polar retry the webhook
							throw err;
						}
					},
					async onOrderPaid(event, ctx) {
						// Replay attack protection: Reject events older than 5 minutes
						const eventTime = new Date(event.createdAt);
						const age = Date.now() - eventTime.getTime();
						const MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes

						if (age > MAX_AGE_MS) {
							console.warn('[webhook] Event too old, ignoring', {
								eventId: event.id,
								ageSeconds: Math.round(age / 1000),
							});
							return; // Don't process old events
						}

						const externalId = event.data.customer?.externalId;
						if (!externalId) {
							console.warn('[webhook] No externalId in order.paid', {
								eventId: event.id,
							});
							return;
						}

						// Idempotency: Check if we've already processed this event
						const processedKey = `polar:webhook:processed:${event.id}`;
						const alreadyProcessed = await kv.get(processedKey);
						if (alreadyProcessed) {
							console.log('[webhook] Event already processed, skipping', {
								eventId: event.id,
								externalId,
							});
							return;
						}

						try {
							await syncPolarStateToKVByExternalId(polarSdk, kv, externalId);

							// Mark event as processed (store for 24 hours)
							await kv.set(processedKey, true, { ex: 86400 });

							console.log('[webhook] Synced on order.paid', {
								eventId: event.id,
								externalId,
							});
						} catch (err) {
							console.error('[webhook] Sync failed for order.paid', {
								eventId: event.id,
								externalId,
								error: err,
							});
							throw err;
						}
					},
					// Optional: catch-all logging
					async onPayload(event, ctx) {
						console.log('[polar webhook]', {
							type: event.type,
							id: event.id,
							timestamp: new Date().toISOString(),
						});
					},
				}),
			],
		}),
	],
});
onCustomerStateChanged is your primary trigger — it fires when the customer is created/updated, subscriptions change, or benefits change.Other events (e.g. onOrderPaid, onSubscriptionUpdated) are nice additional triggers, especially for renewals.Keep handlers fast. If syncPolarStateToKV becomes heavy, move work to a queue but still fetch Customer State from Polar in that background job.
Webhook Events Reference:
EventPriorityWhen to HandleNotes
customer.state_changedHighAlways syncCovers most state changes - subscriptions, benefits, customer data
order.paidMediumOptional - faster UXSync immediately after payment for instant access
subscription.createdLowOptional loggingRedundant with customer.state_changed
subscription.updatedLowOptional loggingRedundant with customer.state_changed
subscription.canceledLowOptional loggingRedundant with customer.state_changed
benefit_grant.createdLowOptional loggingRedundant with customer.state_changed
benefit_grant.revokedLowOptional loggingRedundant with customer.state_changed
Polar retries failed deliveries (with exponential backoff and a generous timeout), but you should still monitor logs/alerts so Customer State doesn’t drift.Some WAF/bot protections (e.g. aggressive Cloudflare settings) can block webhook traffic; allowlist Polar’s IPs when necessary.Log event.id, event.type, and customer.externalId for debugging.

Reading state from KV + helpers

Store the raw Customer State blob and derive entitlements at read time.
access.ts
// billing/access.ts
import type { CustomerState, KV } from './sync';

/**
 * Define which products/benefits unlock your "subscribed" experience.
 * Adjust these to match your Polar configuration.
 */
export type SubscriberPolicy = {
	// e.g. ['pro-plan', 'team-plan']
	allowedBenefitSlugs?: string[];

	// e.g. ['prod_12345678', 'prod_87654321']
	allowedProductIds?: string[];
};

export const DEFAULT_SUBSCRIBER_POLICY: SubscriberPolicy = {
	allowedBenefitSlugs: ['pro-plan'],
	// optionally also require specific product IDs:
	// allowedProductIds: ['prod_12345678'],
};

/**
 * Core helper: is this customer "subscribed" according to your policy?
 *
 * Policy-based entitlement check:
 * - If `allowedProductIds` is specified: checks if customer has active subscription to any allowed product
 * - If `allowedBenefitSlugs` is specified: checks if customer has any granted benefit matching allowed slugs
 * - Returns true if EITHER condition is met (OR logic)
 *
 * If neither is specified in policy, always returns false (explicit opt-in required).
 */
export function isActiveSubscriber(
	state: CustomerState | null | undefined,
	policy: SubscriberPolicy = DEFAULT_SUBSCRIBER_POLICY
): boolean {
	if (!state) return false;

	const { allowedBenefitSlugs, allowedProductIds } = policy;

	let hasAllowedSub = false;
	let hasAllowedBenefit = false;

	// Check subscription-based access
	// Only performs check if allowedProductIds is specified (length > 0)
	// This allows flexible policy configuration - omit to skip subscription checks
	if (Array.isArray(state.activeSubscriptions) && allowedProductIds?.length) {
		hasAllowedSub = state.activeSubscriptions.some((sub) =>
			allowedProductIds.includes(sub.productId)
		);
	}

	// Check benefit-based access
	// Only performs check if allowedBenefitSlugs is specified (length > 0)
	// Benefits can grant access independently of subscriptions
	if (Array.isArray(state.grantedBenefits) && allowedBenefitSlugs?.length) {
		hasAllowedBenefit = state.grantedBenefits.some((benefit) =>
			allowedBenefitSlugs.includes(benefit.slug)
		);
	}

	// OR logic: customer is "subscribed" if they have EITHER an allowed subscription OR an allowed benefit
	return hasAllowedSub || hasAllowedBenefit;
}

/** Check for a specific benefit slug ("beta-access", "enterprise-seat", etc.) */
export function hasBenefit(
	state: CustomerState | null | undefined,
	benefitSlug: string
): boolean {
	if (!state?.grantedBenefits) return false;
	return state.grantedBenefits.some((benefit) => benefit.slug === benefitSlug);
}

/**
 * Read a meter balance by slug (for usage-based entitlements).
 * For "increment" meters: returns total usage accumulated.
 * For "decrement" meters: returns remaining credits/quota.
 * Returns null if meter doesn't exist or isn't active.
 */
export function getMeterBalance(
	state: CustomerState | null | undefined,
	meterSlug: string
): number | null {
	if (!state?.activeMeters) return null;

	const meter = state.activeMeters.find((m) => m.slug === meterSlug);
	if (!meter || typeof meter.balance !== 'number') return null;

	return meter.balance;
}

/**
 * Prototype-friendly helper: ANY sub OR ANY benefit counts as "subscribed".
 * Fine for early demos; too broad for production.
 */
export function hasAnySubscriptionOrBenefit(
	state: CustomerState | null | undefined
): boolean {
	if (!state) return false;

	const hasSub =
		Array.isArray(state.activeSubscriptions) &&
		state.activeSubscriptions.length > 0;

	const hasBenefit =
		Array.isArray(state.grantedBenefits) && state.grantedBenefits.length > 0;

	return hasSub || hasBenefit;
}
requireSubscribedUser() helper
requireSubscribedUser.ts
// billing/guards.ts
import type { CustomerState, KV } from './sync';
import {
    isActiveSubscriber,
    DEFAULT_SUBSCRIBER_POLICY,
    SubscriberPolicy,
} from './access';

export async function requireSubscribedUser(
    kv: KV<CustomerState | null>,
    userId: string,
    policy: SubscriberPolicy = DEFAULT_SUBSCRIBER_POLICY
): Promise<CustomerState> {
    const state = await kv.get(`polar:state:${userId}`);
    if (!state || !isActiveSubscriber(state, policy)) {
        throw Object.assign(new Error('Subscription required'), { status: 403 });
    }
    return state;
}
Use this in API routes / loaders / RPC handlers to enforce access. Key idea: your policy is explicit; it encodes which Polar product IDs / benefit slugs map to your Pro or Team experiences.

Seats & Org-Level Subscriptions

referenceId
For team / org plans, the subscription may belong to an organization, not the individual user. Polar + BetterAuth support this via a reference system:
  • Pass a referenceId in checkout (typically your organization ID).
  • Later, list subscriptions or orders for that referenceId to determine whether the org is subscribed.
Example client-side org subscription check:
// client/org-access.ts
import { authClient } from '../auth/client';

export async function orgHasActiveSubscription(organizationId: string) {
	const { data: subscriptions } = await authClient.customer.orders.list({
		query: {
			page: 1,
			limit: 10,
			active: true,
			referenceId: organizationId,
		},
	});

	return subscriptions.some((sub) => {
		// your policy: check product, plan, etc.
		return sub.product?.id === '123-456-789';
	});
}
Org Customer State can still be fetched via polar.customers.getStateExternal({ externalId }) — but in many apps, the external ID will represent the org instead of the user.You then map users → org(s) → Customer State → entitlements (seats) using your own membership tables.
Customer State for a user does not include subscriptions made by a parent organization.You must use the referenceId-based queries or org-scoped state for that.

Security Considerations

Security is critical when handling payments and customer data. Here are the key areas to address:

Secret Management

Never commit secrets to version control. Use environment variables and secret management tools.
Your integration requires these sensitive values:
SecretPurposeRotation Policy
POLAR_ACCESS_TOKENAuthenticates your app to Polar APIRotate if exposed; use separate tokens for sandbox/production
POLAR_WEBHOOK_SECRETVerifies webhook signaturesRotate immediately if leaked; update in both Polar dashboard and your app
BETTER_AUTH_SECRETSigns BetterAuth sessionsRotate periodically (e.g., quarterly)
Best practices:
  • Use a secret management service (AWS Secrets Manager, HashiCorp Vault, Doppler, etc.)
  • Never log full secret values - log only prefixes for debugging
  • Implement secret rotation procedures before you need them
  • Use different secrets for sandbox vs. production environments
// ❌ BAD: Hardcoded or logged secrets
const token = 'polar_live_1234567890';
console.log('Token:', process.env.POLAR_ACCESS_TOKEN);

// ✅ GOOD: Environment variables, safe logging
const token = process.env.POLAR_ACCESS_TOKEN!;
console.log('Token prefix:', token.slice(0, 10) + '...');

Webhook Security

BetterAuth’s webhooks plugin handles signature verification automatically, but you should still:
  1. Verify the secret is configured correctly
    if (!process.env.POLAR_WEBHOOK_SECRET) {
      throw new Error('POLAR_WEBHOOK_SECRET is required');
    }
    
  2. Log webhook verification failures
    // BetterAuth handles verification, but monitor for suspicious patterns
    async onPayload(event, ctx) {
      console.log('[webhook] Received', {
        type: event.type,
        id: event.id,
        timestamp: new Date().toISOString(),
      });
    }
    
  3. Allowlist Polar’s IPs (if using strict firewall/WAF)
  4. Monitor for replay attacks
    • Polar includes timestamps in webhook payloads
    • The webhook handlers in this guide implement age validation, rejecting events older than 5 minutes
Advanced: Timing-Safe String ComparisonFor high-security applications, consider using timing-safe string comparison when verifying checkout ownership to prevent timing attacks:
import { timingSafeEqual } from 'crypto';

function constantTimeStringCompare(a: string, b: string): boolean {
	if (a.length !== b.length) return false;
	return timingSafeEqual(Buffer.from(a), Buffer.from(b));
}

// Use in checkout verification
if (!constantTimeStringCompare(checkout.customer.externalId, user.id)) {
	// Ownership mismatch
}
This prevents attackers from using response timing to guess valid user IDs. For most applications, the standard !== comparison is sufficient.

CSRF Protection

BetterAuth includes CSRF protection for session-based operations by default.
For checkout initiation:
  • The checkout plugin’s authenticatedUsersOnly: true ensures only authenticated users can create checkouts
  • Session cookies should have sameSite: 'lax' or 'strict'
  • For additional protection, validate the request origin:
// In your checkout handler (if needed)
const origin = req.headers.get('origin');
const allowedOrigins = [
  'https://yourapp.com',
  'https://www.yourapp.com',
];

if (origin && !allowedOrigins.includes(origin)) {
  return new Response('Invalid origin', { status: 403 });
}

Access Token Security

If your Polar access token is compromised, attackers could:
  • Read all customer data
  • Create fraudulent checkouts
  • Manipulate subscriptions
Mitigation:
  1. Use scoped tokens (if Polar supports it in the future)
  2. Monitor for unusual API activity:
    • Unexpected customer creates/updates
    • Checkout creation spikes
    • Failed authentication attempts
  3. Implement alerts for auth errors (401/403 responses)
  4. Have a token rotation procedure ready
// Monitor token validity
try {
  const state = await polar.customers.getStateExternal({ externalId });
} catch (error: any) {
  if (error?.status === 401 || error?.status === 403) {
    // Alert ops team immediately
    await sendAlert({
      severity: 'critical',
      message: 'Polar access token invalid - check immediately',
      error,
    });
  }
  throw error;
}

Data Privacy & GDPR

When a user requests account deletion, you need to balance privacy requirements (like GDPR’s right to deletion) with legal obligations to retain financial records.
Financial Record Retention: Payment processors and tax authorities typically require retaining transaction records for 7-10 years, even after customer deletion. This includes data needed for:
  • Chargebacks and payment disputes
  • Tax audits and accounting
  • Fraud investigations
  • Legal/compliance obligations (e.g. local tax laws, PCI DSS for card data, SOX if you’re a public company)
This is not legal advice; always check with your own counsel / DPO for your exact jurisdiction.
Option A: Immediate Deletion (Higher Compliance Risk)
This is the most privacy-friendly approach but only safe if you truly have no legal or contractual need to keep records. Use this only if you have no legal retention requirements:
// Immediate deletion - only use if you don't need financial records
async function deleteUserImmediate(userId: string) {
  try {
    // 1. Delete from your auth system
    await auth.api.deleteUser(userId);

    // 2. Delete from Polar (removes PII from Polar's systems)
    // ⚠️ WARNING: This removes customer data but Polar retains transaction records
    await polar.customers.deleteExternal({ externalId: userId });

    // 3. Clear cached state
    if (kv.delete) {
      await kv.delete(`polar:state:${userId}`);
    }

    // 4. Log for audit trail
    console.log('[user-deletion] Completed', { userId, timestamp: new Date() });
  } catch (error) {
    console.error('[user-deletion] Failed', { userId, error });
    throw error;
  }
}
Option B: Retention Period (Recommended for Production)
Implement a soft-delete with retention period for financial/legal compliance:
// Recommended: Soft delete with retention period
async function deleteUserWithRetention(userId: string) {
  try {
    // 1. Calculate retention end date (e.g., 7 years for financial records)
    const retentionYears = 7;
    const retainUntil = new Date();
    retainUntil.setFullYear(retainUntil.getFullYear() + retentionYears);

    // 2. Soft delete in your database - pseudonymise PII but keep financial metadata
    await db.user.update({
      where: { id: userId },
      data: {
        // Mark as deleted
        deletedAt: new Date(),
        retainUntil,

        // Pseudonymise direct identifiers (supports GDPR right to erasure)
        email: `deleted-${userId}@example.com`,
        name: '[DELETED]',

        // Keep minimal data for financial/legal purposes:
        // - Transaction IDs (for chargeback lookups)
        // - Payment dates and amounts (for accounting)
        // - Subscription history (for revenue recognition)
        // Note: Store these in a separate audit table, not user table
      },
    });

    // 3. Fetch and archive critical financial data before deletion
    const customerState = await polar.customers.getStateExternal({
      externalId: userId
    });

    // Archive to separate financial records table (not exposed via normal user-facing APIs)
    await db.financialArchive.create({
      data: {
        userId,
        archivedAt: new Date(),
        retainUntil,
        // Store minimal data needed for disputes/audits
        transactionIds: customerState.activeSubscriptions.map(s => s.id),
        orderHistory: customerState.activeSubscriptions.map(s => ({
          productId: s.productId,
          startDate: s.startedAt,
          amount: s.amount,
        })),
      },
    });

    // 4. Delete from Polar ONLY if past retention period
    // Otherwise, keep for financial record retention
    const now = new Date();
    if (retainUntil <= now) {
      await polar.customers.deleteExternal({ externalId: userId });
    } else {
      // Just anonymize in Polar but keep transaction records
      // Polar retains transaction/order data even after customer deletion
      console.log('[user-deletion] Customer marked for deletion after retention period', {
        userId,
        retainUntil,
      });
    }

    // 5. Clear cached state immediately (user can't access account anymore)
    if (kv.delete) {
      await kv.delete(`polar:state:${userId}`);
    }

    // 6. Log for audit trail
    console.log('[user-deletion] Soft delete completed', {
      userId,
      retainUntil,
      timestamp: new Date()
    });
  } catch (error) {
    console.error('[user-deletion] Failed', { userId, error });
    throw error;
  }
}
Background Job: Clean Up After Retention Period
Set up a scheduled job to permanently delete data after the retention period expires:
// jobs/cleanup-expired-deletions.ts
export async function cleanupExpiredDeletions() {
  const now = new Date();

  // Find users whose retention period has expired
  const expiredUsers = await db.user.findMany({
    where: {
      deletedAt: { not: null },
      retainUntil: { lte: now },
    },
  });

  for (const user of expiredUsers) {
    try {
      // 1. Permanently delete from Polar
      await polar.customers.deleteExternal({ externalId: user.id });

      // 2. Delete financial archive (if your retention policy allows)
      await db.financialArchive.deleteMany({
        where: {
          userId: user.id,
          retainUntil: { lte: now },
        },
      });

      // 3. Permanently delete user record
      await db.user.delete({ where: { id: user.id } });

      console.log('[cleanup] Permanently deleted user after retention', {
        userId: user.id,
        originalDeletionDate: user.deletedAt,
      });
    } catch (error) {
      console.error('[cleanup] Failed to delete expired user', {
        userId: user.id,
        error,
      });
    }
  }
}
What Polar Retains After Customer Deletion
Even after calling polar.customers.deleteExternal(), Polar retains:
  • Transaction records for their compliance (typically 7 years)
  • Order and subscription history linked to transaction IDs
  • Payment metadata required for disputes and chargebacks
What Polar removes:
  • Customer PII (name, email, address)
  • Custom metadata you added to the customer
  • Active subscriptions and benefits (immediately revoked)
Handling Chargebacks After Deletion
If you need to handle a chargeback after customer deletion:
// Handle chargeback even after customer deletion
async function handleChargeback(transactionId: string) {
  // 1. Look up transaction in your financial archive
  const archive = await db.financialArchive.findFirst({
    where: {
      transactionIds: { has: transactionId },
    },
  });

  if (!archive) {
    throw new Error('Transaction not found - may have been deleted');
  }

  // 2. Fetch transaction details from Polar using order/subscription ID
  // Polar retains these even after customer deletion
  const order = await polar.orders.get({ id: transactionId });

  // 3. Process chargeback with available data
  console.log('[chargeback] Processing for deleted user', {
    transactionId,
    orderAmount: order.amount,
    productId: order.productId,
  });

  // Your chargeback handling logic here...
}
GDPR considerations:
  • Right to erasure: Implement soft delete that anonymizes PII while retaining financial data
  • Right to access: For active users, expose current subscription + billing state via your app (e.g., authClient.customer.state()). For formal access requests (including deleted accounts), be prepared to pull data from archives/logs as well.
  • Right to portability: Export order history via Polar’s API before deletion
  • Retention limits: Set retention based on your local tax/accounting rules (for many EU countries this is around 6–10 years for financial records – check your jurisdiction)
  • Justification: Document why you’re retaining data (e.g., “Required for tax compliance under [regulation]”)
Best Practice: When a user requests deletion, immediately:
  1. Revoke all access and pseudonymise PII
  2. Archive minimal financial data to a separate, restricted table
    • Archived financial records are still subject to access requests
  3. Schedule permanent deletion after your legal retention period expires
This pattern helps you honour erasure requests while still meeting your legal and accounting obligations. Exact requirements depend on your jurisdiction, so check with your counsel.

Edge Cases & Error Handling

Subscription Lifecycle Events

Handle all stages of the subscription lifecycle:
Cancellations
// Decide your cancellation policy
export function isActiveSubscriber(
  state: CustomerState | null | undefined,
  policy: SubscriberPolicy = DEFAULT_SUBSCRIBER_POLICY
): boolean {
  if (!state) return false;

  const { allowedProductIds } = policy;

  return state.activeSubscriptions.some((sub) => {
    // Check if subscription is for an allowed product
    if (!allowedProductIds?.includes(sub.productId)) return false;

    // Option A: Access until end of billing period
    if (sub.status === 'active') return true;
    if (sub.status === 'canceled' && sub.currentPeriodEnd) {
      const endDate = new Date(sub.currentPeriodEnd);
      return endDate > new Date(); // Still active until period ends
    }

    // Option B: Immediate revocation on cancellation
    // return sub.status === 'active';

    return false;
  });
}
Trial Expirations
When a trial ends without payment:
  • Customer State will show activeSubscriptions as empty
  • Handle gracefully in your app - don’t break the user experience
  • Show upgrade CTA instead of hard-blocking
Failed Payments
Polar handles dunning (retry logic) automatically. Customer State reflects the current status:
  • Subscription may enter past_due status
  • After dunning attempts exhausted, becomes canceled
  • Implement grace period logic if desired:
// Grace period for past_due subscriptions
const GRACE_PERIOD_DAYS = 3;

export function hasActiveOrGracePeriodSubscription(
  state: CustomerState | null | undefined
): boolean {
  if (!state) return false;

  return state.activeSubscriptions.some((sub) => {
    if (sub.status === 'active') return true;

    if (sub.status === 'past_due' && sub.currentPeriodEnd) {
      const gracePeriodEnd = new Date(sub.currentPeriodEnd);
      gracePeriodEnd.setDate(gracePeriodEnd.getDate() + GRACE_PERIOD_DAYS);
      return gracePeriodEnd > new Date();
    }

    return false;
  });
}

Rate Limiting

Polar’s API has rate limits. Your KV cache mitigates this, but you should still handle 429 responses:
// Exponential backoff helper
async function fetchWithRetry<T>(
  fn: () => Promise<T>,
  maxRetries = 3
): Promise<T> {
  let lastError: any;

  for (let i = 0; i < maxRetries; i++) {
    try {
      return await fn();
    } catch (error: any) {
      lastError = error;

      if (error?.status === 429) {
        const delay = Math.pow(2, i) * 1000; // 1s, 2s, 4s
        console.warn(`[rate-limit] Retrying after ${delay}ms`, { attempt: i + 1 });
        await new Promise((resolve) => setTimeout(resolve, delay));
        continue;
      }

      // Don't retry other errors
      throw error;
    }
  }

  throw lastError;
}

// Usage
const state = await fetchWithRetry(() =>
  polar.customers.getStateExternal({ externalId })
);
Cache TTL strategy:
  • Longer TTL (1h+): Reduces API calls, relies on webhooks for updates
  • Shorter TTL (5min): More API calls, faster eventual consistency if webhooks fail
  • No TTL: Only invalidate on webhooks (risky if webhooks are blocked/fail)
Cache Warming Strategy: Prevent slow first requests after cache expiry by proactively refreshing the cache:
// jobs/cache-warmer.ts
import { polarSdk } from '../auth/server';
import { syncPolarStateToKVByExternalId } from '../billing/sync';
import { kv } from '../kv';

/**
 * Background job to refresh expiring customer state cache
 * Run this periodically (e.g., every 30 minutes) via cron or scheduled function
 */
export async function refreshExpiringCustomerState() {
  // Get list of active user IDs from your database
  // This query depends on your auth/DB setup
  const activeUsers = await getActiveUserIds(); // Implement based on your DB

  let refreshed = 0;
  let skipped = 0;

  for (const userId of activeUsers) {
    try {
      const key = `polar:state:${userId}`;

      // Check if cache is close to expiring
      // If your KV supports TTL inspection (like Redis), use it
      // Otherwise, refresh all users on a schedule
      const shouldRefresh = await shouldRefreshCache(key);

      if (shouldRefresh) {
        await syncPolarStateToKVByExternalId(polarSdk, kv, userId);
        refreshed++;
      } else {
        skipped++;
      }

      // Rate limiting: small delay between refreshes
      await new Promise((resolve) => setTimeout(resolve, 100));
    } catch (error) {
      console.error('[cache-warmer] Failed to refresh state', {
        userId,
        error,
      });
      // Continue with next user
    }
  }

  console.log('[cache-warmer] Completed', {
    totalUsers: activeUsers.length,
    refreshed,
    skipped,
  });
}

/**
 * Check if cache should be refreshed based on TTL
 * Implementation depends on your KV store capabilities
 */
async function shouldRefreshCache(key: string): Promise<boolean> {
  // Example for Redis with TTL support:
  // const ttl = await redis.ttl(key);
  // return ttl > 0 && ttl < 300; // Refresh if < 5 minutes remaining

  // For KV stores without TTL inspection, use a simpler strategy:
  // Always refresh if key exists (assumes job runs less frequently than TTL)
  const exists = await kv.get(key);
  return exists !== null;
}

/**
 * Get list of active user IDs
 * Implement based on your database/auth setup
 */
async function getActiveUserIds(): Promise<string[]> {
  // Example with SQL:
  // return db.query('SELECT id FROM users WHERE last_active > NOW() - INTERVAL 7 DAY');

  // Example with your auth system:
  // return auth.listUsers({ active: true });

  // Placeholder:
  return [];
}
Deploy as cron job:
# Example: Vercel cron job (vercel.json)
{
  "crons": [{
    "path": "/api/jobs/cache-warmer",
    "schedule": "*/30 * * * *"  # Every 30 minutes
  }]
}
// api/jobs/cache-warmer.ts (Vercel serverless function)
import { refreshExpiringCustomerState } from '../../jobs/cache-warmer';

export default async function handler(req: Request) {
  // Verify cron secret to prevent unauthorized calls
  const authHeader = req.headers.get('authorization');
  if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
    return new Response('Unauthorized', { status: 401 });
  }

  try {
    await refreshExpiringCustomerState();
    return new Response('OK', { status: 200 });
  } catch (error) {
    console.error('[cache-warmer-cron] Failed', error);
    return new Response('Internal Server Error', { status: 500 });
  }
}
Trade-offs:
  • Pros: Eliminates cache misses, consistent low latency for users
  • Cons: Increases API calls to Polar, adds infrastructure complexity
  • When to use: High-traffic applications where consistent latency is critical
  • When to skip: Small applications, or if Polar API rate limits are a concern

Network Failures

Your app should gracefully handle:
Polar API Down
try {
  const state = await syncPolarStateToKVByExternalId(polar, kv, userId);
} catch (error) {
  // Polar API is unreachable - use cached state as fallback
  console.error('[billing] Polar API unreachable, using cached state', error);
  const cachedState = await kv.get(`polar:state:${userId}`);

  if (!cachedState) {
    // No cached state - decide whether to fail open or closed
    // Fail open: allow access (risky)
    // Fail closed: deny access (safer, but bad UX)
    throw new Error('Unable to verify subscription status');
  }

  return cachedState;
}
KV Store Unreachable
try {
  await kv.set(`polar:state:${externalId}`, state);
} catch (error) {
  console.error('[billing] Failed to cache state', { externalId, error });
  // Don't fail the request - state is fetched successfully from Polar
  // Just log the cache failure for monitoring
}

Multiple Active Subscriptions

If you enable “Allow multiple subscriptions per customer” in Polar:
// Priority-based subscription resolution
const TIER_PRIORITY = ['enterprise', 'pro', 'basic'] as const;

export function getHighestTierSubscription(
  state: CustomerState | null | undefined,
  tierProductIds: Record<string, string>
): string | null {
  if (!state?.activeSubscriptions.length) return null;

  for (const tier of TIER_PRIORITY) {
    const hasThisTier = state.activeSubscriptions.some(
      (sub) =>
        sub.status === 'active' && sub.productId === tierProductIds[tier]
    );
    if (hasThisTier) return tier;
  }

  return null;
}

Environment Variables Reference

VariableRequiredDefaultDescription
POLAR_ACCESS_TOKENYes-Organization access token from Polar dashboard. Use polar_test_* for sandbox, polar_live_* for production.
POLAR_WEBHOOK_SECRETYes-Webhook signing secret from Polar. Found in organization settings → Webhooks.
POLAR_ENVNosandboxSet to production for live mode, sandbox for testing.
BETTER_AUTH_SECRETYes-Secret for signing BetterAuth sessions. Generate with openssl rand -base64 32.
UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKENDepends-Connection details for your KV store (example for Upstash; other providers use different env vars).
Example .env.local:
# Polar (sandbox)
POLAR_ACCESS_TOKEN=polar_test_abc123...
POLAR_WEBHOOK_SECRET=whsec_xyz789...
POLAR_ENV=sandbox

# BetterAuth
BETTER_AUTH_SECRET=your-generated-secret-here
BETTER_AUTH_URL=http://localhost:3000

# KV Store (Upstash)
UPSTASH_REDIS_REST_URL=https://your-instance.upstash.io
UPSTASH_REDIS_REST_TOKEN=your-kv-token

# Database
DATABASE_URL=postgresql://...

Testing & Monitoring

Local Development Setup

1

Set Up Sandbox Environment

  1. Create a sandbox organization in Polar
  2. Get your sandbox access token (starts with polar_test_)
  3. Configure webhook secret for sandbox
  4. Set POLAR_ENV=sandbox in .env.local
2

Set Up Webhook Tunneling

Use a tunnel service to expose your local server to Polar’s webhooks:Option A: Cloudflare Tunnel (Recommended)
# Install
brew install cloudflare/cloudflare/cloudflared

# Tunnel to local dev server
cloudflared tunnel --url http://localhost:3000
Option B: ngrok
ngrok http 3000
Update your webhook URL in Polar to the tunnel URL + /polar/webhooks
3

Set Up Local KV Store

Option A: Redis via Docker
docker run -p 6379:6379 redis:alpine
Option B: Upstash (serverless)
  • Create a free database at Upstash
  • Copy connection details to .env.local
Option C: In-memory (testing only)
// kv/in-memory.ts
const cache = new Map<string, any>();

export const kv = {
  async get(key: string) {
    return cache.get(key) ?? null;
  },
  async set(key: string, value: any) {
    cache.set(key, value);
  },
  async delete(key: string) {
    cache.delete(key);
  },
};

Integration Testing

Test the complete flow end-to-end:
test/billing.test.ts
import { describe, it, expect, vi } from 'vitest';
import { syncPolarStateToKVByExternalId } from '../billing/sync';
import { isActiveSubscriber } from '../billing/access';

describe('Billing Integration', () => {
  it('should sync customer state and validate subscription', async () => {
    const mockPolar = {
      customers: {
        getStateExternal: vi.fn().mockResolvedValue({
          customer: { id: 'cust_123', externalId: 'user_123' },
          activeSubscriptions: [
            { productId: 'prod_pro', status: 'active' },
          ],
          grantedBenefits: [{ slug: 'pro-plan' }],
          activeMeters: [],
        }),
      },
    };

    const mockKV = {
      get: vi.fn(),
      set: vi.fn(),
    };

    const state = await syncPolarStateToKVByExternalId(
      mockPolar as any,
      mockKV,
      'user_123'
    );

    expect(mockPolar.customers.getStateExternal).toHaveBeenCalledWith({
      externalId: 'user_123',
    });
    expect(mockKV.set).toHaveBeenCalled();
    expect(isActiveSubscriber(state)).toBe(true);
  });

  it('should handle 404 for non-existent customer', async () => {
    const mockPolar = {
      customers: {
        getStateExternal: vi.fn().mockRejectedValue({ status: 404 }),
      },
    };

    const mockKV = {
      get: vi.fn(),
      set: vi.fn(),
    };

    const state = await syncPolarStateToKVByExternalId(
      mockPolar as any,
      mockKV,
      'user_nonexistent'
    );

    expect(state).toBeNull();
    expect(mockKV.set).toHaveBeenCalledWith(
      'polar:state:user_nonexistent',
      null,
      expect.any(Object)
    );
  });
});

Monitoring & Alerting

Key metrics to track:
MetricDescriptionAlert Threshold
webhook_delivery_failuresCount of failed webhook deliveries> 5 in 5 minutes
sync_errorsFailed calls to syncPolarStateToKVByExternalId> 10 in 1 hour
polar_api_latency_p9595th percentile API response time> 2000ms
kv_cache_hit_rate% of requests served from cache< 80%
auth_token_errors401/403 responses from Polar> 0 (critical)
stale_customer_stateCustomer state not updated in 24h+Alert per customer
Structured Logging Best Practices:
Production Logging: For production applications, replace console.log/console.error with a proper logging library like Pino, Winston, or your platform’s native logger (e.g., Vercel’s logger).These libraries provide:
  • Structured JSON output for log aggregation (Datadog, CloudWatch, etc.)
  • Log levels and filtering
  • Performance optimizations
  • Correlation IDs for request tracing
Example: Structured logging with Pino
// utils/logger.ts
import pino from 'pino';

export const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  formatters: {
    level: (label) => ({ level: label }),
  },
});

export function logWebhookEvent(
  eventType: string,
  eventId: string,
  externalId: string,
  success: boolean,
  error?: any
) {
  const logData = {
    service: 'billing-webhooks',
    event: {
      type: eventType,
      id: eventId,
      externalId,
    },
    success,
    error: error
      ? {
          message: error.message,
          status: error.status,
          stack: error.stack,
        }
      : undefined,
  };

  if (success) {
    logger.info(logData, 'Webhook processed successfully');
  } else {
    logger.error(logData, 'Webhook processing failed');
  }
}

// Usage in webhook handler
async onCustomerStateChanged(event, ctx) {
  const externalId = event.data.customer.externalId;
  try {
    await syncPolarStateToKVByExternalId(polarSdk, kv, externalId);
    logWebhookEvent('customer.state_changed', event.id, externalId, true);
  } catch (error) {
    logWebhookEvent('customer.state_changed', event.id, externalId, false, error);
    throw error;
  }
}
Request ID Tracing: Add request IDs to correlate logs across your application:
// utils/request-context.ts
import { AsyncLocalStorage } from 'async_hooks';

interface RequestContext {
  requestId: string;
  userId?: string;
}

export const requestContext = new AsyncLocalStorage<RequestContext>();

export function getRequestId(): string {
  return requestContext.getStore()?.requestId || 'unknown';
}

// middleware/request-id.ts
export function requestIdMiddleware(req: Request): string {
  // Try to get request ID from header, or generate new one
  const requestId = req.headers.get('x-request-id') || crypto.randomUUID();

  requestContext.run({ requestId }, () => {
    // Your handler code runs here with access to requestId
  });

  return requestId;
}

// Updated billing/success.ts with request tracing
export async function handleBillingSuccess(req: Request): Promise<Response> {
  const requestId = requestIdMiddleware(req);

  try {
    const session = await auth.getSession(req);
    // ... rest of handler

    logger.info({ requestId, userId: user.id }, 'Billing success completed');
  } catch (error) {
    logger.error({ requestId, error }, 'Billing success failed');
    throw error;
  }
}
Example: Alerting with Sentry
import * as Sentry from '@sentry/node';

try {
  await syncPolarStateToKVByExternalId(polar, kv, userId);
} catch (error: any) {
  if (error?.status === 401 || error?.status === 403) {
    // Critical: Token issue
    Sentry.captureException(error, {
      level: 'fatal',
      tags: { service: 'billing', error_type: 'auth_failure' },
      extra: { userId, status: error.status },
    });
  } else {
    // Routine errors
    Sentry.captureException(error, {
      level: 'error',
      tags: { service: 'billing' },
    });
  }
  throw error;
}

Debugging Production Issues

Common failure modes:
Symptoms: Customer state doesn’t update after checkoutDebug steps:
  1. Check Polar dashboard → Webhooks → Delivery logs
  2. Verify webhook URL is correct and accessible
  3. Check for WAF/firewall blocking Polar’s IPs
  4. Test webhook endpoint manually with curl
  5. Check application logs for verification failures
Symptoms: User has active subscription in Polar but can’t access featuresDebug steps:
  1. Fetch state directly: polar.customers.getStateExternal({ externalId })
  2. Check KV cache: kv.get(\polar:state:$`)`
  3. Compare timestamps - is cache stale?
  4. Check webhook delivery logs for recent customer.state_changed events
  5. Manually trigger sync: syncPolarStateToKVByExternalId(polar, kv, userId)
Symptoms: User completes payment but doesn’t get accessDebug steps:
  1. Check checkout status in Polar: polar.checkouts.get({ id: checkoutId })
  2. Verify webhook was delivered (check Polar dashboard)
  3. Check application logs for sync errors around checkout time
  4. Verify external ID mapping: does checkout.customer.externalId match user.id?
  5. Check if createCustomerOnSignUp is enabled
Symptoms: Intermittent failures, especially during high trafficDebug steps:
  1. Check Polar dashboard for API usage metrics
  2. Increase KV cache TTL to reduce API calls
  3. Implement exponential backoff (see Rate Limiting section)
  4. Consider upgrading Polar plan if limits are insufficient
  5. Audit code for unnecessary calls to Polar API

Implementation Examples

KV Store Adapters

The guide references a KV<T> interface. Here are concrete implementations:
  • Upstash Redis
  • Vercel KV
  • ioredis
  • Cloudflare KV
kv/upstash.ts
import { Redis } from '@upstash/redis';
import type { KV } from '../billing/sync';

const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});

export const kv: KV<any> = {
  async get(key: string) {
    return await redis.get(key);
  },
  async set(key: string, value: any, opts?: { ex?: number }) {
    if (opts?.ex) {
      await redis.setex(key, opts.ex, value);
    } else {
      await redis.set(key, value);
    }
  },
  async delete(key: string) {
    await redis.del(key);
  },
};

Handling the Finishing Setup Race

When users are redirected to /billing/success after checkout, there’s often a race between:
  1. The eager sync in your success handler
  2. Polar’s webhook delivery
Show a “finishing setup” page that polls for subscription status:
  • React
  • SvelteKit
  • Vue
finishing-setup.tsx
import { useEffect, useState } from 'react';
import { authClient } from '../auth/client';
import { useNavigate } from 'react-router-dom';

export function FinishingSetupPage() {
  const [attempts, setAttempts] = useState(0);
  const [error, setError] = useState(false);
  const navigate = useNavigate();

  useEffect(() => {
    const checkInterval = setInterval(async () => {
      try {
        const { data: state } = await authClient.customer.state();

        // Check if subscription is active
        if (state?.activeSubscriptions?.length > 0) {
          clearInterval(checkInterval);
          navigate('/app');
          return;
        }

        setAttempts((prev) => prev + 1);

        // Give up after 30 seconds (15 attempts * 2s)
        if (attempts >= 15) {
          clearInterval(checkInterval);
          setError(true);
        }
      } catch (err) {
        console.error('Failed to check subscription status', err);
      }
    }, 2000); // Check every 2 seconds

    return () => clearInterval(checkInterval);
  }, [attempts, navigate]);

  if (error) {
    return (
      <div className="text-center p-8">
        <h1 className="text-2xl font-bold mb-4">Taking longer than expected</h1>
        <p className="mb-4">
          We're still processing your subscription. Please check back in a few
          minutes or contact support.
        </p>
        <a href="/support" className="text-blue-600 underline">
          Contact Support
        </a>
      </div>
    );
  }

  return (
    <div className="text-center p-8">
      <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4" />
      <h1 className="text-2xl font-bold mb-2">Finishing setup...</h1>
      <p className="text-gray-600">
        We're activating your subscription. This usually takes a few seconds.
      </p>
    </div>
  );
}
Typical webhook latency: Most webhooks arrive within 1-5 seconds of the event. The polling strategy above gives a 30-second window, which should cover 99%+ of cases.

Pro Tips & Guardrails

These are adapted from the original Polar guide, plus BetterAuth-specific notes.
Fetch/update/delete Customers and fetch Customer State by external ID.Avoid persisting Polar internal IDs except for debugging.
The checkout plugin automatically ties checkouts to the authenticated user, so customer.externalId appears in webhooks and Customer State.You shouldn’t be passing user IDs from the client explicitly.
This event is “one webhook to rule them all” and fires on customer, subscription, and benefit changes.Other events (order.*, subscription.*, benefit_grant.*) are optional extra triggers.
Set server: 'sandbox' in the Polar SDK for sandbox; production has a separate access token and separate products.
With BetterAuth’s webhooks plugin, signature verification is already handled for you; just configure the secret and endpoint.Still log event.id, event.type, and customer.externalId for debugging, and monitor for repeated failures.
Polar has an org-level toggle Allow multiple subscriptions per customer. Leave this off to enforce one active subscription per customer by default.Still enforce your policy at the app layer using Customer State + SubscriberPolicy (e.g. redirect existing subscribers to a “Manage plan” screen instead of starting a new checkout).
Use authClient.usage.ingest({...}) to send events, and authClient.usage.meters.list() or Customer State meters to read balances.Map meter balances to entitlements using getMeterBalance(state, 'meter-slug').

You’re Not Done

BetterAuth & Polar handle a lot, but you still own:
Managing sandbox vs production access tokens, webhook secrets, and env variables.
Which Polar products, prices, trials, and discounts are exposed via the checkout plugin configuration.
Choosing a TTL, eviction strategy, and storage location for CustomerState (polar:state:${externalId}).Optionally exposing a simple /api/billing/state endpoint for your front-end or other services.
Mapping Polar benefits (license keys, feature flags, roles) to your own permission model.Mapping meters and usage to quotas, credit balances, and overage behavior.
/billing/finishing-setup UX for races between /success and webhooks.Past due, grace periods, and what users see when their subscription is canceled or expires.Customer portal entry points (e.g. Manage billing button that calls authClient.customer.portal()).
Defining how users map to organizations, how many seats they consume, and how org-level subscriptions translate into per-user access.
BetterAuth’s plugin has patterns for syncing customer deletion; you decide when a user deletion should cascade to Polar.When you propagate deletion, preserve only what you genuinely need for tax, accounting, and disputes, and keep that in a separate, locked-down archive.

Quick Checklist

  • Configure POLAR_ACCESS_TOKEN, POLAR_WEBHOOK_SECRET, and POLAR_ENV='sandbox' in dev.
  • Set up BetterAuth with polar, checkout, portal, usage, and webhooks plugins.
  • Implement syncPolarStateToKVByExternalId(polarSdk, kv, externalId) using polar.customers.getStateExternal({ externalId }).
  • Implement /billing/success:
    • Auth user with BetterAuth.
    • Optionally verify checkout_id belongs to that user.
    • Call syncPolarStateToKVByExternalId(user.id).
    • Route based on isActiveSubscriber(state).
  • Configure BetterAuth webhooks:
    • Add onCustomerStateChanged and optionally onOrderPaid, onSubscriptionUpdated, etc.
    • Each handler calls syncPolarStateToKVByExternalId(externalId).
  • Implement isActiveSubscriber, hasBenefit, getMeterBalance, and requireSubscribedUser.
  • Decide single-subscription vs multi-subscription policy and enforce via Customer State.
  • (Optional) Wire up authClient.customer.portal(), authClient.customer.state(), and authClient.usage.* for richer UX.
If you implement all of the above, you’ll have a BetterAuth-native Polar integration that is…
  • immune to event-ordering nightmares,
  • easy to reason about,
  • and fully owned by your own explicit entitlement rules.

Further Reading