The Complete Lemon Squeezy Webhook Guide for Next.js
Lemon Squeezy
Webhooks
Next.js
Payments
SaaS

The Complete Lemon Squeezy Webhook Guide for Next.js

Signature verification, idempotency, out-of-order events, and the subscription lifecycle — everything that breaks in Lemon Squeezy integrations and how to build one that doesn't.

July 11, 20264 min read

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:

  1. Upsert, don't update — every subscription handler should upsert keyed on the LS subscription ID.
  2. Trust status over event names — the payload's data.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.

Related: Usage-Based Billing with Stripe · Stripe Connect Marketplace Guide

Last updated July 13, 2026

Get thoughtful updates

Join our monthly digest for founders and builders.

Work with PolarSoftBD

Need help shipping your next product? Let's talk.

Start a project →