Skip to main content
ASoc
Comparison

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.

The ASoc Team10 min read

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 hereGumroad, hosted
Where the buyer browsesYour site — 111 product pages, your routing and SEOGumroad's product page
Where checkout happensLemonSqueezy-hosted overlay, launched from your buttonGumroad-hosted
Where the files liveYour private bucket, behind your authorizationGumroad's delivery
How entitlement is decidedYour code, from webhook events you storeThe platform's purchase record
DiscoveryNone — you bring the trafficA marketplace exists alongside your own traffic
Integration you maintainWebhook verification, entitlements, gated downloads, emailA product listing
Who gets the support ticket when delivery breaksYouThe 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:

FileLinesJob
src/lib/lemonsqueezy/webhook.ts287Event handling: guards, variant→tier mapping, order + slot creation
src/lib/download.ts373Rate limiting, audit writes, signed-URL issuance
src/components/molecules/BuyButton.tsx185The checkout launch, per edition and tier
src/app/api/download/route.ts157The gated download endpoint
src/lib/actions/redemption.ts142Spending a slot on a specific template
src/lib/lemonsqueezy/variants.ts119Mapping LemonSqueezy variant ids to tiers
src/lib/entitlements.ts75Pure entitlement logic — does this slot cover this download?
src/lib/email/purchaseWelcome.ts72The post-purchase email
src/app/api/webhooks/lemonsqueezy/route.ts69The HTTP entry point
src/lib/lemonsqueezy/webhookDb.ts43Persistence seam for the webhook
src/lib/lemonsqueezy/signature.ts25HMAC 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:

ConditionWhy it is ignored, not rejected
store_id is not yoursA valid signature from a different store of yours should not grant anything here
test_mode === true in productionTest purchases must never create real entitlements
variant_id maps to no configured tierAn unknown product is not a tier; guessing would grant the wrong thing
Event is neither order_created nor order_refundedNothing 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

SymptomCauseFix
Every webhook fails verificationBody parsed before hashingRead req.text() first; hash the raw bytes
timingSafeEqual throws on bad inputLength mismatch between buffersCompare lengths before calling it
Checkout works, buyers get nothing, no errorsUUID used where the numeric variant id belongsThey are different identifiers; map webhooks by numeric id only
LemonSqueezy retries a webhook foreverUnwanted event answered with 4xxReturn 202 for events you deliberately ignore
Test purchases create real entitlementsNo test_mode guardReject test_mode === true when NODE_ENV is production
Buyer charged, order recorded with no userPurchase email matches no accountRecord it unattached and alert; never drop a paid order
Download limit trips under parallel requestsCount-then-insert raceCount 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.

Keep reading

Comparison8 min read

LemonSqueezy vs PayPal: Merchant of Record vs Payment Processor

614 lines of LemonSqueezy integration compute zero sales tax, because LemonSqueezy is the merchant of record. What a PayPal-only checkout puts back on you.

Read more
Comparison9 min read

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.

Read more