DEV Community

Cenk KURTOĞLU
Cenk KURTOĞLU

Posted on

How I add Polar (Merchant of Record) + Stripe to any Next.js app in 10 minutes — without an SDK

If you're a solo dev outside the US, "just add Stripe" is rarely just. Stripe doesn't onboard sellers directly in a lot of countries, so you reach for a Merchant of Record (MoR) like Polar or Lemon Squeezy — and then you hit the second wall: the official SDK throws fetch failed the moment you deploy to a serverless platform like Vercel.

I shipped paid checkout on two live products this way and got tired of re-solving the same problems. Here's the pattern that actually works in production.

1. Skip the SDK. Use native fetch.

Most payment SDKs assume a long-lived Node server. On serverless, the bundled HTTP client and keep-alive sockets misbehave and you get opaque fetch failed errors. The fix is boring and bulletproof: call the REST API directly.

// lib/polar.ts
const POLAR_API = "https://api.polar.sh/v1";

export async function createCheckout(productId: string, successUrl: string) {
  const res = await fetch(`${POLAR_API}/checkouts/`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.POLAR_ACCESS_TOKEN!}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ product_id: productId, success_url: successUrl }),
  });
  if (!res.ok) throw new Error(`Polar checkout failed: ${res.status}`);
  return res.json();
}
Enter fullscreen mode Exit fullscreen mode

No SDK, no version drift, no serverless surprises. Works on Vercel, Cloudflare, anywhere fetch exists.

2. Verify webhooks yourself (it's ~15 lines)

Don't trust a payment callback you didn't sign-check. Polar uses the Standard Webhooks spec (base64 HMAC); Stripe uses its own t=,v1= scheme. Both are a crypto.timingSafeEqual away:

// lib/verify.ts
import crypto from "node:crypto";

export function verifyPolar(payload: string, signature: string, secret: string) {
  const expected = crypto
    .createHmac("sha256", Buffer.from(secret, "base64"))
    .update(payload)
    .digest("base64");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
Enter fullscreen mode Exit fullscreen mode

timingSafeEqual (not ===) is the part people skip — and it's exactly the part that matters for a signature check.

3. Verify payment status server-side on the success page

The redirect to your success_url is not proof of payment — a user can just visit that URL. Re-fetch the checkout server-side before you grant access or trigger a download:

const checkout = await getCheckout(checkoutId);
const PAID = ["succeeded", "confirmed"];
if (!PAID.includes(checkout.status)) redirect("/pricing");
Enter fullscreen mode Exit fullscreen mode

4. One source of truth for tiers

Keep your subscription tiers in one typed object and generate the pricing UI from it. Adding a plan becomes a one-line change, not a five-file hunt.

export const TIERS = {
  pro:      { name: "Pro",      price: 19, productId: process.env.POLAR_PRO_ID! },
  business: { name: "Business", price: 49, productId: process.env.POLAR_BIZ_ID! },
} as const;
Enter fullscreen mode Exit fullscreen mode

Why this matters

This is the unglamorous 20% of payments that eats 80% of the time: serverless fetch failures, signature verification, the success-page trust gap. None of it is hard once you've seen it — but the first time costs you a weekend.

I packaged the full working version — checkout, signed webhooks for both Polar and Stripe, tiers, and a server-verified success page — as a drop-in Next.js kit so you don't have to re-derive it. It's the exact code running on my own live products.

Polar + Stripe Kit for Next.js

Either way, the four patterns above are yours to copy. Ship the payment, not the yak-shave.

Top comments (0)