Lemon Squeezy vs Polar: 543 Lines, and Which of Them Survive
Both are merchants of record, so the tax question is settled. An inventory of the vendor-specific code in this storefront, and what a migration would cost.
Both Lemon Squeezy and Polar are merchants of record: each is legally the seller, collects and remits sales tax and VAT where it is registered, and absorbs the chargeback. So the merchant-of-record question is settled before you compare them, and what is left is narrower and more practical — how much vendor-specific code each one puts in your repository, and what happens to that code if you leave.
This storefront sells digital downloads through Lemon Squeezy. Below is an exact inventory of the Lemon-Squeezy-shaped code in it, which parts of that inventory are portable, and where Polar's posture differs. We have not shipped the same store on Polar, so treat the integration notes as measured and the Polar column as a reading of its documented model rather than as experience.
The short answer
Choose Lemon Squeezy if you want a hosted checkout you can link to and a mature dashboard, and you are comfortable being a customer of a company now owned by Stripe. Choose Polar if you want an open-source platform, a developer-first API, and pricing that is published rather than negotiated. Both are merchants of record, so neither decision changes your tax exposure.
Where the two actually diverge
| Lemon Squeezy | Polar | |
|---|---|---|
| Merchant of record | Yes | Yes |
| Source available | No | Yes — the platform is open source |
| Underlying processor | Stripe (acquired Lemon Squeezy in 2024) | Stripe |
| Checkout | Hosted page + overlay script | Hosted checkout + API-created sessions |
| Webhook signing | X-Signature, hex HMAC-SHA256 over the raw body | Standard Webhooks style signing — confirm the current header names in their docs before writing a verifier |
| Digital delivery | Built-in file hosting, or your own | Built-in benefits, or your own |
| Fee model | Published percentage + fixed, per transaction | Published percentage + fixed, per transaction |
| Self-hosting | Not possible | Possible, though the MoR benefit comes from their hosted service |
Fees on both move; read both pricing pages rather than any blog post's table, this one included.
The actual inventory: 543 lines, and what they are made of
$ wc -l src/lib/lemonsqueezy/*.ts src/app/api/webhooks/lemonsqueezy/route.ts
25 src/lib/lemonsqueezy/signature.ts
119 src/lib/lemonsqueezy/variants.ts
287 src/lib/lemonsqueezy/webhook.ts
43 src/lib/lemonsqueezy/webhookDb.ts
69 src/app/api/webhooks/lemonsqueezy/route.ts
543 total
Sorted by what a vendor change would do to each file, the picture is more encouraging than the total suggests:
| File | Fate on a migration |
|---|---|
signature.ts | Rewritten — the signing scheme is the vendor's |
variants.ts | Rewritten — product identifiers are the vendor's |
webhook.ts | Mostly kept — event parsing changes, the decision logic does not |
webhookDb.ts | Kept — it calls our own Postgres RPCs |
route.ts | Mostly kept — raw body, verify, dispatch, after() |
And the file that decides what a buyer is entitled to, src/lib/entitlements.ts, contains no vendor code at all. That is the single most useful structural choice in the whole integration: the payment provider's job ends at "this person paid for tier t2", and everything after that is ours.
What vendor-specific actually looks like
Signature verification is 25 lines, and every one of them encodes a Lemon Squeezy decision:
export function verifySignature(
rawBody: string,
signatureHeader: string,
secret: string,
): boolean {
if (!signatureHeader) return false;
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(signatureHeader, "hex");
return a.length === b.length && b.length > 0 && timingSafeEqual(a, b);
}
Hex digest, one header, compared as decoded bytes because timingSafeEqual throws on a length mismatch and a malformed header must return false rather than crash the route. A move to Polar replaces this function wholesale — different header set, different encoding, different composition of the signed string. The thing that survives is the property the route depends on: verify against the raw bytes, never a re-serialized parsed object.
const raw = await req.text();
const signature = req.headers.get("X-Signature");
req.text() before anything else, because JSON.parse followed by JSON.stringify can differ byte-for-byte from what the vendor signed. That rule is identical on both platforms and is the most common reason a first webhook integration returns 401 to every delivery.
The identifier trap, and why it is worth checking on any MoR
The sharpest edge in the Lemon Squeezy integration is not signing. It is that a variant has two identifiers:
LS_VARIANT_ID_* numeric id — what webhook payloads carry
LS_VARIANT_UUID_* uuid — what /checkout/buy/<id> accepts
Swapping them fails in the worst possible direction: checkout works, the buyer is charged, and every webhook falls through the variant-to-tier lookup to 202 ignored — money taken, nothing granted, no error anywhere. The guard is to refuse to sell a tier that is not fully configured:
export function checkoutConfiguredForTier(tier: Tier): boolean {
return (
variantUuidForTier(tier) !== null &&
variantIdForTier(tier) !== null &&
Boolean(process.env.LEMONSQUEEZY_STORE_SUBDOMAIN)
);
}
The numeric id is not needed to build a checkout URL — it is only needed later, when the webhook arrives. Checking it anyway is what turns a silent revenue bug into a disabled button. Whatever MoR you pick, the question to ask its docs on day one is: which identifier does the webhook carry, and is it the same one the checkout URL takes?
What the webhook rejects, and why that list is portable
processWebhook answers with 202 ignored rather than an error in five distinct cases — an unhandled event name, a foreign store_id, a test_mode payload in production, an unmapped variant, and an order shape it cannot read:
if (String(attributes.store_id) !== deps.storeId) {
return { status: 202, body: "ignored" };
}
if (deps.isProduction && attributes.test_mode === true) {
return { status: 202, body: "ignored" };
}
A 202 tells the sender "received, nothing to do" so it stops retrying; a 500 invites redelivery forever. Both checks exist because a webhook endpoint is a public URL that anything can post to, and both translate directly to Polar — every MoR has an organization or store identifier in the payload and a sandbox mode you must not honour in production.
Idempotency is the third portable property, and here it lives in Postgres rather than in the handler: create_order_with_slots inserts with on conflict (ls_order_id) do nothing inside a single transaction, so a duplicate delivery grants nothing twice. The migration that made it atomic is where that guarantee is actually written down. Swap the vendor and only the column name changes.
The costs nobody puts in the comparison table
A payment provider is not only code. In this repository Lemon Squeezy also appears in the Content-Security-Policy:
script-src https://app.lemonsqueezy.com
frame-src https://*.lemonsqueezy.com
form-action https://*.lemonsqueezy.com
Three directives in next.config.ts that name the vendor. A migration edits those too, and forgetting one produces a checkout that silently fails to open in production while working perfectly in a dev build with a looser policy. Add to that: the tier and variant environment variables, the webhook endpoint registered in their dashboard, the store-id check, the email copy that names your seller, and the refund flow — src/lib/email/refundRequest.ts tells support to refund in the Lemon Squeezy dashboard, because on a merchant-of-record platform the refund is theirs to issue, not yours.
None of that is an argument against switching. It is an argument for knowing the real size of the switch, which is bigger than 543 lines and smaller than a rewrite.
Mistakes and how they show up
| Symptom | Cause | Fix |
|---|---|---|
| Every webhook returns 401 | Signature computed over re-serialized JSON | Read req.text() first; verify against those exact bytes |
| Buyer charged, nothing granted | Checkout identifier used where the webhook identifier belongs | Configure both ids; fail closed if either is missing |
| Duplicate entitlements after a retry | Idempotency left to the handler | Enforce it in the database with a unique constraint plus on conflict do nothing |
| Test purchases grant real access | test_mode payloads honoured in production | Reject them explicitly when NODE_ENV is production |
| Checkout opens in dev, blank in production | Vendor hosts missing from the CSP | Add script-src, frame-src and form-action entries for the checkout domain |
| Webhook retried forever | Unhandled events answered with 5xx | Return 202 for anything you deliberately ignore |
Frequently asked questions
Is Polar cheaper than Lemon Squeezy? Sometimes, and the gap moves. Both publish percentage-plus-fixed pricing; run your own average order value through each current pricing page. At small volumes the difference is usually smaller than the cost of the migration described above.
Does the Stripe acquisition of Lemon Squeezy change anything technically? Not for the integration in this repo — the API, webhooks and hosted checkout have kept working throughout. It is a vendor-risk question rather than a code question, and it is a fair one to weigh.
Can I use Polar and keep my entitlement logic?
Yes, if your entitlement logic does not import the vendor. Here authorizeDownload(slots, target) is a pure function over plain objects and never sees an order payload, which is what makes the payment provider replaceable at all.
Which is better for selling a template or a digital download? Either. Both are merchants of record, both handle EU VAT, and both can host the file or hand off to your own gated route. This storefront serves downloads from its own private storage bucket precisely so that the answer stays "either".
Templates in this post
ASoc Till is a POS-system landing page. ASoc Timbre is an AI voice-generator marketing site. ASoc Uptime is a web-hosting site with plan comparison tables. Each one is the storefront half of a product that would sit behind exactly the checkout and webhook described here.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
