Polar.sh Payments in Node.js: Complete Backend Guide
Updated on January 3, 2026
Polar.sh payment integration with Node.js backend
This post expands on my X thread about Polar.sh payments with complete code examples, error handling, and lessons learned from production use.
If you’re building a SaaS or selling digital products, you need a payment provider. Polar.sh has become my go-to for indie projects—here’s how to integrate it with a Node.js backend.
Why Polar.sh? #why-polar-sh
Before diving into code, here’s why I chose Polar.sh over alternatives:
vs Stripe: Polar handles more out of the box. You get hosted checkout pages, a customer portal, and subscription lifecycle management without building these yourself. Stripe gives you more control but requires more code.
vs Paddle/Lemon Squeezy: Similar merchant-of-record benefits (they handle VAT/sales tax), but Polar is built specifically for developers and open-source projects. The DX is notably better.
What Polar handles for you:
- Hosted checkout pages
- Payment processing
- Customer portal
- Subscription lifecycle
- Tax compliance (as merchant of record)
What you handle:
- Webhook events
- User entitlements
- Usage tracking
Project Setup #project-setup
Install the SDK and webhook verification library:
npm install @polar-sh/sdk standardwebhooks
Initialize the Polar client:
import { Polar } from '@polar-sh/sdk';
export const polar = new Polar({
accessToken: process.env.POLAR_ACCESS_TOKEN!,
server: process.env.NODE_ENV === 'production' ? 'production' : 'sandbox',
});
Environment variables needed:
POLAR_ACCESS_TOKEN=your_access_token
POLAR_WEBHOOK_SECRET=your_webhook_secret
Get these from your Polar dashboard. Use sandbox mode during development—it provides test cards and won’t charge real money.
Creating Checkout Sessions #creating-checkout-sessions
When a user clicks “Subscribe” or “Buy”, create a checkout session and redirect them:
import { polar } from '../lib/polar';
export async function createCheckout(user: { id: string; email: string }, productId: string) {
const checkout = await polar.checkouts.create({
products: [productId],
successUrl: 'https://yourapp.com/success?session_id={CHECKOUT_SESSION_ID}',
customerEmail: user.email,
metadata: {
userId: String(user.id),
},
});
return checkout.url;
}
Key points:
metadata.userIdlinks the Polar customer to your user. You’ll need this in webhooks.successUrlcan include{CHECKOUT_SESSION_ID}which Polar replaces with the actual ID.productstakes an array—you can bundle multiple products in one checkout.
Express route example:
router.post('/create-checkout', async (req, res) => {
const user = req.user; // from your auth middleware
const { productId } = req.body;
if (!productId) {
return res.status(400).json({ error: 'Product ID required' });
}
try {
const checkoutUrl = await createCheckout(user, productId);
res.json({ url: checkoutUrl });
} catch (error) {
console.error('Checkout creation failed:', error);
res.status(500).json({ error: 'Failed to create checkout' });
}
});
Webhook Handling #webhook-handling
Webhooks are how Polar tells your app about payment events. This is the most critical part to get right.
Signature Verification
Polar uses the Standard Webhooks spec. You must verify signatures to prevent spoofed requests:
import { Webhook } from 'standardwebhooks';
const secret = 'whsec_' + Buffer.from(
process.env.POLAR_WEBHOOK_SECRET!
).toString('base64');
const wh = new Webhook(secret);
export function verifyWebhook(rawBody: string, headers: Record<string, string>) {
try {
return wh.verify(rawBody, headers);
} catch (error) {
console.error('Webhook verification failed:', error);
throw new Error('Invalid webhook signature');
}
}
Route Setup (Critical!)
The webhook route needs the raw request body for signature verification. Register it BEFORE your JSON middleware:
import express from 'express';
import { webhookRouter } from './routes/webhooks';
const app = express();
// Webhook routes FIRST - need raw body
app.use('/api/webhooks', webhookRouter);
// JSON parsing AFTER webhooks
app.use(express.json());
// Other routes...
app.use('/api', apiRouter);
import { Router } from 'express';
import express from 'express';
import { verifyWebhook } from '../webhooks/polar';
import { handlePolarEvent } from '../services/polar-events';
const router = Router();
router.post('/polar',
express.raw({ type: 'application/json' }),
async (req, res) => {
try {
const event = verifyWebhook(
req.body.toString(),
req.headers as Record<string, string>
);
await handlePolarEvent(event);
res.status(200).json({ received: true });
} catch (error) {
console.error('Webhook error:', error);
res.status(400).json({ error: 'Webhook processing failed' });
}
}
);
export { router as webhookRouter };
Event Handlers
Here’s how to handle the key subscription events:
import { db } from '../lib/db';
interface PolarEvent {
type: string;
data: {
id: string;
status: string;
customer_id: string;
metadata?: { userId?: string };
current_period_end?: string;
};
}
export async function handlePolarEvent(event: PolarEvent) {
const { type, data } = event;
switch (type) {
case 'subscription.created':
// Link Polar customer to your user
await db.user.update({
where: { id: data.metadata?.userId },
data: {
polarCustomerId: data.customer_id,
subscriptionId: data.id,
subscriptionStatus: 'pending',
},
});
break;
case 'subscription.active':
// Payment succeeded - activate the subscription
await db.user.update({
where: { polarCustomerId: data.customer_id },
data: {
subscriptionStatus: 'active',
currentPeriodEnd: new Date(data.current_period_end!),
// Set plan-specific limits
apiCallsLimit: 10000,
storageLimit: 5 * 1024 * 1024 * 1024, // 5GB
},
});
break;
case 'subscription.updated':
// Plan change, renewal, etc.
await db.user.update({
where: { polarCustomerId: data.customer_id },
data: {
subscriptionStatus: data.status,
currentPeriodEnd: new Date(data.current_period_end!),
},
});
break;
case 'subscription.canceled':
// User canceled - still active until period end
await db.user.update({
where: { polarCustomerId: data.customer_id },
data: {
subscriptionStatus: 'canceled',
canceledAt: new Date(),
},
});
break;
case 'subscription.revoked':
// Immediate revocation (payment failed, refund, etc.)
await db.user.update({
where: { polarCustomerId: data.customer_id },
data: {
subscriptionStatus: 'revoked',
apiCallsLimit: 0,
storageLimit: 0,
},
});
break;
case 'order.paid':
// One-time purchase completed
await handleOneTimePurchase(data);
break;
default:
console.log(`Unhandled event type: ${type}`);
}
}
async function handleOneTimePurchase(data: PolarEvent['data']) {
// Grant lifetime access, credits, etc.
await db.user.update({
where: { id: data.metadata?.userId },
data: {
lifetimeAccess: true,
},
});
}
Subscription Management #subscription-management
Cancel Subscription
Let users cancel from your app (they keep access until period end):
import { polar } from '../lib/polar';
export async function cancelSubscription(subscriptionId: string) {
await polar.subscriptions.update({
id: subscriptionId,
subscriptionUpdate: {
cancelAtPeriodEnd: true,
},
});
}
Customer Portal
Polar provides a hosted portal where customers can manage billing, update payment methods, and view invoices:
export async function getCustomerPortalUrl(polarCustomerId: string) {
const session = await polar.customerSessions.create({
customerId: polarCustomerId,
});
return session.customerPortalUrl;
}
Express route:
router.post('/billing-portal', async (req, res) => {
const user = req.user;
if (!user.polarCustomerId) {
return res.status(400).json({ error: 'No subscription found' });
}
try {
const portalUrl = await getCustomerPortalUrl(user.polarCustomerId);
res.json({ url: portalUrl });
} catch (error) {
console.error('Portal creation failed:', error);
res.status(500).json({ error: 'Failed to create portal session' });
}
});
Common Pitfalls #common-pitfalls
After integrating Polar in production, here are the mistakes I’ve seen (and made):
1. JSON middleware before webhooks
If you see signature verification errors, this is almost always the cause. The raw body gets parsed by express.json() before your webhook handler sees it.
Fix: Register webhook routes before express.json().
2. Not handling all subscription states
A subscription can be: active, canceled, past_due, incomplete, trialing, or revoked. Don’t just check for active—handle the full lifecycle.
3. Missing metadata
If you forget to pass userId in checkout metadata, you can’t link the Polar customer to your user. Always include identifying information.
4. Not idempotent webhook handlers
Polar may retry webhooks. Your handlers should be idempotent—processing the same event twice shouldn’t break anything.
// Bad - creates duplicate records
await db.payment.create({ data: { orderId: event.data.id, ... } });
// Good - upsert based on unique ID
await db.payment.upsert({
where: { polarOrderId: event.data.id },
create: { polarOrderId: event.data.id, ... },
update: { ... },
});
5. Blocking webhook responses
Webhook endpoints should respond quickly. If you need to do heavy processing, acknowledge immediately and process async:
router.post('/polar', express.raw({ type: 'application/json' }), async (req, res) => {
const event = verifyWebhook(req.body.toString(), req.headers);
// Respond immediately
res.status(200).json({ received: true });
// Process async (use a proper job queue in production)
setImmediate(() => handlePolarEvent(event));
});
Testing Your Integration #testing-your-integration
Use Sandbox Mode
Polar’s sandbox environment lets you test the full flow without real charges:
const polar = new Polar({
accessToken: process.env.POLAR_ACCESS_TOKEN,
server: 'sandbox', // Test mode
});
Test Cards
In sandbox, use these test card numbers:
4242 4242 4242 4242- Successful payment4000 0000 0000 0002- Card declined
Local Webhook Testing
Use ngrok or similar to expose your local server:
ngrok http 3000
Then set your webhook URL in Polar dashboard to: https://your-ngrok-url.ngrok.io/api/webhooks/polar
Verify Your Setup
Checklist before going live:
- Checkout creates successfully and redirects to Polar
- Success URL redirects back to your app
- Webhook signature verification passes
-
subscription.activeevent activates user access - Customer portal loads correctly
- Cancellation works and sets
cancelAtPeriodEnd
That’s the complete Polar.sh payment integration. The key insight: let Polar handle what it’s good at (checkout UI, payment processing, customer portal) and focus your code on webhook handling and user entitlements.
For more details, check the official Polar docs.