Tillping
Menu

Fix guides

Lemon Squeezy webhook keeps retrying

Short answer: Lemon Squeezy only counts an HTTP 200 as success. Anything else — a 201 Created, a 204 No Content, a crash (5xx), a timeout — is retried. It tries up to three more times with growing waits (about 5s, 25s, 125s). After that it marks the event failed and stops. Reply with 200, and do slow work after you reply.

Signs you have this problem

  • In Lemon Squeezy, the webhook shows failed tries, or "failed after 4 attempts".
  • Your logs show the same order_created (or other event) three or four times in a row.
  • Your code wrote the order fine, but Lemon Squeezy still retries.
  • A helper or framework returned 201 or 204 after a good write.

Why Lemon Squeezy retries

Lemon Squeezy's docs are plain about this:

  1. Return a 200 to show the webhook was captured.
  2. If the status is anything else, it retries up to three more times with exponential backoff (for example 5 seconds, 25 seconds, 125 seconds).
  3. After the fourth try, the event is failed. It is not retried on its own. You can resend it from your webhook settings.

So four tries in total is normal when your reply is not a 200. The same event can land up to four times. Your code must skip repeats.

The common trap: 201 or 204

Many helpers return 201 Created after they insert a row. Some return 204 No Content when there is nothing to say back. Both look fine in your own tests. Both are not 200.

Examples that make Lemon Squeezy retry:

  • return Response.json({ ok: true }, { status: 201 })
  • return new NextResponse(null, { status: 204 })
  • return NextResponse.json(data) when your helper sets a non-200 status
  • Throwing an error → Next.js turns it into a 500
  • Doing slow work inside the handler → a timeout, which also counts as a failure

The fix is simple: after you check the signature and store the event, reply with exactly status 200.

A route that works (Next.js App Router)

This reads the raw body, checks X-Signature, skips a repeat, stores the event, then returns 200. Slow work (emails, unlocks) belongs in a queue or background job, not in this request.

// app/api/lemonsqueezy/webhook/route.ts
import { createHmac, timingSafeEqual } from 'node:crypto'

export const runtime = 'nodejs'

// Swap this for your database or queue. 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) {
    // Reply 401. Do not throw — a throw becomes a 500 and Lemon Squeezy retries.
    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)) {
    // Already handled. Still return 200 so Lemon Squeezy stops retrying.
    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 })
  // await queue.add('ls-webhook', { key, raw })

  // Only HTTP 200 counts as success. Not 201. Not 204.
  return new Response('ok', { status: 200 })
}

Use a real unique rule in your database for key (or for the object id plus event name). An in-memory Set is wiped on every deploy and does not stop two copies that arrive at the same moment.

If the signature check fails for every real event, fix that first: /fix/lemon-squeezy-webhook-signature-invalid.

Step by step

  1. Open the failed delivery in Lemon Squeezy. Settings → Webhooks → your endpoint. Note the status code your server sent back.
  2. If it is 201 or 204, change the reply to new Response('ok', { status: 200 }). Redeploy.
  3. If it is 500, your code threw or crashed. Read your host logs. Catch errors and reply with a clear status instead of throwing after you already did the work.
  4. If it is slow or timed out, move emails and other slow work out of the request. Store the event, return 200, process after.
  5. Skip repeats. Retries mean the same event can arrive up to four times. Save event_name + data.id and ignore a key you already handled. For Stripe-style repeats, see /fix/stripe-webhook-duplicate-events.
  6. Resend by hand if you need to. After four failed tries, Lemon Squeezy stops. You can resend recent webhooks from the webhook settings once your route returns 200.

Also using Paddle?

Paddle asks you to reply with 200 within five seconds, then process the event after. Use event_id to skip repeats. For signature errors on Paddle, see /fix/paddle-webhook-signature-verification-failed.

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, a redirect, a Vercel login wall, a Cloudflare challenge, a slow reply, a bad certificate, and 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, and it can't tell whether a real event got a 201 instead of a 200. Use the delivery log in Lemon Squeezy for that.

Sources

Check your webhook now

Paste your webhook address to see what it answers. Free, no account, no keys.

Check my webhook

Get an email when Deep Scans open

One email to confirm, then one when we launch. Leave any time.

We send one email to confirm. You can leave at any time.