Skip to content

Recovering from missed Lemon Squeezy webhooks

Three retries over about two and a half minutes, then the event is gone. A nightly reconcile from the API, what it can and cannot rebuild, and how to fix the rows it cannot.

Lemon Squeezy3 min readships at docs/solutions/lemonsqueezy/recovering-from-missed-webhooks.md

Tags: lemonsqueezy · webhooks · reconcile · operations · incidents · billing

Lemon Squeezy retries a failed webhook delivery three more times, at about 5, 25 and 125 seconds, and then gives up. A deploy that takes four minutes, a database failover, a rotated secret you forgot to update: each one loses events for good. A payment integration that trusts the stream alone drifts, quietly.

The fix is to treat your billing tables as a cache of Lemon Squeezy and rebuild the cache on a schedule.

Reconcile from the API, through the webhook's own code

A reconcile job does three things:

  1. Lists every subscription in the store (listSubscriptions filtered by store id, every status, every page) and writes each one through the same function the webhook uses. Upserts by id, so a second run changes nothing.
  2. Cancels local rows the API no longer has, but only after the listing finished. "Lemon Squeezy did not mention it" is only evidence if it finished talking. Those rows come from a test-mode reset or the other mode.
  3. Re-reads recent orders (listOrders, newest first, stop at a cut-off like 90 days) and writes the one-time ones, which also catches refunds a webhook missed.

Send no mail from it. Receipts belong to the webhook that saw the payment; a sweep that mails would send a receipt for every order in the window.

Run it nightly. It is safe during an incident and at 2am.

What the API cannot tell you

The API does not return checkout custom data, so a reconcile cannot learn which of your users bought something from the order alone. It can only match through what you already stored:

  • the customer mapping (Lemon Squeezy customer id to your user), recorded the first time a webhook carried both;
  • an existing purchase row for the same order.

A subscription or a paid one-time order with neither stays unmatched, and the job should list them rather than guess. Matching by email is a guess: shared inboxes, gifts and changed addresses all make it wrong.

Fixing an unmatched order

  1. Find the order in the dashboard and the user it belongs to (their email, then your auth provider's user list).
  2. Either resend the order_created delivery from the webhook's page in the dashboard (it still carries the custom data, and your handler records it now), or link the customer by hand:
insert into billing_customers (user_id, provider, provider_customer_id)
values ('<user id>', 'lemonsqueezy', '<lemon squeezy customer id>')
on conflict do nothing;
  1. Run the reconcile again. The linked customer's subscriptions and orders now match.

Stuck claims

An idempotency table that stamps a claim when work starts and completes it when work ends shows a third state: claimed long ago, never completed. That is a handler that died mid-flight (a timeout, a crash, a deploy). Lemon Squeezy got no 200 and has probably stopped retrying.

Do not delete the claim to "let the retry through": there is no retry coming, and a later manual resend would find a clean slate and run side effects twice. Run the reconcile, check the affected rows, and leave the claim to age out with the rest.

Know when it happened

  • The webhook's page in the dashboard lists recent deliveries and their responses. A run of 500s is your incident window.
  • A pruning job that also counts stuck claims turns "something went wrong last week" into a number you see every night.