Most payment providers hand you a delivery id. Stripe has evt_..., and
Standard Webhooks providers send a webhook-id header. You insert it into a
table and duplicates stop.
Lemon Squeezy does not. That changes how you build the handler.
What arrives
Every delivery is a POST with the headers that matter:
Content-Type: application/json
X-Event-Name: order_created
X-Signature: 3f1c...64 hex characters...
The body is JSON:API:
{
"meta": {
"event_name": "order_created",
"test_mode": true,
"webhook_id": "0f34a5c2-...",
"custom_data": { "userId": "user_123", "priceId": "pro-lifetime" }
},
"data": { "type": "orders", "id": "4211037", "attributes": { "updated_at": "..." } }
}
meta.webhook_id looks like a delivery id. It is not. It identifies the
webhook you configured, so every delivery to that endpoint carries the same
value. Keying on it would dedupe everything into one event.
meta.custom_data is whatever you put in checkoutData.custom when you
created the checkout. It is how a webhook knows which of your users paid.
Verify first, on the raw bytes
X-Signature is the hex HMAC-SHA256 of the raw body, keyed with the secret
you typed when you created the webhook (6 to 40 characters).
import { createHmac, timingSafeEqual } from "node:crypto";
export function isValidSignature(rawBody: string, signature: string, secret: string) {
const received = signature.trim().toLowerCase();
if (!/^[0-9a-f]{64}$/.test(received)) return false;
const expected = createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");
return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(received, "hex"));
}
Three mistakes to avoid:
- Parsing before verifying. In a Next.js route handler, read
await request.text().request.json()followed byJSON.stringifygives different bytes and every signature fails. - Comparing with
===. String equality returns early on the first mismatch, which leaks timing.timingSafeEqualthrows on unequal lengths, so check the shape first. - Assuming the signature stops replays. There is no timestamp in the scheme. A captured request stays valid forever. Replay protection has to come from your idempotency table.
Answer 400 to a bad signature (a retry cannot fix it) and 500 while the secret is not configured (so the provider keeps retrying until it is).
Build the key from what a retry keeps
A retry resends the same body. A new change to the same object moves its
updated_at. So this key is stable across retries and unique across changes:
<meta.event_name>:<data.type>:<data.id>:<data.attributes.updated_at>
order_created:orders:4211037:2026-05-01T09:30:14.000000Z
order_refunded:orders:4211037:2026-05-03T14:02:40.000000Z
subscription_updated:subscriptions:1880042:2026-05-20T17:44:10.000000Z
Include the event name. Lemon Squeezy fires subscription_updated beside
subscription_cancelled for the same change, with the same updated_at.
Without the name, the second one would be swallowed as a duplicate. Two
partial refunds of one order each move updated_at, so each gets its own key.
Claim, then handle, then complete
if (!(await claimEvent(`lemonsqueezy:${key}`, type))) return duplicate(); // insert, on conflict do nothing
try {
await applyBillingEvents(await translate(event));
} catch (error) {
await releaseEvent(key); // so the retry is real work
return new Response("Handler failed", { status: 500 });
}
await completeEvent(key); // stamp completed_at
Claim before the work, not after. Two instances handed the same POST both run the insert; the primary key lets one through, and the other stops before it writes a row or sends an email.
Make the writes idempotent too
The claim table is one guard. The writes are the second:
- A subscription is upserted by its id, from what the API says now.
- A purchase is upserted by
(provider, order id), and its status only moves forward (pending < failed < paid < partially_refunded < disputed < refunded). A lateorder_createdcannot undo a refund. - Mail carries an idempotency key per order or invoice at the email provider.
Even if the claim table is pruned or a key shape changes, the same payment cannot land twice.
Re-read what moves both ways
Orders only move forward, so the signed payload is safe to store, as long as the write is monotonic. Subscriptions move both ways (cancel, resume, pause, plan switch) and deliveries arrive out of order, so re-read the subscription with the API on every subscription event and store what it says now. Keep the payload as a fallback for a subscription the API answers 404 for (a test-mode store reset), so its row still reaches a final state.
Three retries is a short fuse
A non-2xx answer is retried three more times, at about 5, 25 and 125 seconds. After that, the event is gone from the stream. A two-minute database failover or a bad deploy can lose events for good. Pair the webhook with a nightly reconcile that lists subscriptions and recent orders through the API and writes them through the same code. See "Recovering from missed Lemon Squeezy webhooks".
Status codes
| Answer | When |
|---|---|
| 200 | Processed, duplicate, an event you ignore, or another mode or store |
| 400 | No X-Signature, or it does not match the body |
| 500 | Secret not set, database down, API timeout on a re-read, a payload that does not parse, or money you cannot attribute |
Never return 200 while swallowing an error to keep the delivery list green. With three retries, the red row is often the only record that something went wrong.