<?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: Failure Mode Labs</title>
    <description>The latest articles on DEV Community by Failure Mode Labs (@failuremodelabs).</description>
    <link>https://dev.to/failuremodelabs</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%2F4127193%2F4b8c53ca-1143-4e47-81ed-74c520c53534.png</url>
      <title>DEV Community: Failure Mode Labs</title>
      <link>https://dev.to/failuremodelabs</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/failuremodelabs"/>
    <language>en</language>
    <item>
      <title>One Payment Event, Two Credit Grants: A TypeScript Webhook Bug</title>
      <dc:creator>Failure Mode Labs</dc:creator>
      <pubDate>Wed, 16 Sep 2026 15:11:58 +0000</pubDate>
      <link>https://dev.to/failuremodelabs/one-payment-event-two-credit-grants-a-typescript-webhook-bug-1g8j</link>
      <guid>https://dev.to/failuremodelabs/one-payment-event-two-credit-grants-a-typescript-webhook-bug-1g8j</guid>
      <description>&lt;p&gt;What happens if your payment handler receives the same event twice?&lt;/p&gt;

&lt;p&gt;In this local TypeScript lab, an event represents a $19 payment. The broken handler grants $19 of application credit on every delivery. Replay the event, and the local balance becomes $38.&lt;/p&gt;

&lt;p&gt;There was no second payment. The application repeated its own business action. These are synthetic events; no real charges are made.&lt;/p&gt;

&lt;p&gt;Here is the demo output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BROKEN
  Delivery 1: processed; 1 credit grant(s); $19.00 local credit
  Delivery 2: processed; 2 credit grant(s); $38.00 local credit
CORRECTED
  Delivery 1: processed; 1 credit grant(s); $19.00 local credit
  Delivery 2: duplicate; 1 credit grant(s); $19.00 local credit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why remembering the event ID is only part of the fix
&lt;/h2&gt;

&lt;p&gt;The broken handler validates the event and immediately inserts a credit grant. It never claims the event ID.&lt;/p&gt;

&lt;p&gt;Adding a processed-event table helps, but the order of writes matters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Commit the event marker first, then fail before granting credit: a retry may skip work that never happened.&lt;/li&gt;
&lt;li&gt;Grant credit first, then fail before recording the event: a retry may grant credit again.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The marker and the local business write need to commit together.&lt;/p&gt;

&lt;h2&gt;
  
  
  The TypeScript correction
&lt;/h2&gt;

&lt;p&gt;The SQLite table gives each processed event a unique key:&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;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&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_id&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is the handler from the lab with its failure-injection hooks omitted for readability. This excerpt uses the lab's helpers: &lt;code&gt;parsePayment&lt;/code&gt; validates the synthetic event and returns its payment fields, while &lt;code&gt;grantCredit&lt;/code&gt; inserts a row into &lt;code&gt;credit_grants&lt;/code&gt; using the same database connection. It is not a standalone HTTP endpoint.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;DatabaseSync&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:sqlite&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;correctedHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DatabaseSync&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;unknown&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;payment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parsePayment&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ignored&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;BEGIN IMMEDIATE&lt;/span&gt;&lt;span class="dl"&gt;'&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;claim&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`
      INSERT INTO processed_events (event_id) VALUES (?)
      ON CONFLICT(event_id) DO NOTHING
    `&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eventId&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;claim&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;changes&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="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;COMMIT&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;duplicate&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;grantCredit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;COMMIT&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;processed&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;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ROLLBACK&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;BEGIN IMMEDIATE&lt;/code&gt; obtains SQLite's write lock before claiming the event. The unique key arbitrates duplicate claims. The transaction makes the marker and credit grant commit or roll back together.&lt;/p&gt;

&lt;p&gt;After a successful commit, replaying the same event finds the marker and returns &lt;code&gt;duplicate&lt;/code&gt;. If the transaction fails, the error propagates so a real adapter can arrange a retry rather than falsely acknowledge success. An error starting the transaction also propagates; it is not treated as a duplicate.&lt;/p&gt;

&lt;p&gt;All competing handlers must use the same database. Separate SQLite files on separate servers do not coordinate. Keep external network calls outside this short transaction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the failure paths, not just the happy path
&lt;/h2&gt;

&lt;p&gt;The free lab includes 10 tests, covering cases such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Replaying the same event without a second credit grant.&lt;/li&gt;
&lt;li&gt;Injecting a failure after the marker or after the grant, then retrying.&lt;/li&gt;
&lt;li&gt;Closing and reopening the database connection.&lt;/li&gt;
&lt;li&gt;Eight concurrent processes contending on one database file.&lt;/li&gt;
&lt;li&gt;Distinct event IDs for the same payment, documenting a limit of event-ID deduplication.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Injected exceptions test transaction rollback; they are not a power-loss durability test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try the complete free sample
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://payhip.com/b/IQ39o" rel="noopener noreferrer"&gt;Download the duplicate-delivery lab&lt;/a&gt;. It includes both handlers, the helpers and schema, a demo, and all 10 tests.&lt;/p&gt;

&lt;p&gt;With Node.js 24.18 or newer, extract the ZIP and run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run demo
npm &lt;span class="nb"&gt;test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Windows, use &lt;code&gt;npm.cmd&lt;/code&gt; if PowerShell blocks &lt;code&gt;npm.ps1&lt;/code&gt;. No npm install, Stripe account, API keys, or Docker needed. Verified on Windows with Node 24.18.0.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this pattern stops
&lt;/h2&gt;

&lt;p&gt;This is an offline application-handler example. A live endpoint still needs raw-body signature verification and appropriate acknowledgment and retry behavior.&lt;/p&gt;

&lt;p&gt;Event-ID deduplication does not deduplicate different event IDs for the same business operation. Define a business key when that is the operation you need to protect.&lt;/p&gt;

&lt;p&gt;A database rollback cannot undo an email, shipment, or external API call. Those need their own durable delivery and idempotency design; this lab does not promise exactly-once external effects.&lt;/p&gt;

&lt;p&gt;Disclosure: Failure Mode Labs makes the linked sample and paid package. The free sample is complete on its own. The optional &lt;a href="https://payhip.com/b/2vGP3" rel="noopener noreferrer"&gt;US$19 Webhook Failure Lab&lt;/a&gt; adds out-of-order subscription-state reconciliation and durable acceptance before acknowledgment: three labs and 20 tests in total.&lt;/p&gt;

&lt;p&gt;Independent educational product; not endorsed by Stripe.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>webdev</category>
      <category>testing</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
