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();
}
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));
}
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");
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;
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)