Claude Code with Polar: Open-Source Monetisation for Devs
Why Polar is a fit for indie SaaS that Claude does not assume
Polar is a payments and monetisation platform built for developers who sell digital products: SaaS subscriptions, one-time purchases, paid GitHub repository access, license keys, and donation-style tipping. It positions itself as the developer-first alternative to LemonSqueezy and Stripe, with a cleaner API surface, native GitHub integration, and merchant-of-record handling that removes tax compliance work from the developer's plate.
Claude Code does not know any of this by default. When you mention "billing" or "subscriptions", Claude reaches for Stripe patterns because that is the dominant training data. The result is code that works in Polar (the SDK is similar in shape) but misses the patterns that make Polar's developer experience worthwhile: the hosted checkout links that need no client-side SDK, the metadata field for linking Polar customers to your application's user records, the webhook event taxonomy that differs from Stripe's, and the customer portal that does not require building your own subscription management UI.
This guide covers the CLAUDE.md configuration that locks Claude Code into Polar's idiomatic patterns: the checkout link pattern, the SDK-based product and price management, the webhook handling with signature verification, the customer portal session creation, and the metadata-driven user linking. If you are building the broader application around Polar, Claude Code with Next.js covers the request lifecycle that webhooks land on, and Claude Code with Stripe covers the same patterns in the comparison platform.
The Polar CLAUDE.md template
The CLAUDE.md at your project root needs to declare which Polar SDK version you are using, the API access token type (OAuth or PAT), the product and price structure, and the webhook signature verification pattern.
# Polar billing rules
## Stack
- @polar-sh/sdk ^0.x
- TypeScript 5.x strict, Node.js 20.x
- Polar account with organization-scoped access token
- POLAR_ACCESS_TOKEN in .env.local (NEVER expose to client)
- POLAR_WEBHOOK_SECRET in .env.local
- POLAR_ENVIRONMENT="sandbox" or "production"
## Access token types (CRITICAL distinction)
- Organization Access Token (OAT): server-to-server, full org permissions
- Personal Access Token (PAT): tied to a user, scoped permissions
- For SaaS backend: ALWAYS use Organization Access Token
- NEVER commit either type to git, ALWAYS environment variables
## Project structure
- src/lib/polar.ts Polar SDK singleton (server only)
- src/app/api/webhooks/polar/route.ts Webhook handler
- src/app/api/checkout/route.ts Checkout link creation
- src/app/api/portal/route.ts Customer portal session
## Products and prices
- Product = the thing you sell (e.g. "Claudify Pro")
- Price = a specific price point for a product (monthly $29, yearly $290)
- Checkout links are created with product_price_id values (NOT product_id)
- Store price IDs in environment variables, NEVER hardcode in components
## Customer linking via metadata
- Polar customers exist independently of your app users
- ALWAYS pass metadata: { userId: yourAppUserId } when creating checkouts
- Read event.data.metadata.userId in webhook handlers to map back
## Hard rules
- NEVER expose POLAR_ACCESS_TOKEN to the client
- NEVER skip webhook signature verification
- NEVER hardcode product or price IDs in source files
- NEVER call paddle.* or stripe.* style methods, the SDK is polar.*
- NEVER assume Polar customer IDs match your user IDs (they do not)
- ALWAYS use Organization Access Tokens for backend, NEVER Personal Access Tokens
Three rules in this template prevent the majority of failures Claude generates without them.
The access token type rule is the most important security-wise. Claude trained on generic API docs will sometimes use a Personal Access Token (PAT) in backend code because PATs are easier to create from the dashboard. PATs are tied to a single user account, so if that user leaves the org or rotates their token, your production billing breaks. Organization Access Tokens (OATs) are tied to the org itself and survive personnel changes.
The metadata rule is the most important architecturally. Polar customers have their own IDs (customer_xxx) that are independent of your application's user IDs. Without explicit metadata, there is no way to map a Polar webhook event back to a user in your database. Claude often skips the metadata field because it is optional, then writes complex lookup logic on top to figure out which user a subscription belongs to.
The price ID vs product ID rule prevents a common runtime error. Polar's checkout API expects product_price_id (pri_xxx) values, not product_id (prod_xxx) values. The naming similarity confuses Claude, and the error message at checkout time is not always clear about which ID type was wrong.
Install and SDK setup
Install the SDK:
npm i @polar-sh/sdk
Set up the server-side singleton:
// src/lib/polar.ts
import { Polar } from '@polar-sh/sdk';
if (!process.env.POLAR_ACCESS_TOKEN) {
throw new Error('POLAR_ACCESS_TOKEN is not defined');
}
export const polar = new Polar({
accessToken: process.env.POLAR_ACCESS_TOKEN,
server:
process.env.POLAR_ENVIRONMENT === 'production'
? 'production'
: 'sandbox',
});
The server config field controls which Polar environment you talk to. Sandbox and production are separate (sandbox checkouts do not charge real cards, production does), and the access token from one will not work against the other. Claude sometimes mixes these because the SDK does not enforce alignment at the type level.
Configure environment variables:
# .env.local
POLAR_ACCESS_TOKEN=polar_oat_xxxxxxxxxxxx
POLAR_WEBHOOK_SECRET=polar_whsec_xxxxxxxxxxxx
POLAR_ENVIRONMENT=sandbox
# Product price IDs (one per plan)
POLAR_PRICE_PRO_MONTHLY=price_xxxxxxxxxxxx
POLAR_PRICE_PRO_YEARLY=price_xxxxxxxxxxxx
Add a setup section to CLAUDE.md:
## SDK setup
- src/lib/polar.ts owns the single Polar SDK instance
- accessToken from POLAR_ACCESS_TOKEN env var
- server: 'production' or 'sandbox' (match POLAR_ENVIRONMENT)
- NEVER instantiate new Polar() inline in route handlers
- The SDK validates the token at first request, not at construction
Checkout links
Polar's checkout flow centres on checkout links: hosted URLs that present a clean payment form, collect tax info automatically, accept card payments, and redirect on completion. Your application creates a checkout link via the API, then redirects the user or sends them the URL.
// src/app/api/checkout/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { polar } from '@/lib/polar';
export async function POST(req: NextRequest) {
const { priceId, userId, userEmail } = await req.json();
if (!priceId || !userId) {
return NextResponse.json(
{ error: 'priceId and userId required' },
{ status: 400 }
);
}
const checkout = await polar.checkouts.create({
productPriceId: priceId,
customerEmail: userEmail,
successUrl: `${process.env.NEXT_PUBLIC_APP_URL}/billing/success?checkout_id={CHECKOUT_ID}`,
metadata: {
userId,
source: 'app-upgrade',
},
});
return NextResponse.json({
url: checkout.url,
id: checkout.id,
});
}
A few details Claude tends to get wrong without explicit guidance.
The successUrl template includes {CHECKOUT_ID} which Polar replaces with the actual checkout ID on completion. This lets your success page look up the checkout server-side to confirm the state, rather than trusting whatever query parameters the redirect contained. Claude often skips this template and writes its own confirmation logic.
The metadata object is the link between Polar's customer record and your application's user record. The userId in metadata flows through to every subsequent webhook event for this customer (subscription created, payment succeeded, subscription cancelled). Without it, your webhook handler has to guess which user owns the subscription based on the email address, which fails when users have multiple email addresses or change their email.
The customerEmail field pre-fills the email on the checkout form. Claude sometimes omits this, which means authenticated users have to retype their email when upgrading. Pre-fill it.
From a client component:
'use client';
async function startCheckout(priceId: string) {
const res = await fetch('/api/checkout', {
method: 'POST',
body: JSON.stringify({
priceId,
userId: currentUser.id,
userEmail: currentUser.email,
}),
});
const { url } = await res.json();
window.location.href = url;
}
Add a checkout section to CLAUDE.md:
## Checkout
- polar.checkouts.create({ productPriceId, customerEmail, successUrl, metadata })
- successUrl ALWAYS includes {CHECKOUT_ID} template
- metadata.userId ALWAYS set to your app's user ID
- customerEmail pre-fills the form for authenticated users
- Return { url, id } from your API, redirect from client
- For one-time purchases: same flow, productPriceId points to a one-time price
Webhook signature verification
Polar signs every webhook with a shared secret using HMAC-SHA256. The @polar-sh/sdk package exports a helper for verification, or you can verify manually.
// src/app/api/webhooks/polar/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { validateEvent, WebhookVerificationError } from '@polar-sh/sdk/webhooks';
const webhookSecret = process.env.POLAR_WEBHOOK_SECRET;
export async function POST(req: NextRequest) {
if (!webhookSecret) {
return NextResponse.json(
{ error: 'Webhook secret not configured' },
{ status: 500 }
);
}
const rawBody = await req.text();
const headers = {
'webhook-id': req.headers.get('webhook-id') ?? '',
'webhook-timestamp': req.headers.get('webhook-timestamp') ?? '',
'webhook-signature': req.headers.get('webhook-signature') ?? '',
};
let event;
try {
event = validateEvent(rawBody, headers, webhookSecret);
} catch (err) {
if (err instanceof WebhookVerificationError) {
console.error('[Polar] Invalid signature:', err);
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 400 }
);
}
throw err;
}
// Idempotency check
const alreadyProcessed = await checkEventProcessed(event.id);
if (alreadyProcessed) {
return NextResponse.json({ ok: true, deduplicated: true });
}
switch (event.type) {
case 'subscription.created':
await handleSubscriptionCreated(event.data);
break;
case 'subscription.active':
await activateSubscription(event.data);
break;
case 'subscription.canceled':
await cancelSubscription(event.data);
break;
case 'subscription.revoked':
await revokeSubscription(event.data);
break;
case 'order.created':
await recordOrder(event.data);
break;
case 'order.refunded':
await processRefund(event.data);
break;
default:
console.log('[Polar] Unhandled event:', event.type);
}
await markEventProcessed(event.id);
return NextResponse.json({ ok: true });
}
async function checkEventProcessed(eventId: string): Promise<boolean> {
return false;
}
async function markEventProcessed(eventId: string): Promise<void> {
// Insert event ID into webhook log table
}
async function handleSubscriptionCreated(data: any) {
const userId = data.metadata?.userId;
if (!userId) {
console.error('[Polar] Subscription created without userId in metadata', data.id);
return;
}
// Store subscription against your user record
}
async function activateSubscription(data: any) {
const userId = data.metadata?.userId;
if (!userId) return;
// Grant access to paid features
}
async function cancelSubscription(data: any) {
// Schedule revocation at period end
}
async function revokeSubscription(data: any) {
// Immediate revocation (refund, chargeback, etc.)
}
async function recordOrder(data: any) {
// Append to orders / revenue table
}
async function processRefund(data: any) {
// Update order, potentially reverse access grant
}
Two details worth pulling out.
The headers Polar uses for signature verification follow the Standard Webhooks specification (webhook-id, webhook-timestamp, webhook-signature). Claude often reaches for polar-signature or x-polar-signature because that is the pattern other webhook providers use. Polar follows the standard, so use the standard headers.
The idempotency check is non-negotiable. Polar's webhook delivery is at-least-once. A network timeout, a transient error in your handler, or a Polar internal retry will cause the same event to arrive twice. Storing event.id and skipping duplicates prevents double-granting access or double-counting revenue.
Add a webhook section to CLAUDE.md:
## Webhooks
- Endpoint: src/app/api/webhooks/polar/route.ts
- Headers: webhook-id, webhook-timestamp, webhook-signature (Standard Webhooks spec)
- Use validateEvent from @polar-sh/sdk/webhooks to verify and parse
- ALWAYS check event.id idempotency before processing
- Read metadata.userId from event.data to link to your user record
- Handle: subscription.created, subscription.active, subscription.canceled,
subscription.revoked, order.created, order.refunded
- Return 200 for valid events (even unhandled), 400 only for invalid signature
- Raw body MUST be read with req.text() before parsing
Get Claudify. The Polar-aware CLAUDE.md template ships with checkout patterns, metadata linking, and webhook idempotency pre-configured.
Customer portal sessions
Polar provides a hosted customer portal where users manage their own subscriptions: update payment methods, view invoices, cancel, switch plans. Your application creates a portal session for an authenticated user and redirects them.
// src/app/api/portal/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { polar } from '@/lib/polar';
export async function POST(req: NextRequest) {
const { customerId } = await req.json();
if (!customerId) {
return NextResponse.json(
{ error: 'customerId required' },
{ status: 400 }
);
}
const session = await polar.customerSessions.create({
customerId,
});
return NextResponse.json({
url: session.customerPortalUrl,
});
}
The customer ID (customer_xxx) comes from your database, where you stored it during the first subscription.created or order.created webhook. Claude often tries to look up the customer by email at portal-creation time, which is unnecessary because the customer ID is already in your records.
Add a customer portal section to CLAUDE.md:
## Customer portal
- polar.customerSessions.create({ customerId })
- Read customerId from your DB (stored during subscription.created webhook)
- NEVER look up customers by email at portal creation time
- Redirect to session.customerPortalUrl
- Sessions expire after a short window, generate fresh on each request
Subscription management
Most subscription changes happen through the customer portal. For cases where your application needs to drive the change (admin tools, support workflows), the SDK provides a subscription update method.
import { polar } from '@/lib/polar';
export async function upgradeSubscription(
subscriptionId: string,
newPriceId: string
) {
const updated = await polar.subscriptions.update(subscriptionId, {
productPriceId: newPriceId,
});
return updated;
}
export async function cancelSubscription(
subscriptionId: string,
immediate: boolean = false
) {
return polar.subscriptions.update(subscriptionId, {
cancelAtPeriodEnd: !immediate,
});
}
The cancelAtPeriodEnd: true pattern lets the subscription remain active until the end of the current billing period, which is the standard SaaS expectation. Setting cancelAtPeriodEnd: false (the default) is what Claude generates when the user says "cancel the subscription", but unless the user explicitly asked for immediate termination, the period-end cancellation is the customer-friendly default.
Add a subscription management section to CLAUDE.md:
## Subscription updates
- polar.subscriptions.update(subId, { productPriceId }) for plan changes
- ALWAYS pass cancelAtPeriodEnd: true unless immediate termination is required
- Immediate cancellation (cancelAtPeriodEnd: false) is destructive, confirm with user
- Plan changes are prorated automatically based on Polar dashboard settings
- For one-time purchases there is nothing to cancel, refund via orders API instead
Common Claude Code mistakes with Polar
Six patterns Claude generates incorrectly without CLAUDE.md constraints, with the correct replacement for each.
1. Personal Access Token in backend
Claude generates: a backend that uses a PAT created from the dashboard's Settings menu.
Correct pattern: Organization Access Token from the org-level settings, survives personnel changes.
2. Missing metadata.userId
Claude generates: polar.checkouts.create({ productPriceId, customerEmail }) with no metadata.
Correct pattern: include metadata: { userId } so webhook events can link back to your user record.
3. Stripe-flavoured event names
Claude generates: webhook handlers expecting customer.subscription.created or similar Stripe event names.
Correct pattern: Polar uses subscription.created, subscription.active, subscription.canceled, subscription.revoked.
4. Missing successUrl template
Claude generates: successUrl: '/billing/success' with no {CHECKOUT_ID} placeholder.
Correct pattern: successUrl: '/billing/success?checkout_id={CHECKOUT_ID}' so the success page can look up the checkout server-side.
5. Email-based customer lookup
Claude generates: a portal handler that calls polar.customers.list({ email }) to find the customer.
Correct pattern: store the customer ID during the first webhook and look it up directly.
6. cancelAtPeriodEnd defaulting to false
Claude generates: polar.subscriptions.update(subId, {}) for cancellation, leaving cancelAtPeriodEnd undefined which defaults to immediate cancellation.
Correct pattern: explicit cancelAtPeriodEnd: true unless immediate termination is required.
Add a common mistakes section to CLAUDE.md with these six pairs. Claude reproduces patterns from CLAUDE.md examples more reliably than from abstract rules.
Permission hooks for Polar scripts
A Polar integration accumulates scripts: customer list dumps, subscription audits, refund processors, webhook replay tools. Some are read-only, some trigger billing actions.
In .claude/settings.local.json:
{
"permissions": {
"allow": [
"Bash(node scripts/list-customers.js*)",
"Bash(node scripts/list-subscriptions.js*)",
"Bash(node scripts/replay-webhook.js*)"
],
"deny": [
"Bash(node scripts/refund-order.js*)",
"Bash(node scripts/cancel-all-subscriptions.js*)",
"Bash(node scripts/upgrade-bulk.js*)"
]
}
}
Refunds, mass cancellations, and bulk plan changes all have side effects that should require explicit confirmation. The deny list forces Claude to surface those operations as prompts rather than running them as part of an automated workflow. For broader patterns on protecting billing infrastructure, Claude Code with Stripe covers similar permission scoping in a different platform.
Building developer-first monetisation
The Polar CLAUDE.md in this guide produces billing integrations where Organization Access Tokens secure the backend, checkout links carry metadata that links customers to your users, webhook signatures are always verified, idempotency is enforced on event handlers, customer IDs are stored from the first webhook, and the customer portal replaces the need to build subscription management UI.
The underlying principle is the same as any platform integration with Claude Code. Polar without a CLAUDE.md produces code that looks Stripe-shaped and runs without errors but misses the patterns that make Polar a good fit for indie SaaS: missing metadata so webhooks cannot link to users, hardcoded price IDs that prevent sandbox-production swaps, and webhook handlers that listen for Stripe event names that never fire on Polar. The CLAUDE.md template removes each failure mode by making the correct pattern the only pattern Claude generates.
For the next layer of the stack, Polar works alongside Claude Code with Supabase for user authentication that feeds the metadata.userId field, and Claude Code with Sentry for monitoring webhook handler reliability over time.
Get Claudify. Drop a Polar-aware CLAUDE.md into your project and ship developer-first monetisation without the Stripe-flavoured mistakes.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify