# 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.

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:

```json
{
  "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).

```ts
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

```ts
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

| 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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
