Lemon Squeezy webhook lost during a deploy
Short answer: Lemon Squeezy tries a webhook four times. If your app is down for all four tries, it stops. The order can still show as paid. Your app just never hears about it. Resend the failed events from Settings, then Webhooks. Or list recent paid orders with Lemon Squeezy's own API and unlock any you did not store.
Signs you have this problem
- A customer paid, but your app did not unlock them.
- In Lemon Squeezy, the delivery shows "failed after 4 attempts" around the time you deployed, or while your host was down.
- Your logs have a gap during the deploy, then nothing for that order.
- New test buys work now. The missed ones do not come back on their own.
This is not the same as a route that replies with 201 or 204 and keeps getting retries. That case is covered in /fix/lemon-squeezy-webhook-keeps-retrying. Here, every try failed, often with a timeout or a 5xx, and Lemon Squeezy stopped.
Why a deploy can lose events
Lemon Squeezy's docs say:
- Return a 200 to show the webhook was captured.
- If the status is anything else, it retries up to three more times with growing waits. The docs give this example: about 5 seconds, then 25 seconds, then 125 seconds.
- After the fourth try, the event is marked failed. It is not sent again on its own. You can resend it from your webhook settings.
The docs do not state how long each single request may take. The waits they list already add up to a few minutes (5 + 25 + 125 seconds). If your app is gone or returning errors for longer than that window, all four tries can fail.
A long rebuild on one server can do this. So can a host that sleeps. So can a crash right after a deploy, or a new release that fails until you fix it. The order can still show as paid. You can still list it later, and you can resend the webhook.
Some hosts keep the old version up until the new one is ready. Some do not. Do not assume a deploy is too short to matter. After you ship, check the webhook log.
How to keep this from happening again
- Reply with HTTP 200, and do it quickly. Store the event first, then reply. Do emails and other slow work after. A helper that returns 201 or 204 will also fail. See /fix/lemon-squeezy-webhook-keeps-retrying.
- Skip repeats. The same event can arrive more than once if some tries get through. Save
event_nameplusdata.idand ignore a key you already handled. For Stripe-style repeats, see /fix/stripe-webhook-duplicate-events. - Check the log after you deploy. Open Settings, then Webhooks. Look at recent deliveries. Resend any that failed.
- Keep the route up if you can. If your host takes the app offline for more than a few minutes, events from that window can be lost. Try not to take the webhook path down for long. There is no promise of zero down time. That depends on your host.
If every real event fails the signature check, fix that first: /fix/lemon-squeezy-webhook-signature-invalid.
A route that works (Next.js App Router)
This reads the raw body, checks X-Signature, skips a repeat, then returns 200. It is the same idea as the retry guide. It is kept short so you can paste it and then catch up.
// app/api/lemonsqueezy/webhook/route.ts
import { createHmac, timingSafeEqual } from 'node:crypto'
export const runtime = 'nodejs'
// Swap this for your database. The Set is only for the example.
const seen = new Set<string>()
export async function POST(req: Request) {
const secret = process.env.LEMONSQUEEZY_WEBHOOK_SECRET
if (!secret) {
return new Response('Missing LEMONSQUEEZY_WEBHOOK_SECRET', { status: 500 })
}
// Raw text. Do not use req.json() before the signature check.
const raw = await req.text()
const sig = req.headers.get('x-signature') ?? ''
const hmac = createHmac('sha256', secret).update(raw).digest('hex')
const ok =
sig.length === hmac.length &&
timingSafeEqual(Buffer.from(hmac, 'utf8'), Buffer.from(sig, 'utf8'))
if (!ok) {
return new Response('Invalid signature', { status: 401 })
}
let payload: { meta?: { event_name?: string }; data?: { id?: string } }
try {
payload = JSON.parse(raw)
} catch {
return new Response('Bad JSON', { status: 400 })
}
const eventName = payload.meta?.event_name
const objectId = payload.data?.id
if (!eventName || !objectId) {
return new Response('Missing event name or object id', { status: 400 })
}
// Lemon Squeezy has no Stripe-style evt_ id. Dedupe on event name + object id.
const key = `${eventName}:${objectId}`
if (seen.has(key)) {
return new Response('ok', { status: 200 })
}
seen.add(key)
// Store or queue the event here, then reply.
// await db.from('ls_events').insert({ key, raw })
// Only HTTP 200 counts as success. See /fix/lemon-squeezy-webhook-keeps-retrying
return new Response('ok', { status: 200 })
}
Use a real unique rule in your database for key. An in-memory Set is wiped on every deploy.
Catch up missed payments
Fixing the route does not bring back events that already failed four times. You have two paths from Lemon Squeezy.
1. Resend from Lemon Squeezy
Go to Settings, then Webhooks. Recent webhooks are logged there. You can see which events were sent, read the full payload, and resend one if you need to.
Resend is the better first step when you used custom_data at checkout (for example a user id). That data lives in the webhook meta field. It is not listed on the Order object that the API returns.
2. List recent paid orders with the API
If you are not sure which events failed, list recent orders. You can also do this to check a span of time. The call is GET https://api.lemonsqueezy.com/v1/orders. You can filter by store_id. Results come back newest first by created_at. Use page[size] up to 100. Then follow links.next if there is another page.
The docs do not list a created_at filter, so this script stops when it reaches an order older than your window. It only unlocks orders whose status is paid. It skips an order id you already stored.
This call returns Order objects. It does not include the webhook meta wrapper, so you will not get meta.event_name or meta.custom_data from it. You can still match on id, identifier, user_email, or customer_id. Check test_mode so you do not unlock a live user for a test buy.
// catch-up-ls-orders.ts
// Run in your own terminal. It only reads orders. It does not change your store.
// LEMONSQUEEZY_API_KEY=... LEMONSQUEEZY_STORE_ID=... npx tsx catch-up-ls-orders.ts
// Never put the key in your code or commit it.
import { pathToFileURL } from 'node:url'
const LIST_URL = 'https://api.lemonsqueezy.com/v1/orders'
export type LsOrder = {
type: string
id: string
attributes: {
store_id: number
customer_id: number
identifier: string
order_number: number
user_name: string
user_email: string
status: string
created_at: string
test_mode: boolean
}
}
type LsOrderList = {
data?: LsOrder[]
links?: { next?: string }
}
export function ordersUrl(storeId: string): string {
const params = new URLSearchParams()
params.set('filter[store_id]', storeId)
params.set('page[size]', '100')
return `${LIST_URL}?${params.toString()}`
}
export async function fetchOrderPage(url: string, apiKey: string): Promise<LsOrderList> {
const res = await fetch(url, {
headers: {
Accept: 'application/vnd.api+json',
'Content-Type': 'application/vnd.api+json',
Authorization: `Bearer ${apiKey}`,
},
})
if (!res.ok) {
throw new Error(`Lemon Squeezy API returned ${res.status}`)
}
return (await res.json()) as LsOrderList
}
export async function catchUpPaidOrders(opts: {
apiKey: string
storeId: string
sinceMs: number
haveOrder: (id: string) => boolean
onNew: (order: LsOrder) => void
}): Promise<{ unlocked: string[]; skipped: string[] }> {
const unlocked: string[] = []
const skipped: string[] = []
let url: string | undefined = ordersUrl(opts.storeId)
while (url) {
const page = await fetchOrderPage(url, opts.apiKey)
const rows = page.data ?? []
let reachedOld = false
for (const order of rows) {
const created = Date.parse(order.attributes.created_at)
if (Number.isFinite(created) && created < opts.sinceMs) {
reachedOld = true
break
}
if (order.attributes.status !== 'paid' || opts.haveOrder(order.id)) {
skipped.push(order.id)
continue
}
opts.onNew(order)
unlocked.push(order.id)
}
url = reachedOld ? undefined : page.links?.next
}
return { unlocked, skipped }
}
export async function main() {
const apiKey = process.env.LEMONSQUEEZY_API_KEY
const storeId = process.env.LEMONSQUEEZY_STORE_ID
if (!apiKey || !storeId) {
console.error('Set LEMONSQUEEZY_API_KEY and LEMONSQUEEZY_STORE_ID. Do not put them in your code.')
process.exitCode = 1
return
}
// Swap this Set for a lookup in your database.
const seen = new Set<string>()
const sinceMs = Date.now() - 2 * 24 * 60 * 60 * 1000
const result = await catchUpPaidOrders({
apiKey,
storeId,
sinceMs,
haveOrder: (id) => seen.has(id),
onNew: (order) => {
seen.add(order.id)
// Unlock the customer here, the same way your webhook does for order_created.
console.log('New paid order', order.id, order.attributes.user_email, 'test_mode=', order.attributes.test_mode)
},
})
console.log(JSON.stringify(result))
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
void main()
}
Run it in your own terminal against your own store. Never put the API key in your app code or in a public place.
Check it with a free scan
Paste your webhook address into the free Tillping scan: https://tillping.online/#scan, and pick Lemon Squeezy. No account is needed. Only scan an address you own or are allowed to test.
The scan sends a few safe test requests, with no real payment. One has no signature. One has a fake X-Signature. It flags a 5xx, a 404, and a redirect. It also flags a Vercel login wall, a Cloudflare challenge, a slow reply, and a bad certificate. And it flags an endpoint that says OK to a fake signature or to no signature.
What it can't do: the scan can't see inside your Lemon Squeezy dashboard. It can't see real event deliveries. It can't see that your app was down during a deploy. And it can't list missed orders. Use the delivery log in Lemon Squeezy, and the order list above, for that.
Sources
- Lemon Squeezy, Webhook Requests (return
200; anything else retried up to three more times with growing waits, for example 5 seconds, 25 seconds, 125 seconds; then failed;X-Signature,X-Event-Name). https://docs.lemonsqueezy.com/help/webhooks/webhook-requests - Lemon Squeezy, Sync With Webhooks (only HTTP 200; store the event and return 200 quickly; process outside the request; after the fourth try you can resend from webhook settings). https://docs.lemonsqueezy.com/guides/developer-guide/webhooks
- Lemon Squeezy, Webhooks (recent webhooks are logged on the webhooks settings page; you can see payloads and resend). https://docs.lemonsqueezy.com/help/webhooks
- Lemon Squeezy, Signing Requests (
X-Signature, HMAC SHA-256 hex of the raw body). https://docs.lemonsqueezy.com/help/webhooks/signing-requests - Lemon Squeezy, List All Orders (
GET /v1/orders; filtersstore_id,user_email,order_number; newest first bycreated_at). https://docs.lemonsqueezy.com/api/orders/list-all-orders - Lemon Squeezy, The Order Object (
id,statusvalues includingpaid,user_email,identifier,customer_id,created_at,test_mode; no webhookmetaorcustom_datalisted). https://docs.lemonsqueezy.com/api/orders/the-order-object - Lemon Squeezy, API Requests (HTTPS;
AcceptandContent-Typeapplication/vnd.api+json;Authorization: Bearer;page[size]1 to 100; followlinks.next). https://docs.lemonsqueezy.com/api/getting-started/requests
Check your webhook now
Paste your webhook address to see what it answers. Free, no account, no keys.
Check my webhookGet an email when Deep Scans open
One email to confirm, then one when we launch. Leave any time.
