Guides · Free check · $9 fix pack

Guides

Lemon Squeezy webhook signature invalid? Fix X-Signature in 5 steps

Your handler throws "Invalid signature" on every Lemon Squeezy event, or the dashboard shows failed deliveries and orders never unlock. It's almost always one of five things.

How Lemon Squeezy signs a webhook

When you create a webhook you choose a signing secret, "normally a random string between 6 and 40 characters." Lemon Squeezy uses it to hash the payload and sends the hash in the X-Signature header, generated "using an HMAC hex digest" (Lemon Squeezy docs: Signing requests). Every request also carries Content-Type: application/json and an X-Event-Name header (Lemon Squeezy docs: Webhook requests).

1. You hashed a parsed body, not the raw bytes

The HMAC has to run over the body exactly as it arrived. If express.json(), a framework body parser, or your own JSON.parse → JSON.stringify touched it first, the bytes differ and the hash can't match. Lemon Squeezy's Next.js guide reads await request.text() before anything else (Lemon Squeezy guide: Webhooks in Next.js).

One trap is specific to Lemon Squeezy: its JSON escapes forward slashes in URLs (https:\/\/api.lemonsqueezy.com\/…). A developer reported that Node handlers dropped the backslashes and always failed while PHP passed; the maintainer couldn't reproduce it and closed the issue (lemonsqueezy.js issue #115). Whatever the cause in your stack, the fix is the same: hash the raw request bytes, and never a re-serialized object. Don't reach for the .replace(/\//g, '\\/') workaround from that thread unless you've proven your framework rewrites the body.

2. Wrong secret, or test vs live mode

The secret is whatever you typed when creating that webhook, and it's never returned by the API afterwards (Lemon Squeezy API: Create a webhook). If you can't confirm it, set a new one and update your env var. In the same thread, the maintainer's checklist includes making sure the API key and the webhook "are configured in the same mode (Test mode or live)" (issue #115).

3. The comparison itself is broken

Compare like with like. Lemon Squeezy's example builds both values as buffers and uses crypto.timingSafeEqual (source). timingSafeEqual throws if the lengths differ, so a missing header can turn into a 500 instead of a clean 400. Check the length (or an empty header) first.

import crypto from "node:crypto";

export async function POST(request: Request) {
  const secret = process.env.LEMONSQUEEZY_WEBHOOK_SECRET!;
  const rawBody = await request.text();                       // raw bytes, before any parsing
  const signature = Buffer.from(request.headers.get("X-Signature") ?? "", "hex");
  const hmac = Buffer.from(crypto.createHmac("sha256", secret).update(rawBody).digest("hex"), "hex");

  if (signature.length === 0 || signature.length !== hmac.length || !crypto.timingSafeEqual(hmac, signature)) {
    return new Response("Invalid signature", { status: 400 });
  }
  const event = JSON.parse(rawBody);                          // only now
  // event.meta.event_name, event.data.attributes, event.meta.custom_data ...
  return new Response("OK", { status: 200 });
}

Adapted from Lemon Squeezy's Next.js guide (source), plus the length check.

4. You answer with something other than 200

"Return a 200 response to show that the webhook was successfully captured. If the response status is anything else, the webhook will be retried up to three more times using an exponential backoff strategy (e.g. 5 secs, 25 secs, 125 secs)" (source). So 201, 202 or 204 count as failures too, and after the fourth attempt Lemon Squeezy stops trying (Lemon Squeezy guide: Sync with webhooks). Store the event, return 200, and process afterwards.

5. The request never reaches your code

A redirect (apex to www, http to https, trailing slash), a login wall (Vercel Deployment Protection, a Supabase Edge Function with JWT verification on, auth middleware on /api), or a Cloudflare bot challenge all reject the request before your signature check runs. For HTTPS URLs, Lemon Squeezy also verifies the SSL certificate before sending (source), so an expired or mismatched certificate fails too.

After you fix it

Recent deliveries, with full payloads, are logged on the webhooks settings page, and you can resend them from there (Lemon Squeezy docs: Webhooks). Resend one failed order to confirm the fix end to end.

Check your Lemon Squeezy endpoint in 10 seconds (free).

Paste your webhook URL. WebhookProof sends 3 harmless test requests and tells you whether Lemon Squeezy can reach it and whether it rejects unsigned and forged events. If it finds a problem, the $9 fix pack gives you the exact copy-paste Lemon Squeezy fix for your stack (refundable for 14 days).

Run the free Lemon Squeezy check

FAQ

What header does Lemon Squeezy use for webhook signatures?

X-Signature. It holds an HMAC-SHA256 hex digest of the raw request body, keyed with the signing secret you set when creating the webhook.

Why does my Lemon Squeezy signature fail in Node but pass in PHP?

Usually the body was parsed or re-serialized before hashing. Lemon Squeezy escapes forward slashes in its JSON, and a re-serialized body loses them. Hash the raw request text or bytes exactly as received.

Does Lemon Squeezy retry failed webhooks?

Yes, but only three more times (roughly 5, 25 and 125 seconds apart) when your endpoint returns anything other than 200. After that you can resend recent webhooks from the dashboard.

Sources

More guides

Stripe webhook not working? Check these 7 things in order · A customer paid but didn't get access. What went wrong? · No signatures found matching the expected signature for payload · Stripe webhook failing with a 307 or 308 redirect

Last reviewed 2026-10-19. WebhookProof is an independent tool, not affiliated with Lemon Squeezy. Quotes are from the linked documentation.