If Lemon Squeezy keeps sending a webhook your app has already handled, or marks deliveries as failed while your logs show them arriving, your endpoint most likely answers with a 2xx that is not 200: Lemon Squeezy counts only a 200 as delivered. If every event fails verification, the hash is computed over a body that was parsed first, or with another webhook's secret. And since a Lemon Squeezy webhook carries neither a timestamp nor a delivery id, telling a repeat from a new event is your code's job.
This page covers the status code, the retries, the signature, duplicates and the events a subscription purchase sends, with a check in plain Node.
Only a 200 counts
Lemon Squeezy's documentation is explicit: return a 200 to show the webhook was captured; if the status is anything else, the webhook is retried up to three more times with exponential backoff, for example after 5, 25 and 125 seconds, and is then considered failed. So 201 Created, 202 Accepted and 204 No Content are failures, as much as a 500.
A handler that does its work and answers 204 therefore runs four times for one event, within about two and a half minutes, and the delivery still ends up failed in the dashboard. Answer exactly 200, and answer quickly: the timeout is not documented, so do slow work after the response.
// Next.js route handler, Hono, Cloudflare Workers, Deno: the same rule
return new Response("ok", { status: 200 });
How the signature works
When you create a webhook, you choose its signing secret, any string, usually 6 to 40 characters. Lemon Squeezy sends three headers with each request: Content-Type: application/json, X-Event-Name with the event's name, and X-Signature, the HMAC-SHA256 of the raw body with your secret, as a hex digest.
| What you see | Why | What to do |
|---|---|---|
| Every event fails verification | The body was parsed and serialized again before the hash, for example by a JSON middleware | Hash the raw body: express.raw({ type: "application/json" }) in Express, await req.text() in a Next.js route handler |
| Every event fails verification | The secret belongs to another webhook. Test mode has its own webhooks, set up while test mode is on | Use the secret of the webhook that sends these events |
| Events pass verification but repeat | Your endpoint answered something other than 200, and Lemon Squeezy retried | Answer 200, and skip events you have already handled (below) |
There is no timestamp in the signed content, so a signature never expires: a request captured once stays valid forever. Verification proves the request came from Lemon Squeezy, not that it is new.
Duplicates without a delivery id
Lemon Squeezy sends no delivery id header. The body's meta.webhook_id is not in the documentation, and in our test-mode capture it was new on every delivery of the same event, a manual Resend included, so it cannot tell a repeat from a new event. A Resend from the dashboard also serializes the body again, so a hash of the body changes too, while updated_at keeps its original value.
What stays the same across a retry and a Resend is the event name, the resource and its updated_at. Use them together as the key of an event you have handled:
const event = JSON.parse(rawBody); // after the signature check
const key = [event.meta.event_name, event.data.type, event.data.id, event.data.attributes.updated_at].join(":");
if (await alreadyHandled(key)) return new Response("ok", { status: 200 });
Which events a subscription purchase sends
A new subscription sends order_created for the order and subscription_created for the subscription, then subscription_payment_success for its first invoice. In our test-mode capture the invoice arrived about 30 seconds after the order, together with a subscription_updated that the documentation's flow does not mention. The order carries no subscription id: link them through the subscription's order_id.
| Status | What it means |
|---|---|
on_trial, active | the subscription is in its trial or paid |
past_due | a renewal payment failed; Lemon Squeezy retries it four times over two weeks, and a successful retry makes it active again |
unpaid | all four retries failed; your store's dunning settings decide whether it expires |
cancelled | future payments are cancelled, but the subscription stays valid until ends_at |
expired | the subscription has ended |
paused | payment collection is paused |
So revoke access on subscription_expired, not on subscription_cancelled: a cancelled subscription is still paid for until ends_at.
Verify a Lemon Squeezy webhook in Node
This check uses only node:crypto and compares in constant time, never with ===.
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the body exactly as received (string or Buffer), not JSON that
// was parsed and serialized again.
export function verifyLemonSqueezyWebhook(rawBody, signatureHeader, secret) {
if (!secret || !signatureHeader) return false;
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
const received = Buffer.from(signatureHeader, "utf8");
const wanted = Buffer.from(expected, "utf8");
return received.length === wanted.length && timingSafeEqual(received, wanted);
}
Call it with the X-Signature header as sent. The digest is lowercase hex, and the same JSON pretty-printed by a middleware no longer matches:
import { createHmac } from "node:crypto";
const secret = "my-signing-secret";
const body = '{"meta":{"event_name":"order_created"},"data":{"type":"orders","id":"1"}}';
const signature = createHmac("sha256", secret).update(body).digest("hex");
// true: the body as signed
verifyLemonSqueezyWebhook(body, signature, secret);
// false: the same JSON, serialized again
verifyLemonSqueezyWebhook(JSON.stringify(JSON.parse(body), null, 2), signature, secret);
How PayHook reports it
PayHook, the webhook inspector we are building, checks every Lemon Squeezy event against your secret and shows why a check failed, without ever showing the secret. When PayHook relays events to your app, it keeps your app's answer next to each event, and when that answer is a 2xx other than 200, it warns that Lemon Squeezy would count it as a failure and retry. PayHook is in closed beta; the home page has the details.
Sources
- Lemon Squeezy documentation, Webhook requests, Signing requests and Event types, checked on 9 October 2026.
- Lemon Squeezy documentation, Simulate webhook events and Test mode, checked on 9 October 2026.
- Lemon Squeezy API reference, the subscription object and the order object: statuses and
order_id, checked on 9 October 2026. - The order of events,
meta.webhook_idand Resend: PayHook's own test-mode capture of Lemon Squeezy events, 7 October 2026.