BillingWebhookKit

Lemon Squeezy webhook 500 Vercel

Fix Lemon Squeezy webhook 500s on Vercel and Next.js

A 500 means Lemon Squeezy reached your endpoint and the handler crashed or rejected the event after route matching. Fix this before exposing checkout, because retries can duplicate emails, licenses, downloads, and access grants.

Run Vercel debugger Debug x-signature Check fulfillment

Start with the failure boundary

Before HMAC

Missing env vars or malformed headers

Confirm the Vercel production deployment has the webhook signing secret, store or variant IDs, and any database or delivery credentials required by the handler. Log only boolean presence, never secret values.

Signature gate

Raw-body or timing-safe comparison crash

Read request.text() once, compare equal-length HMAC buffers, and reject mismatches with 400. A length mismatch passed into timingSafeEqual can throw and become a 500.

Event parse

JSON shape is not what the code expects

Validate meta.event_name, object ID, payment status, product or variant, and customer target before fulfillment. Unknown events should quarantine cleanly, not crash the route.

Side effects

Database, email, or license delivery throws

Record idempotency before running side effects where possible. If a retry happens, the route must skip duplicate delivery instead of sending another download, email, or license key.

Safer error handling shape

Separate unauthenticated failures, unsupported events, and retryable fulfillment failures. Do not let every exception become an uncontrolled 500.

export async function POST(request: Request) {
  const rawBody = await request.text();
  const signature = request.headers.get("x-signature");

  if (!signature) return new Response("Missing x-signature", { status: 400 });
  if (!process.env.LEMONSQUEEZY_WEBHOOK_SECRET) {
    return new Response("Webhook secret not configured", { status: 500 });
  }

  const verified = await verifyLemonSignature(rawBody, signature);
  if (!verified) return new Response("Invalid signature", { status: 400 });

  const event = JSON.parse(rawBody);
  const idempotencyKey = `lemonsqueezy:${event.meta?.event_name}:${event.data?.id}`;

  try {
    await processOnce(idempotencyKey, async () => {
      await runPaidOrderFulfillment(event);
    });
    return Response.json({ ok: true });
  } catch {
    return new Response("Retryable fulfillment failure", { status: 500 });
  }
}

What to capture before launch

  1. Vercel production route URL and the exact Lemon Squeezy webhook endpoint path.
  2. Boolean env-var presence for signing secret, store ID, variant ID, database URL, and delivery service config.
  3. A signed fake payload proving raw-body verification returns 2xx.
  4. A malformed signature proving the route returns 400 instead of crashing.
  5. A duplicate replay test proving paid fulfillment runs once.
  6. A secret-free report attached to the release or PR.

When 500 is the correct response

Returning 500 can be correct when a verified paid event cannot be durably processed and Lemon Squeezy should retry. Returning 500 is not correct for bad signatures, unsupported events, wrong products, missing customer targets, or duplicate deliveries that have already been processed.

Convert the fix into launch evidence

Safety boundary: use fake payloads and redacted logs in public reports. Never paste Lemon Squeezy API keys, webhook signing secrets, private checkout links, customer data, database URLs, or full live order payloads into shared debugging artifacts.