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

```sql
insert into billing_customers (user_id, provider, provider_customer_id)
values ('<user id>', 'lemonsqueezy', '<lemon squeezy customer id>')
on conflict do nothing;
```

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

---

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
