Your app checks access by reading CustomerState from KV and applying explicit rules
Once this is in place, all of your billing logic becomes:
Copy
// Pseudocodeconst 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.
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_changedwebhook 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.
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.
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.
This route gives users instant feedback after checkout and eagerly syncs Customer State.
success.ts
Copy
// routes/billing/success.tsimport { 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.
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
Copy
// 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:
Event
Priority
When to Handle
Notes
customer.state_changed
High
Always sync
Covers most state changes - subscriptions, benefits, customer data
order.paid
Medium
Optional - faster UX
Sync immediately after payment for instant access
subscription.created
Low
Optional logging
Redundant with customer.state_changed
subscription.updated
Low
Optional logging
Redundant with customer.state_changed
subscription.canceled
Low
Optional logging
Redundant with customer.state_changed
benefit_grant.created
Low
Optional logging
Redundant with customer.state_changed
benefit_grant.revoked
Low
Optional logging
Redundant 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.
Store the raw Customer State blob and derive entitlements at read time.
access.ts
Copy
// billing/access.tsimport 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
Copy
// billing/guards.tsimport 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.
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.
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.
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:
Copy
// Immediate deletion - only use if you don't need financial recordsasync 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:
Copy
// Recommended: Soft delete with retention periodasync 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:
Copy
// jobs/cleanup-expired-deletions.tsexport 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:
Copy
// Handle chargeback even after customer deletionasync 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:
Revoke all access and pseudonymise PII
Archive minimal financial data to a separate, restricted table
Archived financial records are still subject to access requests
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.
// Decide your cancellation policyexport 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:
Copy
// Grace period for past_due subscriptionsconst 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; });}
Polar’s API has rate limits. Your KV cache mitigates this, but you should still handle 429 responses:
Copy
// Exponential backoff helperasync 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;}// Usageconst 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:
Copy
// jobs/cache-warmer.tsimport { 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 [];}
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
Copy
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}
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.)
When users are redirected to /billing/success after checkout, there’s often a race between:
The eager sync in your success handler
Polar’s webhook delivery
Show a “finishing setup” page that polls for subscription status:
React
SvelteKit
Vue
finishing-setup.tsx
Copy
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.
These are adapted from the original Polar guide, plus BetterAuth-specific notes.
Prefer external IDs everywhere.
Fetch/update/delete Customers and fetch Customer State by external ID.Avoid persisting Polar internal IDs except for debugging.
Let BetterAuth set `external_customer_id`.
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.
Prefer `customer.state_changed` as your primary webhook trigger
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.
Use sandbox correctly
Set server: 'sandbox' in the Polar SDK for sandbox; production has a separate access token and separate products.
Webhook hygiene
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.
Single-subscription policies
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).
Usage-based billing
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').
BetterAuth & Polar handle a lot, but you still own:
Environment configuration
Managing sandbox vs production access tokens, webhook secrets, and env variables.
Product & pricing configuration
Which Polar products, prices, trials, and discounts are exposed via the checkout plugin configuration.
KV/DB modeling
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.
Entitlement design
Mapping Polar benefits (license keys, feature flags, roles) to your own permission model.Mapping meters and usage to quotas, credit balances, and overage behavior.
UX & edge cases
/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()).
Org/seat mapping
Defining how users map to organizations, how many seats they consume, and how org-level subscriptions translate into per-user access.
Deletion & GDPR flows
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.