LemonSqueezy vs Gumroad: Checkout Is the Cheap Part
Both take the payment. The difference is the delivery path you own — 1,547 lines here, the webhook guards that keep it safe, and the bug that grants nothing.
Both LemonSqueezy and Gumroad take the payment, act as merchant of record, and hand you the money minus a cut. The real choice is narrower: Gumroad hosts the product page and the file delivery, LemonSqueezy expects you to own both. On this storefront, owning them is 1,547 lines of TypeScript across eleven files.
That number is the honest comparison. Fee tables are easy to find and easy to misquote; what nobody shows you is the code that stands between "payment captured" and "the right buyer downloads the right zip". Below is that code, from a storefront that runs it, plus the failure mode it exists to prevent.
The decision in one table
| LemonSqueezy, as integrated here | Gumroad, hosted | |
|---|---|---|
| Where the buyer browses | Your site — 111 product pages, your routing and SEO | Gumroad's product page |
| Where checkout happens | LemonSqueezy-hosted overlay, launched from your button | Gumroad-hosted |
| Where the files live | Your private bucket, behind your authorization | Gumroad's delivery |
| How entitlement is decided | Your code, from webhook events you store | The platform's purchase record |
| Discovery | None — you bring the traffic | A marketplace exists alongside your own traffic |
| Integration you maintain | Webhook verification, entitlements, gated downloads, email | A product listing |
| Who gets the support ticket when delivery breaks | You | The platform |
Read the last two rows together. They are the same row. (If your question is really "which hosted platform is cheapest", that is a different question with a different answer.) Everything the platform does for you is also something you cannot fix when it misbehaves, and everything you own is something you must get right.
On fees: LemonSqueezy's pricing page states "5% + 50¢" per transaction, "no monthly charges for ecommerce features", and "Lemon Squeezy is your merchant of record", with a note that transactions outside the US may carry small additional fees — read on 2026-10-20. Gumroad's pricing page was unreachable from the sandbox this was written in, so no figure for it appears here; check it directly rather than trusting an aggregator, because the comparison sites that rank for this query disagree with each other about both platforms.
The 1,547 lines, file by file
This is the whole commerce surface of a LemonSqueezy integration, measured in this repository:
| File | Lines | Job |
|---|---|---|
src/lib/lemonsqueezy/webhook.ts | 287 | Event handling: guards, variant→tier mapping, order + slot creation |
src/lib/download.ts | 373 | Rate limiting, audit writes, signed-URL issuance |
src/components/molecules/BuyButton.tsx | 185 | The checkout launch, per edition and tier |
src/app/api/download/route.ts | 157 | The gated download endpoint |
src/lib/actions/redemption.ts | 142 | Spending a slot on a specific template |
src/lib/lemonsqueezy/variants.ts | 119 | Mapping LemonSqueezy variant ids to tiers |
src/lib/entitlements.ts | 75 | Pure entitlement logic — does this slot cover this download? |
src/lib/email/purchaseWelcome.ts | 72 | The post-purchase email |
src/app/api/webhooks/lemonsqueezy/route.ts | 69 | The HTTP entry point |
src/lib/lemonsqueezy/webhookDb.ts | 43 | Persistence seam for the webhook |
src/lib/lemonsqueezy/signature.ts | 25 | HMAC verification |
Note the shape. The payment integration proper — signature, variants, webhook, route — is 543 lines. The other 1,004 are entitlement and delivery: deciding who owns what, and handing them a file. A hosted storefront replaces that larger half, not the smaller one. If you are choosing LemonSqueezy to save money on fees, you are buying the 1,004 lines.
The 25 lines you cannot get wrong
Webhook verification is the shortest file and the only one where a mistake is a security hole rather than a bug:
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);
}
Two details are easy to miss. timingSafeEqual throws on a buffer-length mismatch, so a malformed X-Signature header has to resolve to false rather than crash the route — hence the explicit length check instead of a try/catch. And Buffer.from(str, "hex") never throws on invalid hex; it truncates silently at the last valid byte pair, which is why the length comparison is load-bearing.
The route feeds it the raw bytes, never a re-serialised object:
export async function POST(req: Request) {
const raw = await req.text();
const signature = req.headers.get("X-Signature");
req.json() first and then JSON.stringify back is the classic way to break this: the HMAC is computed over bytes the sender never signed, every webhook fails verification, and nobody is granted anything. Gumroad's model has no equivalent of this file, which is a genuine point in its favour.
Three guards that return 202, and why
Verified-but-unwanted events are the next layer. The webhook answers 202 ignored — not an error — in four situations:
| Condition | Why it is ignored, not rejected |
|---|---|
store_id is not yours | A valid signature from a different store of yours should not grant anything here |
test_mode === true in production | Test purchases must never create real entitlements |
variant_id maps to no configured tier | An unknown product is not a tier; guessing would grant the wrong thing |
Event is neither order_created nor order_refunded | Nothing to do; a 4xx would make LemonSqueezy retry forever |
That last column is the part people get wrong. A webhook that returns an error status gets retried, so anything you do not want must be acknowledged rather than refused.
The class of bug that only exists because you own the integration
A LemonSqueezy variant carries two identifiers — a numeric id in webhook payloads and a UUID in the hosted checkout path — and swapping them lets checkout succeed while every webhook falls through to 202 ignored: the buyer is charged and granted nothing, with no error logged anywhere. We worked that failure through in detail against Stripe, so the mechanics are not repeated here.
What belongs in this comparison is the category. A hosted storefront cannot have that bug, because there is no mapping to get wrong. Neither can it have a webhook that verifies against re-serialised bytes, a tier table that drifts from the dashboard, or an entitlement check that passes twice under concurrency. Those are not mistakes you make through carelessness; they are the standing risk of owning a delivery path, and the 119-line variants.ts here exists to document one of them for whoever edits the config next.
That is the real fee differential: a percentage of revenue against a class of silent, money-in-nothing-out failures that you are now the only person watching for.
Entitlements are yours either way, if you sell more than one thing
The piece that surprises people: the entitlement model is not a payments concern at all, and no platform supplies it once your catalogue has structure. This storefront sells single templates, an all-templates tier, and an all-access tier, so ownership is three slot kinds and one pure function:
export function slotCovers(slot: Slot, target: DownloadTarget): boolean {
if (slot.status !== "active") return false;
switch (slot.kind) {
case "all_access":
return true;
case "all_templates":
return target.framework !== "backend";
case "template_single":
return (
slot.productSlug === target.productSlug &&
target.framework !== "backend"
);
}
}
Seventy-five lines, no imports, no I/O — which is why it is the easiest part of the system to test and the part that has never broken. Every download request re-runs it server-side: the owner-only menus in the UI are a convenience, and /api/download re-authorizes per request regardless of what the page rendered.
Delivery carries its own rules that a purchase record does not express — the whole gated-download path is worked through separately. The hourly download limit here is a floor of 30 pulls, plus 3 per entitled edition, enforced atomically inside a Postgres function holding a per-user advisory lock so two parallel requests cannot both pass the check. An over-limit request records nothing. That is 373 lines in src/lib/download.ts, and it exists because a template buyer legitimately pulls many editions while a scripted scraper pulls many more.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Every webhook fails verification | Body parsed before hashing | Read req.text() first; hash the raw bytes |
timingSafeEqual throws on bad input | Length mismatch between buffers | Compare lengths before calling it |
| Checkout works, buyers get nothing, no errors | UUID used where the numeric variant id belongs | They are different identifiers; map webhooks by numeric id only |
| LemonSqueezy retries a webhook forever | Unwanted event answered with 4xx | Return 202 for events you deliberately ignore |
| Test purchases create real entitlements | No test_mode guard | Reject test_mode === true when NODE_ENV is production |
| Buyer charged, order recorded with no user | Purchase email matches no account | Record it unattached and alert; never drop a paid order |
| Download limit trips under parallel requests | Count-then-insert race | Count and insert in one transaction under a per-user lock |
Frequently asked questions
Is LemonSqueezy or Gumroad cheaper? Per transaction, compare their published rates directly — LemonSqueezy's page states 5% + 50¢ as of October 2026, and Gumroad's should be read from Gumroad. The cost that does not appear on either page is the integration: 1,547 lines here, which you maintain forever.
Do I need to handle webhooks with Gumroad? Not to deliver a file — that is the point of a hosted storefront. You need them as soon as entitlement lives in your own app, for example gating a dashboard or an API key by what someone bought.
Can I use LemonSqueezy without building a storefront? Yes; it provides hosted checkout and can email the file. The 1,004 lines of entitlement and delivery above only become yours when buyers download from your site, which is also the only way you own the post-purchase experience.
Which should I pick for selling templates? If one product and a hosted page will do, the hosted route is less code and less risk. If you are running a catalogue with tiers, your own product pages and SEO, and a buyer dashboard, the integration is unavoidable — and then the platform is chosen on fees, tax handling and payout reliability, not on features.
What breaks most often in a self-owned checkout? The identifier mapping, by a wide margin. It fails silently in the worst direction: money in, nothing delivered, no error logged.
Templates with the commerce path already built
If you have read this far deciding whether to own the delivery path, the honest summary is that the payment integration is small and the entitlement layer is not. The landing-page templates below ship the storefront half already written — product pages, checkout launch, and the structure the gated-download path expects — so the code above is something you configure rather than something you discover one unhappy customer at a time.
