<?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: Webhooker</title>
    <description>The latest articles on DEV Community by Webhooker (@webhooker-eu).</description>
    <link>https://dev.to/webhooker-eu</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%2F4132023%2F77333e76-9705-42fe-b6a5-4864763ca2c8.png</url>
      <title>DEV Community: Webhooker</title>
      <link>https://dev.to/webhooker-eu</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/webhooker-eu"/>
    <language>en</language>
    <item>
      <title>Your Stripe handler is shipping orders nobody paid for</title>
      <dc:creator>Webhooker</dc:creator>
      <pubDate>Fri, 09 Oct 2026 19:55:14 +0000</pubDate>
      <link>https://dev.to/webhooker-eu/your-stripe-handler-is-shipping-orders-nobody-paid-for-18a9</link>
      <guid>https://dev.to/webhooker-eu/your-stripe-handler-is-shipping-orders-nobody-paid-for-18a9</guid>
      <description>&lt;p&gt;Here is the Stripe Checkout handler most of us wrote first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;checkout.session.completed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fulfilOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&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;p&gt;It passes every test with &lt;code&gt;4242 4242 4242 4242&lt;/code&gt;. It goes live, cards work, everyone moves on.&lt;/p&gt;

&lt;p&gt;Then someone in Germany pays with SEPA Direct Debit. The order ships the same minute. Four days later the debit bounces, and you have sent a parcel to someone who never paid for it.&lt;/p&gt;

&lt;p&gt;Nothing in that handler is wrong for cards. It just reads the event name as "payment succeeded", and the event does not say that.&lt;/p&gt;

&lt;h2&gt;
  
  
  completed means the customer finished, not that you got paid
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;checkout.session.completed&lt;/code&gt; fires when the customer submits the Checkout page. Stripe's own description of the session's &lt;code&gt;status: "complete"&lt;/code&gt; is careful about this: "The checkout session is complete. Payment processing may still be in progress."&lt;/p&gt;

&lt;p&gt;Whether you actually have the money lives in a different field, &lt;code&gt;payment_status&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;paid&lt;/code&gt;: the funds are in your account&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;unpaid&lt;/code&gt;: the funds are not there yet&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;no_payment_required&lt;/code&gt;: nothing to collect, like a free trial or a 100% coupon&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With cards, iDEAL or Bancontact the session completes as &lt;code&gt;paid&lt;/code&gt;, so the bug stays invisible. With SEPA Direct Debit, Bacs Direct Debit or a bank transfer, Checkout completes before the bank has said anything, and the session arrives as &lt;code&gt;unpaid&lt;/code&gt;. The real outcome shows up days later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;day 0     checkout.session.completed                payment_status: "unpaid"
day 0     payment_intent.processing
day 2-5   payment_intent.succeeded                  (or payment_failed)
day 2-5   checkout.session.async_payment_succeeded  payment_status: "paid"
          (or checkout.session.async_payment_failed)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you sell in Europe, this is not an edge case. SEPA is a very normal way to pay here.&lt;/p&gt;

&lt;h2&gt;
  
  
  The obvious fix makes it worse
&lt;/h2&gt;

&lt;p&gt;The first instinct is to switch to &lt;code&gt;payment_intent.succeeded&lt;/code&gt;, since that one only fires once the money is confirmed. It does fix SEPA. It also breaks three other things.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Free orders stop working.&lt;/strong&gt; No money moved, so there is no successful PaymentIntent and the event never fires. The giveaway, the free tier and the 100% launch coupon all complete Checkout and then sit there. Those are exactly the orders nobody looks at until a customer complains.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Subscriptions fulfil twice, then forever.&lt;/strong&gt; Every paid invoice produces its own &lt;code&gt;payment_intent.succeeded&lt;/code&gt;. Your handler cannot tell a new subscriber from their twelfth renewal without extra lookups, and if you kept the session handler too, month one gets processed twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The payload has nothing you need.&lt;/strong&gt; A PaymentIntent knows the amount and currency. It does not know the line items, the address the customer typed into Checkout, your &lt;code&gt;client_reference_id&lt;/code&gt;, or the &lt;code&gt;metadata&lt;/code&gt; you set on the session. Session metadata is not copied over unless you also pass it as &lt;code&gt;payment_intent_data.metadata&lt;/code&gt;. Most handlers built this way end up calling the API to find the Checkout Session again, which is a long way round to the event they started with.&lt;/p&gt;

&lt;p&gt;And one more: &lt;code&gt;payment_intent.succeeded&lt;/code&gt; is account-wide. If another app on the same Stripe account takes payments, or Billing charges an invoice, your Checkout handler gets those events too.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule that covers every case
&lt;/h2&gt;

&lt;p&gt;For Checkout and Payment Links:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Listen to &lt;code&gt;checkout.session.completed&lt;/code&gt; and &lt;code&gt;checkout.session.async_payment_succeeded&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Send both into the same fulfilment function.&lt;/li&gt;
&lt;li&gt;Fulfil only when &lt;code&gt;payment_status&lt;/code&gt; is not &lt;code&gt;unpaid&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The third rule is what makes it work. Card orders pass on the first event. SEPA orders get skipped on the first event and pass on the second. Free orders pass as &lt;code&gt;no_payment_required&lt;/code&gt;. Subscriptions fulfil once from Checkout, and renewals go through &lt;code&gt;invoice.paid&lt;/code&gt;, where they belong.&lt;/p&gt;

&lt;p&gt;Handle &lt;code&gt;checkout.session.async_payment_failed&lt;/code&gt; too. That is the moment to cancel the order and email the customer, because nobody else is going to tell them their debit failed.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;payment_intent.succeeded&lt;/code&gt; is still the right event in one setup: when you build your own form with the Payment Element and create PaymentIntents yourself. There is no Checkout Session there, you put the order ID in the intent's metadata, and you fulfil on &lt;code&gt;payment_intent.succeeded&lt;/code&gt;. What you should not do is listen to both for the same order. They describe one payment from two objects, and a handler that reacts to both ships twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  A handler that fulfils exactly once
&lt;/h2&gt;

&lt;p&gt;Stripe delivers at least once and does not promise any order. &lt;code&gt;async_payment_succeeded&lt;/code&gt; can land while you are still processing &lt;code&gt;completed&lt;/code&gt;, a retry can bring the same event twice, and the customer's browser can hit your success page while the webhook is still in flight. Here is a version that survives all three:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stripe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fulfilmentEvents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;checkout.session.completed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;checkout.session.async_payment_succeeded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/webhooks/stripe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stripe-signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fulfilmentEvents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fulfil-checkout&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;checkout.session.async_payment_failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;payment-failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Called by the queue worker and by the success page.&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fulfilCheckout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;checkout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sessions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;expand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;line_items&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payment_status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unpaid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;claimed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`INSERT INTO fulfilments (checkout_session_id) VALUES ($1)
     ON CONFLICT (checkout_session_id) DO NOTHING
     RETURNING checkout_session_id`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;claimed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rowCount&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;shipLineItems&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;line_items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer_details&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;p&gt;What each piece is there for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;express.raw&lt;/code&gt;&lt;/strong&gt; keeps the exact bytes Stripe signed. Parse the JSON first and you get &lt;code&gt;No signatures found matching the expected signature&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enqueue, then answer.&lt;/strong&gt; When you set a &lt;code&gt;success_url&lt;/code&gt;, Checkout waits up to 10 seconds for your endpoint to answer before redirecting the customer. A queue write takes milliseconds. Emails and warehouse calls happen in the worker. Enqueue &lt;em&gt;before&lt;/em&gt; the &lt;code&gt;200&lt;/code&gt;, though: answer first, fail to enqueue, and Stripe thinks the event was delivered and never retries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retrieve the session fresh.&lt;/strong&gt; The object in the event is a snapshot from when the event was created. Asking the API for the current state means it no longer matters which of the two events arrives first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A unique constraint, not a check.&lt;/strong&gt; Two concurrent calls both pass a &lt;code&gt;SELECT ... WHERE fulfilled&lt;/code&gt;. Only one of them wins the &lt;code&gt;INSERT ... ON CONFLICT DO NOTHING&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The success page calls the same function.&lt;/strong&gt; Put &lt;code&gt;{CHECKOUT_SESSION_ID}&lt;/code&gt; in your &lt;code&gt;success_url&lt;/code&gt; and call &lt;code&gt;fulfilCheckout&lt;/code&gt; when the customer lands. Webhooks can be late, and the unique row makes the second call a no-op.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One caveat on that claim row: if the worker dies after the insert and before shipping, the row says "done" and every retry skips it. If your shipping step is not quick and safe to repeat, record a status on the row and only mark it fulfilled after the work commits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing the path your test cards skip
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;stripe trigger checkout.session.completed&lt;/code&gt; sends a card-shaped event, so it never exercises the delayed path. To see it for real, enable SEPA Direct Debit in your sandbox, go through an actual Checkout, and pay with Stripe's test IBAN &lt;code&gt;AT611904300234573201&lt;/code&gt;. You should see &lt;code&gt;checkout.session.completed&lt;/code&gt; with &lt;code&gt;payment_status: "unpaid"&lt;/code&gt;, then &lt;code&gt;checkout.session.async_payment_succeeded&lt;/code&gt; shortly after.&lt;/p&gt;

&lt;p&gt;If your handler ships on the first one, you have the bug from the top of this post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cheat sheet
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Your setup&lt;/th&gt;
&lt;th&gt;Fulfil on&lt;/th&gt;
&lt;th&gt;Also handle&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Checkout, cards only&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;checkout.session.completed&lt;/code&gt; + &lt;code&gt;payment_status&lt;/code&gt; check&lt;/td&gt;
&lt;td&gt;&lt;code&gt;checkout.session.expired&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Checkout with SEPA, Bacs or bank transfer&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;completed&lt;/code&gt; + &lt;code&gt;async_payment_succeeded&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;async_payment_failed&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Checkout in subscription mode&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;checkout.session.completed&lt;/code&gt; to provision&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;invoice.paid&lt;/code&gt;, &lt;code&gt;invoice.payment_failed&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Your own PaymentIntents (Payment Element)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;payment_intent.succeeded&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;payment_intent.payment_failed&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The full version, with the field-by-field comparison of both events, the subscription event map and why &lt;code&gt;payment_intent&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt; on a fresh session, is on our blog: &lt;a href="https://webhooker.eu/blog/checkout-session-completed-vs-payment-intent-succeeded" rel="noopener noreferrer"&gt;checkout.session.completed vs payment_intent.succeeded&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>webhooks</category>
      <category>payments</category>
      <category>node</category>
    </item>
    <item>
      <title>Stop guessing why your webhook signature check fails</title>
      <dc:creator>Webhooker</dc:creator>
      <pubDate>Thu, 24 Sep 2026 08:47:12 +0000</pubDate>
      <link>https://dev.to/webhooker-eu/stop-guessing-why-your-webhook-signature-check-fails-17a0</link>
      <guid>https://dev.to/webhooker-eu/stop-guessing-why-your-webhook-signature-check-fails-17a0</guid>
      <description>&lt;p&gt;&lt;code&gt;No signatures found matching the expected signature for payload&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That is Stripe's wording. GitHub gives you a mismatch on &lt;code&gt;X-Hub-Signature-256&lt;/code&gt;, Shopify words it differently again, and hand written handlers usually log something like "invalid signature". They all mean exactly one thing: the HMAC you computed is not the HMAC that arrived.&lt;/p&gt;

&lt;p&gt;What none of them tell you is which input was wrong. And that is the whole problem, because there are only four:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the signing secret&lt;/li&gt;
&lt;li&gt;the exact bytes of the request body&lt;/li&gt;
&lt;li&gt;the hash algorithm&lt;/li&gt;
&lt;li&gt;the encoding of the result&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every failed verification is one of those four being different from what the sender used. Four candidates is a small enough space that you should never be guessing. Yet the usual debugging session is an hour of swapping secrets and re-reading docs, because the error message collapses four distinct failures into one string.&lt;/p&gt;

&lt;p&gt;Here is how to find the broken input in about five minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, log the two things that actually narrow it down
&lt;/h2&gt;

&lt;p&gt;Before changing any code, add this to the handler and send one delivery through it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getRawBody&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// whatever your framework gives you&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;contentLength&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content-length&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;header&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stripe-signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;  &lt;span class="c1"&gt;// or x-hub-signature-256, etc&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Never log the raw body itself. It has customer data in it, and now your logging provider has it too. The hash and the length tell you what you need without keeping the payload around.&lt;/p&gt;

&lt;p&gt;Two numbers, one comparison, and you have already split the problem in half.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check one: does the byte count match?
&lt;/h2&gt;

&lt;p&gt;Compare &lt;code&gt;bytes&lt;/code&gt; against the &lt;code&gt;content-length&lt;/code&gt; header the sender set.&lt;/p&gt;

&lt;p&gt;Different? Your body was modified before you hashed it, and nothing else matters until you fix that. This is the single most common cause by a wide margin, and every vendor doc that ranks for this error says so.&lt;/p&gt;

&lt;p&gt;The usual culprit is a JSON parser that ran first. &lt;code&gt;express.json()&lt;/code&gt;, &lt;code&gt;body-parser&lt;/code&gt;, FastAPI's automatic model binding, a framework middleware you never registered explicitly. The parse gives you an object, you re-serialise it to hash it, and &lt;code&gt;JSON.stringify&lt;/code&gt; does not reproduce the sender's bytes. Key order can shift. Whitespace is gone. Unicode escaping differs. One byte off and the HMAC is completely different, which is the entire point of a hash.&lt;/p&gt;

&lt;p&gt;In Express the fix is to keep the raw buffer on the webhook route only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/webhooks/stripe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// req.body is a Buffer here, not an object&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;p&gt;Order matters. If &lt;code&gt;app.use(express.json())&lt;/code&gt; is registered above this line, it wins and you are back to a parsed body.&lt;/p&gt;

&lt;p&gt;Same number? Good, the body is intact. The problem is one of the other three, and you can stop rewriting your middleware stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check two: is it the secret or the encoding?
&lt;/h2&gt;

&lt;p&gt;Now sign a known value and compare against a known answer, taking your app out of the picture entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mac&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;test_secret&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hello&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 8e3c5f1f7dcbf1e6e9a0d0f2b1e3c... whatever your runtime gives&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mac&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the same thing in a plain Node REPL, or &lt;code&gt;openssl dgst -sha256 -hmac test_secret&lt;/code&gt;. If the two differ, the bug is in how your code builds the HMAC, not in the webhook at all.&lt;/p&gt;

&lt;p&gt;Two specific things to look at here.&lt;/p&gt;

&lt;p&gt;Encoding. Stripe sends hex. Shopify sends base64. If you compare a hex digest against a base64 header, the lengths alone will never match, and the failure looks identical to a wrong secret. Check the length of what you computed against the length of what arrived. 64 characters is a hex sha256; 44 characters ending in &lt;code&gt;=&lt;/code&gt; is base64.&lt;/p&gt;

&lt;p&gt;The secret itself. Copying from a dashboard drags in a trailing newline or space more often than anyone admits, and the value is invisible in logs. Print &lt;code&gt;secret.length&lt;/code&gt; once. Stripe signing secrets start with &lt;code&gt;whsec_&lt;/code&gt; and the whole string is the secret, prefix included. Do not strip it. And the signing secret is not the API key: &lt;code&gt;sk_live_&lt;/code&gt; will never verify anything, no matter how correct the rest of the code is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check three: are you on an edge runtime?
&lt;/h2&gt;

&lt;p&gt;This one is missing from almost every tutorial, because the tutorials predate the platforms.&lt;/p&gt;

&lt;p&gt;Cloudflare Workers, Deno Deploy, Vercel Edge, Supabase Edge Functions. None of them have Node's synchronous crypto. Stripe's &lt;code&gt;constructEvent&lt;/code&gt; is synchronous and will throw or silently misbehave there. The async variant exists precisely for this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEventAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single change is what fixes a surprising number of "it worked on Pages, it broke on Workers" threads. The &lt;a href="https://community.cloudflare.com/t/stripe-webhook-stopped-working-after-migrating-from-pages-to-workers/884847" rel="noopener noreferrer"&gt;Cloudflare community has a good example from January&lt;/a&gt; where a working webhook broke on migration for exactly this reason.&lt;/p&gt;

&lt;p&gt;The second edge trap is that a &lt;code&gt;Request&lt;/code&gt; body can only be read once. If anything upstream already called &lt;code&gt;await req.json()&lt;/code&gt;, your &lt;code&gt;await req.text()&lt;/code&gt; returns an empty string, you hash nothing, and the signature never matches. Read the text first, parse from the string you already have:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;       &lt;span class="c1"&gt;// read once&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stripe-signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEventAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;       &lt;span class="c1"&gt;// parse from the same string, not the request&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you are writing the HMAC yourself on an edge runtime, it is Web Crypto, and all of it is async:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;importKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;raw&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;HMAC&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SHA-256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sign&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mac&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;HMAC&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mac&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  "It works locally but not in production"
&lt;/h2&gt;

&lt;p&gt;If local is fine and production fails, the four inputs are the same in your code, so something in between changed the request or the environment.&lt;/p&gt;

&lt;p&gt;A proxy or CDN that buffers and re-emits the body will change the bytes. So will decompressing a gzipped payload and re-encoding it. Nginx with &lt;code&gt;proxy_set_body&lt;/code&gt;, an API gateway doing request transformation, a WAF that normalises JSON. Any of these produce the same symptom as a parser.&lt;/p&gt;

&lt;p&gt;Or it is simply the wrong secret for the environment. Test and live endpoints have different signing secrets, and a &lt;code&gt;.env&lt;/code&gt; that got copied between them fails in a way that looks mysterious but is not.&lt;/p&gt;

&lt;p&gt;One more that catches people: a tunnel used in local development. Some tunnels rewrite headers or re-encode the body, which means local passes for the wrong reason and production is where you first meet real bytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  While you are in there, fix the comparison
&lt;/h2&gt;

&lt;p&gt;Once verification passes, check how you compare the two values. This is not why it fails, but it is where a fixed handler often stays broken in a way nobody notices:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;computed&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;received&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;                 &lt;span class="c1"&gt;// leaks timing information&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;          &lt;span class="c1"&gt;// constant time&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;timingSafeEqual&lt;/code&gt; throws if the two buffers have different lengths, so guard for that first. And check the timestamp too. A valid signature on a request captured an hour ago is still a valid signature, which is why providers include a timestamp in the signed payload and expect you to reject anything outside a few minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing worth keeping
&lt;/h2&gt;

&lt;p&gt;Leave the length and hash logging in permanently. Not the body, just &lt;code&gt;bytes&lt;/code&gt;, the truncated hash, and the header. It costs nothing, contains no customer data, and the next time this breaks you will know within one delivery whether the bytes changed or the secret did.&lt;/p&gt;

&lt;p&gt;That is really the whole trick. The error message describes a symptom shared by four different causes, so stop reading the message and start measuring the inputs.&lt;/p&gt;

&lt;p&gt;Longer write up with the full list of causes, the error strings each provider uses, and what to do about clock skew: &lt;a href="https://webhooker.eu/blog/webhook-signature-verification-failed" rel="noopener noreferrer"&gt;Webhook signature verification failed: 6 causes and fixes&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webhooks</category>
      <category>debugging</category>
      <category>node</category>
      <category>security</category>
    </item>
    <item>
      <title>The webhook dedupe everyone copies has a hole in it</title>
      <dc:creator>Webhooker</dc:creator>
      <pubDate>Fri, 18 Sep 2026 19:49:36 +0000</pubDate>
      <link>https://dev.to/webhooker-eu/the-webhook-dedupe-everyone-copies-has-a-hole-in-it-2lnj</link>
      <guid>https://dev.to/webhooker-eu/the-webhook-dedupe-everyone-copies-has-a-hole-in-it-2lnj</guid>
      <description>&lt;p&gt;If you have ever searched for how to stop processing the same webhook twice, you have seen this snippet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;processed_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;NOTHING&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="n"&gt;event_key&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Got a row back, you are the first one here, do the work. Got nothing, somebody already handled it, return 200 and move on. It is correct, it is atomic, and it beats the check-then-insert version that races itself under load.&lt;/p&gt;

&lt;p&gt;It also has a hole. Claim the key, start the work, and have the process die before the work commits: the key stays in the table, marked as handled. Every retry after that hits the conflict branch and returns 200. The sender is happy. The event never happened.&lt;/p&gt;

&lt;p&gt;I want to walk through that specific failure, because most dedupe tutorials stop one paragraph before it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why you are seeing the same event twice at all
&lt;/h2&gt;

&lt;p&gt;Duplicates are not a provider bug you can report. They fall out of how at-least-once delivery works.&lt;/p&gt;

&lt;p&gt;Your handler gets an event, commits the write, and then the response back to the sender gets lost, or your reply lands two seconds after the sender's read timeout. From where the sender sits, that delivery failed, so it sends the event again. Stripe &lt;a href="https://docs.stripe.com/webhooks#automatic-retries" rel="noopener noreferrer"&gt;retries failed events for up to three days in live mode&lt;/a&gt;, and you can resend anything from the dashboard on top of that. GitHub lets you redeliver anything from the last three days out of the UI.&lt;/p&gt;

&lt;p&gt;There is no timeout value that closes the window. The duplicate is a normal outcome of a system that would rather send twice than lose one, so the handler has to make repeats harmless.&lt;/p&gt;

&lt;h2&gt;
  
  
  The snippet, and where it breaks
&lt;/h2&gt;

&lt;p&gt;Here is the whole thing in Python, roughly as it gets copied into production:&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;claimed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INSERT INTO processed_events (event_key, source) &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;VALUES (%s, %s) ON CONFLICT (event_key) DO NOTHING &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RETURNING event_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;event_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fetchone&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;claimed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;  &lt;span class="c1"&gt;# already handled
&lt;/span&gt;
&lt;span class="nf"&gt;fulfil_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# the actual work
&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things commit here, and they commit separately. The claim goes in first. The work goes in second. Anything that kills the process in between leaves you with a key that says "done" and a side effect that never ran: a pod restart mid-deploy, an OOM kill, a connection drop to the downstream API, a &lt;code&gt;SIGTERM&lt;/code&gt; your worker does not handle gracefully.&lt;/p&gt;

&lt;p&gt;The window is small. It is also hit constantly, because deploys happen during traffic and webhook volume is bursty. And the failure is quiet: no error, no retry, no alert. The event is just gone, and you find out when a customer emails about the thing they paid for.&lt;/p&gt;

&lt;p&gt;Worse, the ordering is what makes it quiet. Do the work first and crash before the claim and you get a duplicate, which is loud and recoverable. Claim first and crash before the work and you get a loss, which is neither.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix one: make it a single commit
&lt;/h2&gt;

&lt;p&gt;If the side effect is a write to the same database, this is easy. Put the claim and the work in one transaction and stop thinking about it:&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="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;claimed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INSERT INTO processed_events (event_key, source) &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;VALUES (%s, %s) ON CONFLICT (event_key) DO NOTHING &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RETURNING event_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;event_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fetchone&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;claimed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;

    &lt;span class="nf"&gt;fulfil_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# same transaction, same commit
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now a crash rolls back both. The key is not there, the next retry claims it cleanly, and the work runs exactly once in the sense that matters: one visible effect. &lt;a href="https://www.postgresql.org/docs/current/sql-insert.html#SQL-ON-CONFLICT" rel="noopener noreferrer"&gt;&lt;code&gt;ON CONFLICT&lt;/code&gt;&lt;/a&gt; still does the concurrency half, and the transaction does the crash half.&lt;/p&gt;

&lt;p&gt;One thing to watch: the transaction stays open for as long as &lt;code&gt;fulfil_order&lt;/code&gt; runs. If that function calls a third party API with a thirty second timeout, you are holding a database connection and a row lock for thirty seconds per event. At low volume nobody notices. At a few hundred events a minute you run out of connections.&lt;/p&gt;

&lt;p&gt;Which brings us to the case this pattern cannot cover.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix two: claim with a state, when the work leaves the database
&lt;/h2&gt;

&lt;p&gt;Sending an email, charging a card, calling somebody's API. None of that rolls back with your transaction, so a single commit does not exist to hide behind. What you can do is stop pretending the claim is binary. It has three states, not two.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;processed_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;event_key&lt;/span&gt;    &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;source&lt;/span&gt;       &lt;span class="nb"&gt;TEXT&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt;       &lt;span class="nb"&gt;TEXT&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- 'in_progress' or 'done'&lt;/span&gt;
    &lt;span class="n"&gt;claimed_at&lt;/span&gt;   &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;completed_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The claim becomes an upsert that will take the key back off a worker that has clearly died:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;processed_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'in_progress'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;
    &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'in_progress'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;claimed_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;processed_events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'in_progress'&lt;/span&gt;
      &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;processed_events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;claimed_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'5 minutes'&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="n"&gt;event_key&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three outcomes, and you have to handle all three:&lt;/p&gt;

&lt;p&gt;A row comes back and you own the event. Do the work, then set &lt;code&gt;status = 'done'&lt;/code&gt; and &lt;code&gt;completed_at = now()&lt;/code&gt;. That second update is the only thing that tells future retries to skip.&lt;/p&gt;

&lt;p&gt;No row comes back and the stored status is &lt;code&gt;done&lt;/code&gt;. Somebody finished this already. Return 200.&lt;/p&gt;

&lt;p&gt;No row comes back and the stored status is &lt;code&gt;in_progress&lt;/code&gt; with a fresh &lt;code&gt;claimed_at&lt;/code&gt;. Another worker has it right now. Do not process, and do not return 200 either: return 409 or 503 so the sender retries in a minute. A 200 here is a lie that costs you the event if the other worker dies.&lt;/p&gt;

&lt;p&gt;Since &lt;code&gt;RETURNING&lt;/code&gt; cannot tell the last two apart, read the row when the claim fails:&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="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;claimed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT status FROM processed_events WHERE event_key = %s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;event_key&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;scalar&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;done&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pick the lease window to be longer than your slowest realistic handler and shorter than your patience. Five minutes is a reasonable default when the work is an HTTP call with a thirty second timeout and a couple of retries. Too short and two workers process the same event; too long and a crashed worker parks the event until the lease expires.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then make the write idempotent anyway
&lt;/h2&gt;

&lt;p&gt;Dedupe tables fail. Somebody truncates one during a migration, a Redis instance without persistence restarts, a key expires earlier than the provider's retry window. So make the write itself survive being run twice, and the dedupe layer becomes an optimisation rather than the only thing standing between you and a double charge.&lt;/p&gt;

&lt;p&gt;A unique constraint on the business key does most of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;uq_orders_event&lt;/span&gt;
    &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;provider_event_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A conditional update covers state transitions, so a replayed &lt;code&gt;payment.succeeded&lt;/code&gt; on an invoice that is already paid changes nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'paid'&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run either one five times and the database looks the same as after one. That is the actual goal. The dedupe table is just a way to avoid the wasted work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use a key that survives the retry
&lt;/h2&gt;

&lt;p&gt;All of the above is worthless if the key changes between copies of the same event. Key on the event's identity, never on the moment it arrived.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;Key to use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Stripe&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;id&lt;/code&gt; on the event object, the &lt;code&gt;evt_...&lt;/code&gt; value&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub&lt;/td&gt;
&lt;td&gt;the &lt;code&gt;X-GitHub-Delivery&lt;/code&gt; header, which stays the same on a manual redelivery&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shopify&lt;/td&gt;
&lt;td&gt;the &lt;code&gt;X-Shopify-Webhook-Id&lt;/code&gt; header&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Webhooker&lt;/td&gt;
&lt;td&gt;the &lt;code&gt;X-Webhooker-Event-Id&lt;/code&gt; header, stable across retries and replays&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Do not use a timestamp taken at receipt, a UUID your framework generates per request, or the retry count. Each of those makes every retry look brand new, which turns your dedupe table into an expensive log of things you processed twice.&lt;/p&gt;

&lt;p&gt;Also: do not key on the object id inside the payload. A single charge produces several distinct events, and keying on &lt;code&gt;ch_...&lt;/code&gt; means the second event gets swallowed as a duplicate of the first.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to actually test this
&lt;/h2&gt;

&lt;p&gt;Two tests, neither of which needs load:&lt;/p&gt;

&lt;p&gt;Send the same delivery twice, in sequence, and assert the side effect happened once. That catches the changing-key mistake, which is more common than the race.&lt;/p&gt;

&lt;p&gt;Then kill the process between the claim and the commit. Drop a &lt;code&gt;sys.exit(1)&lt;/code&gt; inside &lt;code&gt;fulfil_order&lt;/code&gt;, replay the event, restart, replay again, and check that the work eventually happens. If your second replay returns 200 without doing anything, you have the hole, and you now know exactly where it is.&lt;/p&gt;

&lt;p&gt;Longer version of this, including the &lt;code&gt;Idempotency-Key&lt;/code&gt; header that everyone confuses with webhook dedupe and why it points the other way, is on our blog: &lt;a href="https://webhooker.eu/blog/webhook-idempotency-keys" rel="noopener noreferrer"&gt;Idempotency keys for webhook consumers&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webhooks</category>
      <category>postgres</category>
      <category>api</category>
      <category>backend</category>
    </item>
  </channel>
</rss>
