All guidesStripe and webhooks
On this page

Lemon Squeezy question

Why does Lemon Squeezy X-Signature webhook verification fail in Next.js?

By Ohad Krispin, founder of VibeRaven · LinkedIn

Almost always because the HMAC is computed over different bytes or with a different secret than Lemon Squeezy used. Lemon Squeezy sends a SHA-256 HMAC hex digest of the request body in the X-Signature header, keyed with the signing secret you entered when you created the webhook. In a Next.js App Router route, read the body once with await request.text(), hash that string with crypto.createHmac('sha256', secret), and compare the hex digest with the header. JSON.stringify(await request.json()) does not give back the signed bytes whenever spacing, key order or escaping differ. Then check that LEMONSQUEEZY_WEBHOOK_SECRET in Vercel holds the secret of the webhook that sent the event, and redeploy after changing it.

What Lemon Squeezy sends

Each delivery is a POST with Content-Type: application/json, an X-Event-Name header with the event name, and the X-Signature header. The signing secret is any string you choose, normally 6 to 40 characters, set per webhook under Settings » Webhooks or in the Webhooks API. Lemon Squeezy's Node example compares the digest and the header as UTF-8 buffers with crypto.timingSafeEqual.

A missing header crashes the comparison

crypto.timingSafeEqual throws when its two buffers have different lengths, and Node's docs say so. If X-Signature is missing or cut short, the docs example throws this error before it gets to its own "Invalid signature." check, so the route fails instead of answering 401. Compare the lengths first, as in the route below. In a local run on 2026-10-30 (Node 24.12.0) it answered 200 for a correct signature and 401 for another secret and for a missing header.

RangeError: Input buffers must have the same byte length

The route, for the App Router

This follows Lemon Squeezy's Node example and its Next.js billing template: raw body first, the secret from an environment variable, and JSON.parse only after the signature matches. The custom data you pass at checkout, such as your user ID, arrives in meta.custom_data on order, subscription and license key events.

// app/api/webhooks/lemonsqueezy/route.ts
import crypto from 'node:crypto'

export async function POST(request: Request) {
  const secret = process.env.LEMONSQUEEZY_WEBHOOK_SECRET
  if (!secret) return new Response('LEMONSQUEEZY_WEBHOOK_SECRET is not set', { status: 500 })

  const rawBody = await request.text()
  const digest = Buffer.from(crypto.createHmac('sha256', secret).update(rawBody).digest('hex'), 'utf8')
  const signature = Buffer.from(request.headers.get('x-signature') ?? '', 'utf8')
  if (digest.length !== signature.length || !crypto.timingSafeEqual(digest, signature)) {
    return new Response('Invalid signature', { status: 401 })
  }

  const event = JSON.parse(rawBody)
  // event.meta.event_name, event.meta.custom_data, event.data
  return new Response('OK', { status: 200 })
}

Checklist

How to fix it, then check the fix

  1. 01
    Find every place the route reads the body before it verifies. A request.json() call, or a JSON.stringify of the parsed body, means the digest is computed over the wrong bytes:
    grep -rnE "request\.json\(\)|JSON\.stringify|X-Signature|x-signature" app/api --include=*.ts
  2. 02Read the body with await request.text() once, verify, then JSON.parse the same string.
  3. 03Copy the signing secret of the webhook that sends the event, from Settings » Webhooks. If you have more than one webhook, for example for a preview URL and for production, each has the secret you entered for it.
  4. 04Set LEMONSQUEEZY_WEBHOOK_SECRET in Vercel for the environment the webhook URL points at, and redeploy. Vercel applies changed variables only to new deployments.
  5. 05Compare the lengths before crypto.timingSafeEqual, so a missing or short header returns 401 instead of throwing.
  6. 06Return 200 once the event is stored. Any other status makes Lemon Squeezy retry up to three more times, about 5, 25 and 125 seconds apart, and then stop.
  7. 07Resend a recent delivery from the webhooks settings page, or in Test mode use Simulate event on a test subscription, and check the route answers 200.

Related

Sources

Official docs behind this checklist

FAQ

FAQ for builders before launch

Is the signature over the raw body or the parsed JSON?

Over the request body as sent. Lemon Squeezy's docs hash the raw body, and its Next.js template reads request.text() and parses the JSON only after the signature matches.

Do I need the Lemon Squeezy SDK to verify webhooks?

No. The check is a SHA-256 HMAC with Node's crypto module, as in the route above, and Lemon Squeezy's own Next.js template verifies that way.

Where do I see what was sent?

The webhooks settings page logs recent webhooks from your store with the full payload of each request, and lets you resend them.