Polar is an open-source developer sales platform for monetizing libraries, SaaS, and APIs — new Polar({ accessToken }) creates the SDK. polar.checkouts.custom.create({ products: [{ productId, productPriceId }], customerId }) generates a checkout URL. polar.subscriptions.get(id) retrieves subscription state. Webhooks validate with validateEvent(payload, headers, secret) — events include subscription.created, subscription.updated, subscription.revoked, and order.created. Benefits are deliverable perks: github_repository stars, discord_role, file_download, license_keys, and custom. polar.customers.getExternal({ externalId: userId }) finds a Polar customer by your user ID. polar.customerSessions.create({ customerId }) generates a customer portal URL. Polar is self-hostable and has a hosted version at polar.sh. Claude Code generates Polar checkout flows, subscription webhooks, benefit provisioning, and customer portal integration.
CLAUDE.md for Polar
## Polar Stack
- Version: @polar-sh/sdk >= 0.26
- Init: const polar = new Polar({ accessToken: process.env.POLAR_ACCESS_TOKEN!, server: "production" })
- Checkout: const { data } = await polar.checkouts.custom.create({ products: [{ productId }], successUrl, customerEmail })
- Get customer: await polar.customers.getExternal({ externalId: userId, organizationId: ORG_ID })
- Subscription: await polar.subscriptions.get({ id: subscriptionId })
- Portal: const { data } = await polar.customerSessions.create({ customerId })
- Webhook: validateEvent(rawBody, headers, POLAR_WEBHOOK_SECRET) — throws on invalid
Polar Client
// lib/billing/polar.ts — Polar SDK utilities
import { Polar } from "@polar-sh/sdk"
import { validateEvent, WebhookVerificationError } from "@polar-sh/sdk/webhooks"
import { db } from "@/lib/db"
import { users, subscriptions } from "@/lib/db/schema"
import { eq } from "drizzle-orm"
export const polar = new Polar({
accessToken: process.env.POLAR_ACCESS_TOKEN!,
server: process.env.NODE_ENV === "production" ? "production" : "sandbox",
})
const ORG_ID = process.env.POLAR_ORG_ID!
// Plan product/price IDs from Polar dashboard
const PLAN_PRODUCTS: Record<string, { productId: string; priceId?: string }> = {
pro_monthly: { productId: process.env.POLAR_PRO_MONTHLY_PRODUCT_ID! },
pro_yearly: { productId: process.env.POLAR_PRO_YEARLY_PRODUCT_ID! },
enterprise_monthly: { productId: process.env.POLAR_ENTERPRISE_MONTHLY_PRODUCT_ID! },
}
// ── Checkout ───────────────────────────────────────────────────────────────
export async function createPolarCheckout(params: {
userId: string
email: string
plan: string
successUrl?: string
metadata?: Record<string, string>
}): Promise<string> {
const product = PLAN_PRODUCTS[params.plan]
if (!product) throw new Error(`Unknown plan: ${params.plan}`)
// Look up or note the Polar customer
let polarCustomerId: string | undefined
try {
const customer = await polar.customers.getExternal({
externalId: params.userId,
organizationId: ORG_ID,
})
polarCustomerId = customer.id
} catch {
// Customer doesn't exist yet — Polar will create them at checkout
}
const checkout = await polar.checkouts.custom.create({
products: [{ productId: product.productId }],
successUrl: params.successUrl ?? `${process.env.APP_URL}/dashboard?welcome=1`,
customerEmail: params.email,
customerId: polarCustomerId,
metadata: {
userId: params.userId,
...params.metadata,
},
})
return checkout.url
}
// ── Customer portal ────────────────────────────────────────────────────────
export async function createPortalSession(userId: string): Promise<string> {
const customer = await polar.customers.getExternal({
externalId: userId,
organizationId: ORG_ID,
})
const session = await polar.customerSessions.create({
customerId: customer.id,
})
return session.customerPortalUrl
}
// ── Webhook signature verification ────────────────────────────────────────
export { validateEvent, WebhookVerificationError }
Webhook Handler
// app/api/webhooks/polar/route.ts — subscription lifecycle
import { NextRequest, NextResponse } from "next/server"
import { validateEvent, WebhookVerificationError } from "@polar-sh/sdk/webhooks"
import { polar } from "@/lib/billing/polar"
import { db } from "@/lib/db"
import { users } from "@/lib/db/schema"
import { eq } from "drizzle-orm"
export async function POST(request: NextRequest) {
const rawBody = await request.text()
const headers = {
"webhook-id": request.headers.get("webhook-id") ?? "",
"webhook-timestamp": request.headers.get("webhook-timestamp") ?? "",
"webhook-signature": request.headers.get("webhook-signature") ?? "",
}
let event: ReturnType<typeof validateEvent>
try {
event = validateEvent(rawBody, headers, process.env.POLAR_WEBHOOK_SECRET!)
} catch (err) {
if (err instanceof WebhookVerificationError) {
return new Response("Invalid signature", { status: 403 })
}
return new Response("Webhook error", { status: 400 })
}
// Idempotency check
const processed = await db.query.polarEvents?.findFirst({
where: eq((polarEvents as any).eventId, event.id),
})
if (processed) return NextResponse.json({ ok: true })
try {
switch (event.type) {
case "subscription.created":
case "subscription.updated": {
const sub = event.data
const userId = sub.metadata?.userId as string | undefined
if (!userId) break
// Determine plan from product
const productId = sub.product.id
let plan: "free" | "pro" | "enterprise" = "free"
if (Object.values(process.env).includes(productId)) {
// Map back from product ID to plan name
const entry = Object.entries({
POLAR_PRO_MONTHLY_PRODUCT_ID: "pro",
POLAR_PRO_YEARLY_PRODUCT_ID: "pro",
POLAR_ENTERPRISE_MONTHLY_PRODUCT_ID: "enterprise",
}).find(([envKey]) => process.env[envKey] === productId)
if (entry) plan = entry[1] as "pro" | "enterprise"
}
const activeStatuses = ["active", "trialing"]
await db.update(users)
.set({ plan: activeStatuses.includes(sub.status) ? plan : "free" })
.where(eq(users.id, userId))
break
}
case "subscription.revoked":
case "subscription.canceled": {
const sub = event.data
const userId = sub.metadata?.userId as string | undefined
if (!userId) break
await db.update(users)
.set({ plan: "free" })
.where(eq(users.id, userId))
break
}
case "order.created": {
// One-time purchase — provision immediately
const order = event.data
const userId = order.metadata?.userId as string | undefined
if (!userId) break
// Grant access based on product
console.log(`[Polar] order created for user ${userId}, product ${order.product.id}`)
break
}
case "benefit.granted": {
// A benefit was provisioned (GitHub repo access, Discord role, etc.)
const { benefit, customer } = event.data
console.log(`[Polar] benefit granted: ${benefit.type} for customer ${customer.id}`)
break
}
}
return NextResponse.json({ ok: true })
} catch (err) {
console.error("[Polar webhook error]", err)
return NextResponse.json({ error: "Handler error" }, { status: 500 })
}
}
Billing Status Hook
// hooks/usePolarBilling.ts — billing state + checkout
"use client"
import { useState, useCallback } from "react"
export function usePolarBilling() {
const [isLoading, setIsLoading] = useState(false)
const startCheckout = useCallback(async (plan: string) => {
setIsLoading(true)
try {
const res = await fetch("/api/billing/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ plan }),
})
const { url } = await res.json()
window.location.href = url
} finally {
setIsLoading(false)
}
}, [])
const openPortal = useCallback(async () => {
setIsLoading(true)
try {
const res = await fetch("/api/billing/portal")
const { url } = await res.json()
window.open(url, "_blank")
} finally {
setIsLoading(false)
}
}, [])
return { startCheckout, openPortal, isLoading }
}
For the Stripe alternative when enterprise-grade billing features like complex subscription items, metered usage, detailed revenue reporting, advanced fraud detection, and a mature webhook ecosystem are required — Stripe is the industry standard for billing infrastructure while Polar is purpose-built for developer tools and OSS monetization with better defaults for that use case, see the Stripe Subscriptions guide. For the Lemon Squeezy alternative when a Merchant of Record solution that handles taxes globally without any tax registration is preferred — both Polar (in MoR mode) and Lemon Squeezy handle taxes, but Lemon Squeezy has a longer track record, see the Lemon Squeezy guide. The Claude Skills 360 bundle includes Polar skill sets covering checkout, webhooks, and benefits. Start with the free tier to try developer billing generation.