
If you’re running a paid newsletter, course platform, or membership site on Lemon Squeezy, you’re probably listening for webhooks to provision access, update subscriber status, or log revenue events. But every webhook Lemon Squeezy sends includes a signature header that most operators ignore—and that’s a problem.
Webhook signature verification isn’t optional security theater. It’s the only way to confirm that the POST request hitting your server actually came from Lemon Squeezy, not a malicious actor replaying old payloads or inventing fake subscription events.
Here’s how Lemon Squeezy’s signature system works, how to validate it in your code, and one non-obvious edge case that breaks verification even when you’ve done everything right.
How Lemon Squeezy generates the signature
Every webhook Lemon Squeezy sends includes an X-Signature header. This is an HMAC-SHA256 hash of the raw request body, signed with your webhook signing secret.
You’ll find your signing secret in the Lemon Squeezy dashboard under Settings → Webhooks. It looks like a long alphanumeric string starting with whsec_. Each webhook endpoint you create gets its own secret—if you rotate or delete an endpoint, the secret changes.
The signature process is straightforward: Lemon Squeezy takes the entire JSON payload, hashes it using HMAC-SHA256 with your secret as the key, then sends the resulting hash in the header. Your server’s job is to recreate that hash and compare it to what was sent.
Validating the signature in your code
Here’s a minimal Node.js example using Express and the built-in crypto module:
const crypto = require('crypto');
const express = require('express');
const app = express();
const WEBHOOK_SECRET = process.env.LEMON_SQUEEZY_SECRET;
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-signature'];
const payload = req.body;
const hash = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(payload)
.digest('hex');
if (hash !== signature) {
console.error('Invalid signature');
return res.status(401).send('Unauthorized');
}
// Signature valid—process the webhook
const event = JSON.parse(payload);
console.log('Event type:', event.meta.event_name);
res.status(200).send('OK');
});
Two things to note: you must use express.raw() to preserve the raw request body. If you parse the JSON first with express.json(), the signature won’t match—whitespace, key order, and encoding all matter. You also need to compare the hash as a hex string, not a buffer.
If you’re using PHP, the process is similar with hash_hmac(). Python uses hmac.new() from the standard library. The algorithm is the same across languages.
The edge case: timestamp drift and replay attacks
Lemon Squeezy doesn’t include a timestamp in the signature itself, which means a valid webhook payload can be replayed indefinitely. If an attacker intercepts a legitimate webhook—say, a subscription cancellation event—they can resend it to your endpoint days or weeks later, and your signature check will pass.
The mitigation is to track processed webhook IDs. Every Lemon Squeezy webhook includes a unique meta.webhook_id field in the payload. Before processing the event, check whether you’ve already seen that ID. If you have, return a 200 status but skip the business logic.
Store processed IDs in a database table with a TTL—30 days is reasonable. This prevents replay attacks without requiring you to implement a sliding timestamp window.
Here’s the addition to the earlier example:
const event = JSON.parse(payload);
const webhookId = event.meta.webhook_id;
// Check if already processed (pseudo-code)
if (await db.webhookExists(webhookId)) {
console.log('Duplicate webhook, ignoring');
return res.status(200).send('OK');
}
await db.saveWebhookId(webhookId);
// Continue processing...
When verification fails even though it shouldn’t
If your signature check is failing consistently and you’re confident the secret is correct, check your server’s request body size limit. Some frameworks—Express included—default to a 100kb limit. Lemon Squeezy’s order_created webhooks can exceed that if the order includes multiple line items or custom data fields.
Increase the limit in your middleware config: express.raw({ type: 'application/json', limit: '1mb' }).
The other common culprit is proxies or load balancers that rewrite the request body. If you’re behind Cloudflare, Nginx, or AWS ALB, confirm that the raw body is being forwarded unmodified. A single trailing newline or whitespace change will break the hash.
Lemon Squeezy doesn’t currently offer a signature verification test mode or sample payloads with pre-signed signatures, so the easiest way to debug is to log both the computed hash and the received signature, then compare them character by character.
If you’re handling payments or provisioning access via webhooks, signature verification isn’t optional. It’s the only way to distinguish legitimate events from forged requests. Set it up once, test it with a live webhook, and add replay protection if your product handles high-value transactions.
Got a question about webhook security or payment automation? Reply to this email—we cover this stuff every week.
