Webhook Debugging Guide

Verifying Lemon Squeezy webhook signatures (X-Signature)

Short answer

Lemon Squeezy documents three headers on every webhook request: Content-Type: application/json, X-Event-Name with the event that fired, and X-Signature. Its docs describe the signature as an HMAC hex digest of the payload, and every official example computes it the same way: HMAC-SHA256 over the raw request body, keyed with the signing secret you typed when you created the webhook, output as lowercase hex. No timestamp, no sha256= prefix, no base64.

X-Signature: 2a510e570dadcbbbca559f03afc787ee879fa0238e79d8bc8d038cbde94a7160

Lemon Squeezy's own samples throw Invalid signature. when the compare fails. If that's what your handler prints, the body you hashed is not the body that was sent, or the secret isn't the one on that webhook. Both are easy to check, below.

Node (Express)

import express from 'express';
import crypto from 'node:crypto';

const SECRET = process.env.LEMONSQUEEZY_WEBHOOK_SECRET;
const app = express();

// express.raw, not express.json: the signature covers the exact bytes sent.
app.post('/lemonsqueezy/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const expected = crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');
  const received = req.get('X-Signature') ?? '';

  const a = Buffer.from(expected, 'utf8');
  const b = Buffer.from(received, 'utf8');
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(401).send('Invalid signature.');
  }

  const event = JSON.parse(req.body.toString('utf8'));
  console.log(req.get('X-Event-Name'), event.meta.event_name, event.data.id);
  res.sendStatus(200);
});

app.listen(3000);

Lemon Squeezy's Node sample reads request.rawBody. Express doesn't define that property; it comes from frameworks and middleware that add it. On plain Express, req.body is the raw Buffer only because express.raw is mounted on this route, and a global express.json() placed before it would replace the bytes with a parsed object. The length check before timingSafeEqual matters too: that function throws a RangeError on buffers of different lengths, which a missing or truncated header would trigger.

Python (Flask)

import hashlib
import hmac
import os

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["LEMONSQUEEZY_WEBHOOK_SECRET"].encode()


@app.post("/lemonsqueezy/webhook")
def lemonsqueezy_webhook():
    raw = request.get_data()  # bytes, before any JSON parsing
    expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
    received = request.headers.get("X-Signature", "")
    if not hmac.compare_digest(expected, received):
        abort(401)
    event = request.get_json()
    print(event["meta"]["event_name"], event["data"]["id"])
    return "", 200

request.get_data() returns the bytes as received; request.get_json() afterwards is fine because it doesn't change them. In Django the equivalent is request.body, and request.META['HTTP_X_SIGNATURE'] for the header, which is exactly what Lemon Squeezy's Django example uses. hmac.compare_digest is the constant-time compare; == is not.

Go (net/http)

package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"io"
	"net/http"
	"os"
)

func lemonSqueezyWebhook(w http.ResponseWriter, r *http.Request) {
	secret := []byte(os.Getenv("LEMONSQUEEZY_WEBHOOK_SECRET"))
	body, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "unreadable body", http.StatusBadRequest)
		return
	}

	received, err := hex.DecodeString(r.Header.Get("X-Signature"))
	if err != nil {
		http.Error(w, "Invalid signature.", http.StatusUnauthorized)
		return
	}
	mac := hmac.New(sha256.New, secret)
	mac.Write(body)
	if !hmac.Equal(mac.Sum(nil), received) {
		http.Error(w, "Invalid signature.", http.StatusUnauthorized)
		return
	}

	// body is verified; decode it and respond 200 quickly.
	w.WriteHeader(http.StatusOK)
}

func main() {
	http.HandleFunc("/lemonsqueezy/webhook", lemonSqueezyWebhook)
	http.ListenAndServe(":3000", nil)
}

Decoding the header with hex.DecodeString and comparing bytes with hmac.Equal sidesteps case differences and keeps the compare constant-time. A header that isn't valid hex fails at the decode, which is the right answer for it.

Test your handler from a terminal

Run the handler with LEMONSQUEEZY_WEBHOOK_SECRET=test-signing-secret, then sign a body with the same value and post it:

SECRET=test-signing-secret
BODY='{"meta":{"event_name":"order_created"},"data":{"type":"orders","id":"1"}}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.*= //')
curl -i http://localhost:3000/lemonsqueezy/webhook \
  -H 'Content-Type: application/json' \
  -H 'X-Event-Name: order_created' \
  -H "X-Signature: $SIG" \
  --data-binary "$BODY"

The sed strips the SHA2-256(stdin)= label that Homebrew's OpenSSL 3 prints; the LibreSSL openssl on a stock Mac prints bare hex. Change one byte of BODY after computing SIG and the same request returns 401, which is the whole check at work. The body here is a cut-down order_created; a real one carries the full Order object under data, and meta.custom_data if the checkout was given any.

Why it fails

Replays work forever

Because nothing time-based is in the signed content, a Lemon Squeezy request you captured last month verifies today, byte for byte. That is convenient for fixtures and local replays, and it is also why your handler should treat the data.id plus meta.event_name as something it may see twice, from Lemon Squeezy's own retries as much as from yours. Stripe, Slack and Polar are the opposite case: their signatures include a timestamp, and a replay older than a few minutes fails unless it is re-signed (see the replay guide).

Where WebhookMon fits

WebhookMon doesn't have a built-in Lemon Squeezy signature check: its verdicts cover Stripe, Polar, GitHub, Shopify and Slack, plus any sender using the Standard Webhooks webhook- headers. A Lemon Squeezy webhook still works with it for everything else. Point the webhook at WebhookMon's relay URL and each event shows up with its exact body and its X-Signature and X-Event-Name headers, which is the quickest way to see whether the bytes your handler hashed are the bytes that were sent. It forwards events to your local server and replays them as captured, and since there's no timestamp in the signature, a replay verifies exactly like the original. If you want a Lemon Squeezy verdict, ask for it; requests decide what gets built next.

WebhookMon's Headers tab for a captured event, listing each request header name and value exactly as received, next to an event list with signature and local response columns
Download WebhookMon free trial Learn more

Related guides