48-hour launch: 40% off with code LAUNCH40, Guide $5.40 / Kit $11.40

Lemon Squeezy webhook signature verification failed: fixing X-Signature checks

When you create a webhook, Lemon Squeezy asks you for a signing secret. It then sends an HMAC-SHA256 hex digest of the request body, keyed with that secret, in the X-Signature header. If your check fails, look at these first:

  1. Parsed body. Hash the raw bytes, not JSON.stringify(req.body). Middleware that parses JSON first is the most common cause.
  2. Wrong secret. Use the signing secret you typed when creating this webhook, not your API key. Each webhook has its own secret, so if you have several (for example separate test and live webhooks), make sure the env var matches the one sending. Watch for stray whitespace or newlines.
  3. Encoding mismatch. The header is a lowercase hex string. Compare it with .digest('hex'), not base64.
  4. Unsafe or length-mismatched compare. crypto.timingSafeEqual throws if the buffers differ in length. Check the lengths first and treat a mismatch as invalid.

Node (Express)

app.post('/webhooks/lemonsqueezy', express.raw({ type: 'application/json' }), (req, res) => {
  const digest = Buffer.from(crypto.createHmac('sha256', process.env.LS_SIGNING_SECRET).update(req.body).digest('hex'), 'utf8');
  const sig = Buffer.from(req.get('X-Signature') || '', 'utf8');
  if (sig.length !== digest.length || !crypto.timingSafeEqual(digest, sig)) return res.sendStatus(401);
  const event = JSON.parse(req.body.toString('utf8'));   // event name also in the X-Event-Name header
  res.sendStatus(200);
});

Next.js App Router

export async function POST(req) {
  const raw = await req.text();
  const digest = crypto.createHmac('sha256', process.env.LS_SIGNING_SECRET).update(raw).digest('hex');
  const sig = req.headers.get('x-signature') || '';
  if (sig.length !== digest.length || !crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(sig)))
    return new Response('invalid signature', { status: 401 });
  // ...
  return new Response('ok');
}

PHP

$payload = file_get_contents('php://input');
$hash = hash_hmac('sha256', $payload, getenv('LS_SIGNING_SECRET'));
if (!hash_equals($hash, $_SERVER['HTTP_X_SIGNATURE'] ?? '')) { http_response_code(401); exit; }

The guide is Stripe-only. The kit is a self-hosted Cloudflare Worker relay that adds retries, replay and a dead-letter list in front of your endpoint. It verifies Lemon Squeezy's hex X-Signature out of the box with its default scheme, and also supports Stripe, Shopify, Paddle Billing, Square, Twilio (status callbacks) and GitHub.

Fix Your Stripe Webhooks guide · Self-Hosted Webhook Relay Kit. Code LAUNCH40 = 40% off until Sun Oct 11, 6:40 AM MT.

Delivery and retries

Lemon Squeezy expects a 200. Its docs say that for any other status the webhook is retried up to three more times with exponential backoff, then marked failed. Respond quickly, and use the dashboard's webhook simulation to test without a real purchase.

Free download: Webhook debugging cheat sheet.

Fighting webhooks on more than one platform? The Fix Your Stripe Webhooks guide ($9) covers raw-body and signature problems in depth, and the Self-Hosted Webhook Relay Kit ($19) adds retries, replay and a dead-letter list in front of any endpoint.