Lemon Squeezy’s webhook signature verification: how it works

Verification-badge.png

The newsletter for newsletter operators

Daily field notes on deliverability, AI tools, hosting, and monetisation. No "top 10 plugins" filler — real tools, real numbers, real failures.

Lemon Squeezy's webhook signature verification: how it works
Photo: Iamovichowdhury via Wikimedia Commons (CC BY-SA 4.0)

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.

The newsletter for newsletter operators

Daily field notes on deliverability, AI tools, hosting, and monetisation. No "top 10 plugins" filler — real tools, real numbers, real failures.

Other newsletters you might like

One Two Three AI

One Two Three AI — in your inbox AI news, practical tips and how-to guides. One useful idea a day.

Subscribe

Love Space

Love Space — in your inbox. Space travel, rockets, planets and the cosmos. One cosmic idea a day.

Subscribe

Love Florida

Love Florida — in your inbox Sun-drenched beaches, world-famous theme parks, the Keys, springs and wildlife, and the best places to visit in Florida. One short email, every day.

Subscribe

Love South Africa

South Africa as a travel destination. The Rainbow nation full of wonderful gems to visit. Going on Safari in the Kruger National Park, visiting the beautiful beaches of Cape Town, indulge in the South African culture and heritage.

Subscribe

Newsletters via the One Two Three Send network.  ·  Want your newsletter featured here? Click here