Polar handles sales tax, VAT, GST, and chargebacks for you. Stripe doesn't. If you're shipping a global SaaS solo and don't want to register in 50 jurisdictions, this is the trade-off worth knowing.
Dashboard β Products β New product. Pick:
Save. Copy the product_id β you'll need it for the checkout URL.
Settings β API Keys β Create API key. Pick scopes:
checkouts:write β to create checkout sessionscustomers:read β to query customer statesubscriptions:read β to check entitlementAdd to .env:
POLAR_ACCESS_TOKEN=polar_oat_β¦
POLAR_WEBHOOK_SECRET=polar_whs_β¦ # from next step
POLAR_PRODUCT_ID=prod_β¦ # from step 1
Install the SDK:
npm install @polar-sh/sdk
Server route β when user clicks "Upgrade":
import { Polar } from "@polar-sh/sdk";
const polar = new Polar({
accessToken: process.env.POLAR_ACCESS_TOKEN,
server: "production", // or "sandbox"
});
// POST /api/upgrade
app.post("/api/upgrade", async (req, res) => {
const checkout = await polar.checkouts.create({
products: [process.env.POLAR_PRODUCT_ID],
successUrl: "https://yourapp.com/welcome?checkout_id={CHECKOUT_ID}",
customerEmail: req.user.email,
metadata: { userId: req.user.id }, // critical β see step 5
});
res.json({ url: checkout.url });
});
Client redirects to checkout.url. Polar handles card entry, tax calculation per the customer's country, receipt email.
Alternative: Polar Checkout Links β no API, just a hosted URL like polar.sh/checkout/abc. Drop into a button. Great for landing pages.
Polar fires events on your server when state changes. Don't trust the success-URL redirect β users close tabs, cards retry async.
Dashboard β Settings β Webhooks β Add endpoint:
https://yourapp.com/api/webhooks/polarsubscription.created, subscription.updated, subscription.canceled, order.created, order.refundedPOLAR_WEBHOOK_SECRETVerify + handle:
import { validateEvent } from "@polar-sh/sdk/webhooks";
app.post(
"/api/webhooks/polar",
express.raw({ type: "application/json" }),
async (req, res) => {
let event;
try {
event = validateEvent(
req.body,
req.headers,
process.env.POLAR_WEBHOOK_SECRET,
);
} catch {
return res.status(401).send("bad signature");
}
switch (event.type) {
case "subscription.created":
case "subscription.updated": {
const userId = event.data.metadata.userId;
await db.users.update(userId, {
plan: event.data.product.name,
polarSubscriptionId: event.data.id,
subscriptionStatus: event.data.status, // active | past_due | β¦
});
break;
}
case "subscription.canceled": {
const userId = event.data.metadata.userId;
await db.users.update(userId, { plan: "free" });
break;
}
}
res.json({ ok: true });
},
);
Same shape as the Stripe webhooks tutorial. Same rules apply: respond 2xx fast, idempotent handler, signature verification before any DB write.
Polar doesn't have a webhook-forwarder CLI like Stripe's. Two options:
ngrok http 3000, set the public URL as your sandbox webhook endpointMake a sandbox purchase with Polar's test card numbers β same as Stripe (4242 4242 4242 4242). Tail your server logs, verify the webhook arrived signed, verify DB updated.
Don't query Polar's API per request β too slow + rate-limited. Read from your DB:
// middleware
function requirePlan(...plans) {
return (req, res, next) => {
if (!plans.includes(req.user.plan)) {
return res.status(402).json({ error: "Upgrade required" });
}
next();
};
}
app.post("/api/pro-only", requirePlan("pro", "max_pro"), handler);
The webhook keeps the DB in sync. Polar's server is authoritative; your DB is the cache.
Don't build cancel/upgrade UI yourself. Polar hosts a Customer Portal.
app.post("/api/billing/portal", async (req, res) => {
const session = await polar.customerPortal.sessions.create({
customerId: req.user.polarCustomerId,
});
res.redirect(session.customerPortalUrl);
});
Users get: change card, upgrade/downgrade plan, cancel, view invoices, download receipts (with correct tax info). Polar handles proration.
metadata on the checkout flows to the order and subscription objects, but you have to read it from event.data.metadata (not event.metadata). Easy to typo.Pricing pages: polar.sh/pricing, lemonsqueezy.com/pricing, paddle.com/pricing.