Skip to content

Idempotent Lemon Squeezy webhooks when there is no delivery id

Lemon Squeezy signs the raw body with X-Signature, sends no event id, and retries three times. Build your own idempotency key, verify in constant time, and know what to re-read.

Lemon Squeezy4 min readships at docs/solutions/lemonsqueezy/idempotent-webhooks-without-a-delivery-id.md

Tags: lemonsqueezy · webhooks · idempotency · hmac · signature · security

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 by JSON.stringify gives different bytes and every signature fails.
  • Comparing with ===. String equality returns early on the first mismatch, which leaks timing. timingSafeEqual throws 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 late order_created cannot 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

AnswerWhen
200Processed, duplicate, an event you ignore, or another mode or store
400No X-Signature, or it does not match the body
500Secret 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.