Blog Resources About Search Topics
Tutorials

Polar.sh Payments in Node.js: Complete Backend Guide

Updated on January 3, 2026

Category: Tutorials
Share

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:

Terminal
npm install @polar-sh/sdk standardwebhooks

Initialize the Polar client:

src/lib/polar.ts
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:

.env
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:

src/routes/checkout.ts
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.userId links the Polar customer to your user. You’ll need this in webhooks.
  • successUrl can include {CHECKOUT_SESSION_ID} which Polar replaces with the actual ID.
  • products takes an array—you can bundle multiple products in one checkout.

Express route example:

src/routes/api.ts
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:

src/webhooks/polar.ts
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:

src/app.ts
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);
src/routes/webhooks.ts
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:

src/services/polar-events.ts
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):

src/services/subscriptions.ts
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:

src/services/subscriptions.ts
export async function getCustomerPortalUrl(polarCustomerId: string) {
    const session = await polar.customerSessions.create({
        customerId: polarCustomerId,
    });

    return session.customerPortalUrl;
}

Express route:

src/routes/api.ts
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 payment
  • 4000 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.active event 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.

Category Tutorials
Share

Get the latest AI insights delivered to your inbox

Stay up to date with the latest trends, tutorials, and industry insights. Join community of developers who trust our newsletter.

New accounts only. By submitting your email you agree to our Privacy Policy