Polar
Payment and subscription system with Polar
Beztack uses Polar as its payment and subscription infrastructure, seamlessly integrated with Better Auth to provide a frictionless auth + payments flow.
Why Polar?
We chose Polar because:
- Developer First: Built with developers in mind
- Native Integration: Official plugin for Better Auth
- Subscriptions: Full SaaS support
- Usage-Based Billing: Usage-based billing
- Customer Portal: Management portal for customers
- Multi-tenancy: Organization support
- Webhooks: Robust notification system
- Sandboxing: Complete testing environment
Initial Setup
1. Create Polar Account
- Go to Polar.sh and create an account
- Create an organization
- Use the Sandbox environment for development
2. Environment Variables
Set up the following variables in your .env file:
# Polar Configuration
POLAR_ACCESS_TOKEN=polar_at_xxxxxxxxxxxxx
POLAR_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxx
# Product IDs (get from Polar Dashboard)
POLAR_BASIC_MONTHLY_PRODUCT_ID=prod_xxxxx
POLAR_BASIC_YEARLY_PRODUCT_ID=prod_xxxxx
POLAR_PRO_MONTHLY_PRODUCT_ID=prod_xxxxx
POLAR_PRO_YEARLY_PRODUCT_ID=prod_xxxxx
POLAR_ULTIMATE_MONTHLY_PRODUCT_ID=prod_xxxxx
POLAR_ULTIMATE_YEARLY_PRODUCT_ID=prod_xxxxx
# URLs
POLAR_SUCCESS_URL=http://localhost:5173/success3. Get Access Token
- Go to Organization Settings in Polar
- Navigate to Access Tokens
- Create a new token with the necessary permissions
- Copy the token to your
.env
4. Configure Webhook
- Go to Organization Settings → Webhooks
- Create a new webhook endpoint:
https://your-domain.com/polar/webhooks - Copy the Webhook Secret to your
.env
Better Auth Configuration
Server (Backend)
Polar plugin configuration is in apps/api/server/utils/auth.ts:
import { betterAuth } from "better-auth";
import { polar, checkout, portal, usage } from "@polar-sh/better-auth";
import { Polar } from "@polar-sh/sdk";
const polarClient = new Polar({
accessToken: process.env.POLAR_ACCESS_TOKEN,
server: "sandbox", // "production" for production
});
export const auth = betterAuth({
// ... Better Auth configuration
plugins: [
polar({
client: polarClient,
createCustomerOnSignUp: true,
use: [
checkout({
products: [
{
productId: process.env.POLAR_BASIC_MONTHLY_PRODUCT_ID,
slug: "basic-monthly",
},
{
productId: process.env.POLAR_PRO_MONTHLY_PRODUCT_ID,
slug: "pro-monthly",
},
],
successUrl: process.env.POLAR_SUCCESS_URL,
authenticatedUsersOnly: true,
}),
portal(),
usage(),
],
}),
],
});Client (Frontend)
En apps/ui/src/lib/auth-client.ts:
import { createAuthClient } from "better-auth/react";
import { polarClient } from "@polar-sh/better-auth";
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_API_URL,
plugins: [
polarClient(), // ← Add Polar plugin
],
});Create Products in Polar
1. From the Dashboard
- Go to Products in your Polar Dashboard
- Click Create Product
- Fill in the information:
- Product name (e.g., "Pro Monthly")
- Description
- Price
- Billing type (one-time or recurring)
- Included benefits
2. Configure Benefits
Benefits determine what features the user has access to:
- Go to Benefits in Polar
- Create benefits like:
unlimited_storagepriority_supportadvanced_analytics
- Associate benefits with products
Checkout (Payments)
Start Basic Checkout
import { authClient } from "@/lib/auth-client";
// Option 1: Use product slug
await authClient.checkout({
slug: "pro-monthly",
});
// Option 2: Use product ID directly
await authClient.checkout({
products: ["prod_xxxxxxxxxxxxx"],
});Checkout with Organization
To associate a purchase with an organization:
// Get active organization
const { data: organizations } = await authClient.organization.list();
const organizationId = organizations?.[0]?.id;
// Start checkout with referenceId
await authClient.checkout({
slug: "pro-monthly",
referenceId: organizationId, // ← Important for multi-tenancy
});Success Page
After completing checkout, the user is redirected to successUrl:
// app/success/page.tsx
import { useSearchParams } from "next/navigation";
export default function SuccessPage() {
const searchParams = useSearchParams();
const checkoutId = searchParams.get("checkout_id");
return (
<div>
<h1>Payment Successful!</h1>
<p>Checkout ID: {checkoutId}</p>
</div>
);
}Customer Portal
The Customer Portal lets users manage their subscriptions, orders, and benefits.
Redirect to Portal
import { authClient } from "@/lib/auth-client";
// Redirect to Polar portal
await authClient.customer.portal();Get Customer State
// Get all customer information
const { data: customerState } = await authClient.customer.state();
// customerState contains:
// - Customer data
// - Active subscriptions
// - Granted benefits
// - Active usage metersList Benefits
const { data: benefits } = await authClient.customer.benefits.list({
query: {
page: 1,
limit: 10,
},
});
// Check if they have a specific benefit
const hasPrioritySupport = benefits?.items.some(
(benefit) => benefit.type === "priority_support"
);List Orders
const { data: orders } = await authClient.customer.orders.list({
query: {
page: 1,
limit: 10,
productBillingType: "recurring", // "one_time" for one-time purchases
},
});List Subscriptions
const { data: subscriptions } = await authClient.customer.subscriptions.list({
query: {
page: 1,
limit: 10,
active: true,
},
});Organization Subscriptions
To check if an organization has an active subscription:
const organizationId = "org-123";
const { data: subscriptions } = await authClient.customer.subscriptions.list({
query: {
page: 1,
limit: 10,
active: true,
referenceId: organizationId, // ← Filter by organization
},
});
const hasActiveSubscription = subscriptions?.items.length > 0;Usage-Based Billing
Polar supports usage-based billing through events and meters.
Record Events
// Record a usage event
await authClient.usage.ingest({
event: "file-uploads",
metadata: {
uploadedFiles: 12,
fileSize: 1024000, // bytes
},
});List Customer Meters
const { data: customerMeters } = await authClient.usage.meters.list({
query: {
page: 1,
limit: 10,
},
});
// customerMeters contains:
// - Units consumed
// - Units credited
// - Current balanceConfigure Meters in Polar
- Go to Meters in the Polar Dashboard
- Create a meter (e.g., "API Requests")
- Define aggregation rules
- Associate the meter with a price in the product
Verify Memberships on Server
Beztack includes utilities to verify memberships on the server.
Use requireAuth with Membership
import { requireAuth } from "~/utils/membership";
export default defineEventHandler(async (event) => {
const user = await requireAuth(event);
// user.membership contains:
// - tier: "free" | "pro" | "enterprise"
// - hasActiveSubscription: boolean
// - benefits: Benefit[]
// - organizationId?: string
if (user.membership?.tier === "free") {
throw createError({
statusCode: 403,
message: "This feature requires a Pro subscription",
});
}
// Endpoint logic
});Verify Specific Benefits
const user = await requireAuth(event);
const hasFeature = user.membership?.benefits.some(
(benefit) => benefit.type === "advanced_analytics"
);
if (!hasFeature) {
throw createError({
statusCode: 403,
message: "You don't have access to this feature",
});
}Webhooks
Polar webhooks are automatically configured at /polar/webhooks.
Handle Events
You can add custom handlers in apps/api/server/utils/auth.ts:
import { webhooks } from "@polar-sh/better-auth";
polar({
client: polarClient,
use: [
webhooks({
secret: process.env.POLAR_WEBHOOK_SECRET,
// When an order is paid
onOrderPaid: async (payload) => {
console.log("Order paid:", payload);
// Activate functionality
// Send confirmation email
},
// When a subscription is updated
onSubscriptionUpdated: async (payload) => {
console.log("Subscription updated:", payload);
// Update permissions
},
// When a subscription is canceled
onSubscriptionCanceled: async (payload) => {
console.log("Subscription canceled:", payload);
// Revoke access
// Send cancellation email
},
// Catch-all for all events
onPayload: async (payload) => {
console.log("Webhook received:", payload.event_type);
},
}),
],
});Available Events
Polar supports over 25 types of webhooks:
onCheckoutCreated,onCheckoutUpdatedonOrderCreated,onOrderPaid,onOrderRefundedonSubscriptionCreated,onSubscriptionUpdated,onSubscriptionActiveonSubscriptionCanceled,onSubscriptionRevokedonProductCreated,onProductUpdatedonBenefitCreated,onBenefitUpdatedonBenefitGrantCreated,onBenefitGrantRevokedonCustomerCreated,onCustomerUpdated,onCustomerDeletedonCustomerStateChanged
UI Components
Beztack includes React components to display Polar information.
Billing Dashboard
import { BillingDashboard } from "@/components/payments/billing-dashboard";
export default function BillingPage() {
return <BillingDashboard />;
}Usage Metrics
import { UsageMetrics } from "@/components/payments/usage-metrics";
export default function UsagePage() {
return <UsageMetrics />;
}Membership Context
To access membership information in any component:
import { useMembership } from "@/contexts/membership-context";
export function FeatureGuard({ children }) {
const { membership, loading } = useMembership();
if (loading) return <Spinner />;
if (membership?.tier === "free") {
return <UpgradeBanner />;
}
return children;
}Testing in Sandbox
Test Cards
Polar provides test cards for sandbox:
- Successful:
4242 4242 4242 4242 - Declined:
4000 0000 0000 0002 - Any future date and valid CVC
Sandbox vs Production Differences
- Tokens and products are completely separate
- Real payments are not processed in sandbox
- Uses different webhook URLs
- Change
server: "sandbox"toserver: "production"
Best Practices
1. Always Use ReferenceId for Organizations
await authClient.checkout({
slug: "pro-monthly",
referenceId: organizationId, // ← Critical for multi-tenancy
});2. Verify State on Server
Never trust the client alone to verify subscriptions:
const user = await requireAuth(event);
if (!user.membership?.hasActiveSubscription) {
throw createError({ statusCode: 403 });
}3. Handle Webhooks Appropriately
onSubscriptionCanceled: async (payload) => {
// Mark as canceled in your DB
// Schedule access revocation for end of period
// Don't revoke immediately if there's a grace period
},4. Use Customer State for Decisions
const { data: state } = await authClient.customer.state();
const hasFeature = state?.benefits.some(b => b.type === "feature");5. Complete Testing
Test all flows:
- Successful checkout
- Canceled checkout
- Subscription renewal
- Subscription cancellation
- Refunds
- Webhooks
6. Separate Environments
- Use sandbox for development
- Use production only when ready
- Don't mix tokens between environments
Troubleshooting
Checkout doesn't work
- Verify that
POLAR_ACCESS_TOKENis valid - Confirm that Product IDs exist in Polar
- Check that the user is authenticated
- Verify
successUrlis valid
Webhooks not arriving
- Confirm the endpoint is publicly accessible
- Verify
POLAR_WEBHOOK_SECRET - Check logs in Polar Dashboard → Webhooks
- Use tools like ngrok for local development
Customer not created
- Verify that
createCustomerOnSignUp: true - Check that the token has write permissions
- Confirm that the user has a unique email