Selling a Digital Product from Next.js with LemonSqueezy Checkout
Wire a Next.js app to LemonSqueezy end to end — hosted checkout, a signature-verified webhook, and the entitlement check that gates the download.
Selling a digital product from a Next.js app takes three moving parts: a hosted checkout you send buyers to, a signature-verified webhook that records the purchase, and an entitlement check that gates the download. LemonSqueezy handles the payment and acts as merchant of record, which means it also handles VAT and sales tax — the part most teams underestimate.
This is the flow end to end, including the security details that are easy to skip and expensive to skip.
The architecture
Buyer clicks Buy
↓
LemonSqueezy hosted checkout (they handle card + tax)
↓
Webhook → your API route (verify signature, record purchase)
↓
Buyer returns to your site
↓
Download route (check entitlement, sign a short-lived URL)
The important property: your app never learns the purchase happened from the browser. The redirect back from checkout is a convenience for the buyer, not a source of truth. Only the webhook grants access. Anyone can visit your success URL; not everyone can forge a signed webhook.
1. Send the buyer to checkout
The simplest integration is a hosted checkout link. Build the URL server-side so you can attach the data you will need when the webhook fires:
// src/lib/checkout.ts
import "server-only";
export function buildCheckoutUrl({
variantId,
userId,
productSlug,
}: {
variantId: string;
userId: string;
productSlug: string;
}) {
const url = new URL(
`https://${process.env.LEMONSQUEEZY_STORE}.lemonsqueezy.com/checkout/buy/${variantId}`,
);
// custom[...] fields come back verbatim in the webhook payload. This is how
// you connect an anonymous payment to a user in your own database.
url.searchParams.set("checkout[custom][user_id]", userId);
url.searchParams.set("checkout[custom][product_slug]", productSlug);
url.searchParams.set("embed", "1");
return url.toString();
}
Attaching user_id at checkout time is what saves you from matching on email later. Email matching breaks the moment someone pays with a different address than they signed up with, which is common enough to matter.
2. Verify the webhook signature
This is the security-critical part of the whole integration. LemonSqueezy signs each webhook with an HMAC-SHA256 of the raw request body using your webhook secret, sent in the X-Signature header.
// src/app/api/webhooks/lemonsqueezy/route.ts
import crypto from "node:crypto";
export async function POST(request: Request) {
const secret = process.env.LEMONSQUEEZY_WEBHOOK_SECRET;
if (!secret) {
// Fail closed. A missing secret must never mean "skip verification".
return new Response("Not configured", { status: 500 });
}
// Read the body as TEXT, not JSON. The signature covers the exact bytes
// that were sent; JSON.parse followed by JSON.stringify does not
// round-trip byte-for-byte, and the digest will never match.
const rawBody = await request.text();
const signature = request.headers.get("x-signature") ?? "";
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
const expectedBuf = Buffer.from(expected, "hex");
const receivedBuf = Buffer.from(signature, "hex");
// Length check first: timingSafeEqual THROWS on a length mismatch, which
// would turn a malformed signature into a 500 instead of a clean 401.
if (
expectedBuf.length !== receivedBuf.length ||
!crypto.timingSafeEqual(expectedBuf, receivedBuf)
) {
return new Response("Invalid signature", { status: 401 });
}
const event = JSON.parse(rawBody);
// ... handle the event
return new Response("OK", { status: 200 });
}
Three mistakes worth naming, because all three ship to production regularly:
Parsing before verifying. If you call await request.json() first, you no longer have the raw bytes. The signature will not match, and the usual "fix" is to disable verification.
Using === on the digests. String comparison short-circuits on the first differing character, leaking timing information. crypto.timingSafeEqual compares in constant time.
Skipping verification when the secret is unset. A conditional like if (secret) verify() means a misconfigured deploy silently accepts forged webhooks that grant free access to everything.
3. Record the purchase idempotently
Webhooks retry. LemonSqueezy will resend an event if your endpoint times out or returns a non-2xx, and a network blip can deliver the same event twice. If your handler is not idempotent, one purchase becomes two entitlements — or two welcome emails.
Store the event ID and let the database enforce uniqueness:
create table processed_webhooks (
event_id text primary key,
processed_at timestamptz not null default now()
);
const eventId = event.meta.event_name + ":" + event.data.id;
const { error } = await admin
.from("processed_webhooks")
.insert({ event_id: eventId });
// 23505 = unique_violation. We have handled this event already; ack and stop.
if (error?.code === "23505") {
return new Response("Already processed", { status: 200 });
}
Return 200 for a duplicate, not an error. A non-2xx tells LemonSqueezy to retry, and you will loop.
Which events you care about depends on what you sell. For one-time digital purchases, order_created is the grant. If you sell subscriptions, you also need subscription_updated, subscription_cancelled, and subscription_payment_failed — and access has to be revoked on those, not just granted on the happy path.
Also handle refunds. order_refunded should revoke the entitlement; otherwise a buyer can refund and keep the files.
4. Gate the download
The entitlement check belongs in the download route, evaluated per request against the signed-in user:
// src/app/api/download/route.ts
import { createClient } from "@/lib/supabase/server";
export async function GET(request: Request) {
const supabase = await createClient();
// getClaims / getUser — verified against the auth server. Never trust a
// client-supplied user id from the query string or a cookie you set.
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return new Response("Unauthorized", { status: 401 });
const slug = new URL(request.url).searchParams.get("slug");
if (!slug) return new Response("Bad request", { status: 400 });
const owns = await userOwns(user.id, slug);
if (!owns) return new Response("Forbidden", { status: 403 });
// Files live in a PRIVATE bucket. The route mints a short-lived signed URL
// per request rather than exposing a permanent public link.
const { data } = await admin.storage
.from("releases")
.createSignedUrl(pathFor(slug), 60);
return Response.redirect(data.signedUrl, 302);
}
Two properties carry the weight here.
The bucket is private. If files sit at a public URL, entitlements are decoration — one buyer shares the link and the gate is gone. Signed URLs expire; public ones do not.
The user comes from the session, not the request. Reading a user ID from a query parameter means anyone can download anything by editing the URL. This is the most common way a download gate turns out not to be a gate.
5. Test the webhook locally
You cannot receive webhooks on localhost without a tunnel:
npx untun@latest tunnel http://localhost:3000
Point the LemonSqueezy webhook at the resulting public URL plus your route path, then use test mode to run real purchases with a test card. LemonSqueezy's dashboard shows each delivery, its response code, and lets you replay one — which is the fastest way to check your idempotency actually works.
Before going live, verify all four: a successful purchase grants access, a replayed event does not double-grant, a tampered body returns 401, and a refund revokes.
LemonSqueezy, Stripe, or Paddle?
| LemonSqueezy | Stripe | Paddle | |
|---|---|---|---|
| Merchant of record | Yes | No | Yes |
| Handles VAT / sales tax | Yes | Via Stripe Tax (extra) | Yes |
| Typical fee | ~5% + 50c | ~2.9% + 30c | ~5% + 50c |
| Integration effort | Low | Higher | Medium |
| Control over checkout | Limited | Full | Limited |
Merchant of record is the deciding factor for most small teams selling digital goods internationally. With Stripe you are the seller of record, which means you are responsible for registering and remitting VAT in every jurisdiction where you have obligations. The extra ~2% that LemonSqueezy and Paddle charge is, in practice, buying that away.
Choose Stripe when you need full control of the checkout experience, have unusual billing logic, or already have tax handled.
Frequently asked questions
Do I need a webhook, or can I use the success redirect? You need the webhook. The redirect can be visited by anyone and can be missed by a real buyer who closes the tab. Treat it as UX only.
Where should the webhook secret live?
A server-only environment variable, never prefixed NEXT_PUBLIC_. Anything with that prefix is inlined into the client bundle at build time and is public.
What if my webhook is down when a purchase completes? LemonSqueezy retries with backoff. Return a non-2xx on genuine failure so the retry happens — and add a reconciliation job that periodically fetches recent orders from the API and backfills anything missing.
Can I use this with the App Router's Edge runtime?
The signature check uses node:crypto, so run the webhook route on the Node.js runtime. Edge's Web Crypto can do the same HMAC, but the Node runtime is the simpler default here.
See it in a finished build
ASoc Ecommerce and our Next.js shop templates ship the storefront side of this flow — product pages, cart, and checkout entry points — so the part left to wire is your own webhook and entitlement logic.
