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
- The body was parsed before you hashed it.
JSON.stringify(req.body), a framework that hands you an object, a body-parser mounted globally. Re-serialized JSON almost never reproduces the original bytes. Hash the raw body, then parse. - The wrong secret. Each webhook has its own, set in Settings » Webhooks (or the
secretattribute when creating one through the API). It isn't your API key. Lemon Squeezy describes it as any string you choose, normally 6 to 40 characters, so a trailing newline from a copy-paste is part of the key as far as the HMAC is concerned. - Comparing in the wrong case or encoding. The digest is lowercase hex. Base64, or an uppercase hex string from another tool, compares unequal even when the bytes agree. Compare decoded bytes, as the Go example does, if you're unsure.
- The header never reached you. A proxy that drops unknown
X-headers, or a framework that lowercases header names in a way your lookup doesn't expect, leaves you comparing against an empty string. - Responding too slowly, not a signature problem at all. Lemon Squeezy wants a
200; anything else is retried up to three more times, after which the delivery is marked failed and you resend it by hand from the webhooks settings page. Return 200 first and do the work after.
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.