<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Hafid Boulaoutaq</title>
    <description>The latest articles on DEV Community by Hafid Boulaoutaq (@hafid_boulaoutaq_a70aab58).</description>
    <link>https://dev.to/hafid_boulaoutaq_a70aab58</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4068073%2F16f0ff81-ea23-4590-bfa1-8776820e29d8.png</url>
      <title>DEV Community: Hafid Boulaoutaq</title>
      <link>https://dev.to/hafid_boulaoutaq_a70aab58</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hafid_boulaoutaq_a70aab58"/>
    <language>en</language>
    <item>
      <title>Three things the Lemon Squeezy API docs don't tell you about failed payments</title>
      <dc:creator>Hafid Boulaoutaq</dc:creator>
      <pubDate>Sun, 09 Aug 2026 14:23:25 +0000</pubDate>
      <link>https://dev.to/hafid_boulaoutaq_a70aab58/three-things-the-lemon-squeezy-api-docs-dont-tell-you-about-failed-payments-40c1</link>
      <guid>https://dev.to/hafid_boulaoutaq_a70aab58/three-things-the-lemon-squeezy-api-docs-dont-tell-you-about-failed-payments-40c1</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The failed payment webhook does not carry a subscription id
&lt;/h2&gt;

&lt;p&gt;When &lt;code&gt;subscription_payment_failed&lt;/code&gt; fires, the payload looks like a subscription object. It isn't. The type is &lt;code&gt;subscription-invoices&lt;/code&gt;, which means &lt;code&gt;data.id&lt;/code&gt; is an invoice id. The subscription id sits one level down, in &lt;code&gt;attributes.subscription_id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you read &lt;code&gt;data.id&lt;/code&gt; you get a number that looks correct, resolves to nothing, and fails silently in every lookup afterwards.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# wrong
&lt;/span&gt;&lt;span class="n"&gt;sub_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# right
&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;attrs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attributes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;subscription-invoices&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;sub_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;subscription_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;sub_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The branch matters because &lt;code&gt;subscriptions&lt;/code&gt; payloads &lt;em&gt;do&lt;/em&gt; put the id in &lt;code&gt;data.id&lt;/code&gt;. You need to check the type, not just reach for a fallback.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. filter[status] on /v1/subscription-invoices does nothing
&lt;/h2&gt;

&lt;p&gt;I needed the unpaid invoice for a subscription. The obvious call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /v1/subscription-invoices?filter[subscription_id]=X&amp;amp;filter[status]=past_due
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It returns HTTP 200 with zero rows. So does &lt;code&gt;filter[status]=pending&lt;/code&gt;. So does &lt;code&gt;unpaid&lt;/code&gt;. Drop the status filter and the same call returns every invoice, including the unpaid one.&lt;/p&gt;

&lt;p&gt;Two things were going on.&lt;/p&gt;

&lt;p&gt;First, &lt;code&gt;past_due&lt;/code&gt; is a &lt;em&gt;subscription&lt;/em&gt; status, not an invoice status. An invoice on a failed renewal shows up as &lt;code&gt;pending&lt;/code&gt;. That one is on me for guessing.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;So filter in your own code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/v1/subscription-invoices&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;filter[subscription_id]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;sub_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;page[size]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="n"&gt;unpaid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attributes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;paid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. There is no way to trigger a retry
&lt;/h2&gt;

&lt;p&gt;This one killed a feature.&lt;/p&gt;

&lt;p&gt;I had assumed a payment platform would let me retry a failed charge on demand. Lemon Squeezy does not. &lt;code&gt;POST /v1/subscription-invoices/{id}/retry&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: the card update link expires
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;attributes.urls.update_payment_method&lt;/code&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /v1/subscriptions/{id}  -&amp;gt;  attributes.urls.update_payment_method
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note also that on the failed-payment payload, &lt;code&gt;urls&lt;/code&gt; only contains &lt;code&gt;invoice_url&lt;/code&gt;. The update link is not in there at all.&lt;/p&gt;

&lt;p&gt;Two smaller ones while I'm at it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Webhooks are scoped per mode.&lt;/strong&gt; Everything you register in Test Mode has to be registered again in Live Mode. Nothing tells you this; the live webhook simply never fires.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The signing secret is capped at 40 characters.&lt;/strong&gt; &lt;code&gt;openssl rand -hex 24&lt;/code&gt; produces 48 and is silently rejected. Use &lt;code&gt;secrets.token_hex(16)&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;I hit all of these building &lt;a href="https://rescuedunning.com" rel="noopener noreferrer"&gt;Rescue&lt;/a&gt;, a dunning tool for Lemon Squeezy and Paddle sellers.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>api</category>
      <category>saas</category>
      <category>payments</category>
      <category>webhooks</category>
    </item>
  </channel>
</rss>
