Lemon Squeezy is a merchant of record, which makes it the fastest way to sell software globally without touching VAT — but its webhook system is where most integrations quietly break. Orders that never activate licenses, subscriptions that stay "active" after cancellation, duplicate fulfillment — we've debugged all of them for clients. This is the complete, production-grade webhook guide for Next.js.
The events that actually matter
Lemon Squeezy emits ~20 event types. For a typical software product you need exactly these:
| Event | What it means | What you do |
|---|---|---|
| order_created | One-time purchase completed | Fulfill: create license, grant access |
| subscription_created | New subscription started | Provision the account |
| subscription_updated | Plan change, renewal, pause | Sync plan + status |
| subscription_cancelled | User cancelled (still paid up) | Mark for downgrade at period end |
| subscription_expired | Access should end now | Revoke access |
| subscription_payment_failed | Dunning started | Notify user, grace period |
| license_key_created | LS generated a key | Store/display to customer |
The most common bug: treating subscription_cancelled as "revoke now." A cancelled subscriber has paid through the period end — revoke on subscription_expired, not before, or you'll refund-storm your own support inbox.
Signature verification (do not skip)
Every webhook is signed with HMAC-SHA256 using your signing secret. In the Next.js App Router you must read the raw body before parsing:
// app/api/webhooks/lemonsqueezy/route.ts
import crypto from "crypto";
import { NextResponse } from "next/server";
export async function POST(req: Request) {
const rawBody = await req.text();
const signature = req.headers.get("x-signature") ?? "";
const digest = crypto
.createHmac("sha256", process.env.LEMON_SQUEEZY_WEBHOOK_SECRET!)
.update(rawBody)
.digest("hex");
const valid =
signature.length === digest.length &&
crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(signature));
if (!valid) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
const event = JSON.parse(rawBody);
const eventName = event.meta.event_name as string;
// ... handle event
return NextResponse.json({ received: true });
}
Use timingSafeEqual, not === — string comparison leaks timing information. And never verify against a re-serialized body; JSON.stringify(JSON.parse(raw)) is not guaranteed byte-identical.
Idempotency: Lemon Squeezy retries, your database shouldn't care
Failed deliveries are retried. Network blips mean you will receive duplicates. Guard with a processed-events table:
const eventId = event.meta.webhook_id;
const already = await db.webhookEvent.findUnique({ where: { eventId } });
if (already) return NextResponse.json({ received: true });
await db.$transaction(async (tx) => {
await tx.webhookEvent.create({ data: { eventId, name: eventName } });
await handleEvent(tx, eventName, event); // fulfillment inside the same tx
});
Fulfillment and the dedupe record must commit in the same transaction, or a crash between them re-opens the duplicate window.
Mapping webhooks to your users
Lemon Squeezy doesn't know your user IDs — unless you tell it. Pass your internal ID as custom data at checkout:
const checkout = await createCheckout(storeId, variantId, {
checkoutData: {
custom: { user_id: user.id },
},
});
It comes back in every webhook at event.meta.custom_data.user_id. Relying on email matching instead is a support-ticket generator: buyers use different emails at checkout constantly.
Handling out-of-order delivery
Webhooks are not guaranteed to arrive in order. A subscription_updated can land before the subscription_created you're waiting for. Two defenses:
- Upsert, don't update — every subscription handler should
upsertkeyed on the LS subscription ID. - Trust
statusover event names — the payload'sdata.attributes.status(active,cancelled,expired,past_due,paused) is the current truth. Store it verbatim and derive access from it.
await db.subscription.upsert({
where: { lsSubscriptionId: sub.id },
update: { status: sub.attributes.status, renewsAt: sub.attributes.renews_at },
create: { lsSubscriptionId: sub.id, userId, status: sub.attributes.status },
});
Local testing without pain
Use ngrok (or cloudflared) to tunnel to localhost, register the tunnel URL as a webhook endpoint in test mode, and replay events from the Lemon Squeezy dashboard's webhook log. The replay button is your best debugging friend — every delivery shows the exact payload and your server's response.
When to choose Lemon Squeezy vs Stripe
Lemon Squeezy (as merchant of record) handles global sales tax for you — huge for solo founders selling worldwide. Stripe gives you lower fees, more control, and Connect for marketplaces. We've built both in production; the honest answer is it depends on your tax situation and product model. That's exactly the kind of thing we scope in a strategy call.
- Stripe & payments integration service → — checkout, subscriptions, webhooks, licensing — fixed price from $60 for checkout flows
- Book a free 30-minute call → — bring your billing model, leave with an architecture
Related: Usage-Based Billing with Stripe · Stripe Connect Marketplace Guide