Lemon Squeezy webhooks look simple until the first payment lands and the upgrade never happens. Or worse, it happens twice. The docs show you the happy path; production shows you the four things the happy path leaves out.
I run a small SaaS on this exact setup, and I've been burned by every one of these. Here's how to handle Lemon Squeezy webhooks in a Next.js 16 App Router route handler so they actually hold up. All the code below is the real thing, not pseudocode.
1. Verify the signature over the RAW body, before parsing
This is the one that silently breaks for almost everyone. The signature Lemon Squeezy sends is an HMAC of the exact bytes it posted. If you let a framework parse the JSON first and then re-serialize it, the bytes change and the signature never matches.
So: read the raw body first, verify, and only then parse the same string.
// app/api/webhooks/lemonsqueezy/route.ts
import crypto from "crypto";
export async function POST(req: Request) {
// Raw body FIRST. Any parsing before verification breaks the signature.
const rawBody = await req.text();
const signature = req.headers.get("x-signature") ?? "";
const digest = crypto
.createHmac("sha256", process.env.LEMONSQUEEZY_WEBHOOK_SECRET!)
.update(rawBody)
.digest("hex");
const a = Buffer.from(digest);
const b = Buffer.from(signature);
// Length check first: timingSafeEqual throws on unequal-length buffers.
// Length isn't secret, so short-circuiting on it is safe.
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return new Response("Invalid signature", { status: 401 });
}
// Only now parse the SAME string you just verified.
const payload = JSON.parse(rawBody);
// ... handle the event
}
Two things people get wrong here: parsing before verifying (breaks the HMAC), and comparing with !== instead of crypto.timingSafeEqual (opens a timing side channel). Both are easy to miss and hard to debug later.
2. Never make Lemon Squeezy retry forever
Lemon Squeezy retries any webhook that doesn't get a 2xx. That retry loop is useful in exactly one case: your own database write failed and a retry might succeed. In every other case, a non-2xx just means the same event keeps hammering your endpoint.
So return 200 for events you don't handle, and for events where the data is missing. Return 500 only when your own write fails.
The tricky part is identity. A webhook doesn't know your user. You carry that yourself: put user_id in the checkout's custom data when you create the checkout, and read it back off the event.
// When creating the checkout:
checkout_data: {
email: user.email,
custom: { user_id: user.id },
}
// In the webhook:
const userId = payload.meta?.custom_data?.user_id;
if (!userId) {
// Nothing we can do with this event. Ack it so LS stops retrying.
return new Response("ok", { status: 200 });
}
Rule of thumb: 200 means "I've seen this, stop sending it." 500 means "my fault, try again." Use 500 sparingly.
3. subscription_updated is not always good news
This is the bug that costs people money. It's tempting to treat subscription_updated as "the subscription is active, grant access." But cancellations, failed payments, and grace-period expirations all arrive as subscription_updated too. The status is what decides.
const ACTIVE = new Set(["active", "on_trial"]);
const INACTIVE = new Set(["cancelled", "expired", "past_due", "unpaid"]);
const status = payload.data?.attributes?.status;
switch (payload.meta.event_name) {
case "subscription_created":
case "subscription_updated":
if (ACTIVE.has(status)) {
await grantAccess(userId, payload);
} else if (INACTIVE.has(status)) {
// Only flip the access flag. Do NOT touch one-time credits here.
await revokeAccess(userId, status);
}
// Unknown future status? Ack with 200 and don't guess a direction.
break;
}
One more trap in the same family: a subscription checkout also fires order_created. If your one-time credit-pack handler only checks status === "paid", every subscription signup will also get a free credit pack. Check both the status and the variant id before granting one-time credits.
4. Make it idempotent
Lemon Squeezy will occasionally redeliver the same event. For boolean flags that's harmless. For credit grants it's a real bug: a redelivered order_created re-grants an already-consumed credit pack. Free credits, on the house.
The fix is a tiny dedup table keyed by the event id. Claim the id before you do the work; if the claim fails because the row already exists, you've seen this event, so skip it.
create table billing_events (
id text primary key,
processed_at timestamptz not null default now()
);
// Try to claim the event. If it already exists, we've handled it.
const claimed = await claimEvent(payload.meta.webhook_id);
if (!claimed) return new Response("ok", { status: 200 });
5. Spend credits atomically
Last one, and it's not webhook-specific but it belongs here. If you decrement credits with a read-then-write in your app code, two parallel requests can both read "1 credit left" and both spend it. Push the decrement into a single SQL statement so the database serializes it for you.
create function consume_credit(p_user_id uuid)
returns boolean language plpgsql as $$
declare updated int;
begin
update profiles set credits = credits - 1
where id = p_user_id and credits > 0;
get diagnostics updated = row_count;
return updated > 0;
end; $$;
One statement, guarded by credits > 0. Postgres row locking makes concurrent calls line up, so the balance can't go negative and nobody overspends.
That's the whole checklist
- Verify over the raw body with
timingSafeEqual, then parse. - 200 to acknowledge, 500 only when your own write fails.
- Branch
subscription_updatedon status; never assume it's good news. - Dedup by event id so redeliveries can't double-grant.
- Decrement credits in one atomic SQL statement. None of these are exotic. They're just the parts that don't show up until real money is moving, which is exactly when you don't want to be discovering them.
I pulled these patterns out of a Next.js 16 SaaS starter I put together (auth, billing, credits, dashboard, all wired the way above). If you'd rather not assemble it from scratch, it's here: https://barjakdev.gumroad.com/l/unmlro. Either way, the code in this post is yours to use.
Top comments (0)