I spent two weeks building a dunning tool on top of the Lemon Squeezy API. Three of those days went to problems the documentation does not mention. Writing them down so the next person loses an afternoon instead.
1. The failed payment webhook does not carry a subscription id
When subscription_payment_failed fires, the payload looks like a subscription object. It isn't. The type is subscription-invoices, which means data.id is an invoice id. The subscription id sits one level down, in attributes.subscription_id.
If you read data.id you get a number that looks correct, resolves to nothing, and fails silently in every lookup afterwards.
# wrong
sub_id = payload["data"]["id"]
# right
data = payload["data"]
attrs = data.get("attributes") or {}
if data.get("type") == "subscription-invoices":
sub_id = attrs.get("subscription_id")
else:
sub_id = data.get("id")
The branch matters because subscriptions payloads do put the id in data.id. You need to check the type, not just reach for a fallback.
2. filter[status] on /v1/subscription-invoices does nothing
I needed the unpaid invoice for a subscription. The obvious call:
GET /v1/subscription-invoices?filter[subscription_id]=X&filter[status]=past_due
It returns HTTP 200 with zero rows. So does filter[status]=pending. So does unpaid. Drop the status filter and the same call returns every invoice, including the unpaid one.
Two things were going on.
First, past_due is a subscription status, not an invoice status. An invoice on a failed renewal shows up as pending. That one is on me for guessing.
Second, and worse: the filter does not error on a value it doesn't recognise. It returns 200 with an empty array, which reads exactly like "this subscription has no unpaid invoices" and sends you looking in entirely the wrong place. I spent an afternoon convinced the invoice hadn't been created yet.
So filter in your own code:
rows = get("/v1/subscription-invoices",
params={"filter[subscription_id]": sub_id, "page[size]": 25})
unpaid = next(
(r for r in rows if r["attributes"]["status"] != "paid"),
None,
)
3. There is no way to trigger a retry
This one killed a feature.
I had assumed a payment platform would let me retry a failed charge on demand. Lemon Squeezy does not. POST /v1/subscription-invoices/{id}/retry returns 404, and no other endpoint does it either. Lemon Squeezy runs its own retry schedule, four attempts over two weeks, and gives you no handle on it.
I found this out after the landing page had been promising retries for a while. Worth checking the endpoint exists before you write the copy that sells it.
If you are building anything in this space, the retry is not yours. What is yours is the email: the wording, the timing, and the card update link.
Bonus: the card update link expires
attributes.urls.update_payment_method is signed and time limited. If you store it when the webhook arrives and send it three days later in a follow-up email, it is dead on arrival. Fetch the subscription at send time instead:
GET /v1/subscriptions/{id} -> attributes.urls.update_payment_method
Note also that on the failed-payment payload, urls only contains invoice_url. The update link is not in there at all.
Two smaller ones while I'm at it:
- Webhooks are scoped per mode. Everything you register in Test Mode has to be registered again in Live Mode. Nothing tells you this; the live webhook simply never fires.
-
The signing secret is capped at 40 characters.
openssl rand -hex 24produces 48 and is silently rejected. Usesecrets.token_hex(16).
I hit all of these building Rescue, a dunning tool for Lemon Squeezy and Paddle sellers.
If you sell subscriptions on either platform and have never actually pulled your failed renewal numbers, that is the part I would check first. Most people don't, because the dashboard says recovery is handled.
Top comments (0)