<?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: MangoOTP</title>
    <description>The latest articles on DEV Community by MangoOTP (@ka_ka_67383071899429c95bc).</description>
    <link>https://dev.to/ka_ka_67383071899429c95bc</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%2F4123704%2Fba5aef50-ede8-4da8-973b-f922170d4f29.png</url>
      <title>DEV Community: MangoOTP</title>
      <link>https://dev.to/ka_ka_67383071899429c95bc</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ka_ka_67383071899429c95bc"/>
    <language>en</language>
    <item>
      <title>Handling Duplicate Webhooks Without Duplicating Work</title>
      <dc:creator>MangoOTP</dc:creator>
      <pubDate>Mon, 14 Sep 2026 00:59:37 +0000</pubDate>
      <link>https://dev.to/ka_ka_67383071899429c95bc/handling-duplicate-webhooks-without-duplicating-work-i0d</link>
      <guid>https://dev.to/ka_ka_67383071899429c95bc/handling-duplicate-webhooks-without-duplicating-work-i0d</guid>
      <description>&lt;p&gt;A webhook sender can deliver an event again when it does not receive a successful response. If the receiver has already committed its work, that retry must not repeat the same business operation.&lt;/p&gt;

&lt;p&gt;Stripe explicitly documents duplicate deliveries and does not guarantee event ordering. Those are useful failure cases to design for, even when integrating a different provider; check that provider's own delivery contract. &lt;a href="https://docs.stripe.com/webhooks#handle-duplicate-events" rel="noopener noreferrer"&gt;Source: Stripe webhook documentation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This walkthrough uses a fictional message-receiving service. The SQL illustrates a PostgreSQL design; the handler is pseudocode, not a drop-in SDK integration or a report of production results.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Define what counts as a duplicate
&lt;/h2&gt;

&lt;p&gt;Separate three identifiers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Delivery attempt:&lt;/strong&gt; one HTTP request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Event ID:&lt;/strong&gt; the logical event being delivered.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Business object ID:&lt;/strong&gt; the message, order, or other resource the event describes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A retry should resolve to the same event key. For this example, use &lt;code&gt;(source, account_id, event_id)&lt;/code&gt;, where &lt;code&gt;source&lt;/code&gt; also distinguishes test and production environments.&lt;/p&gt;

&lt;p&gt;Derive the account scope from trusted endpoint configuration or authenticated event context. Validate required identifiers before processing. Verify authenticity using the provider's documented mechanism; a plausible-looking event ID is not authentication.&lt;/p&gt;

&lt;p&gt;Avoid deduplicating by message text or receipt time. Two legitimate messages can contain identical text. Conversely, separate event IDs may describe one business operation, which needs its own domain constraint.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Let the database arbitrate concurrent requests
&lt;/h2&gt;

&lt;p&gt;This application-level sequence has a race:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if event is not in processed_events:
    apply_business_change()
    remember_event()
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two workers can both pass the first check. A unique database key gives them a shared point of coordination.&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_webhook_events&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;account_id&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;event_id&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;processed_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="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="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_id&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;Attempt the insert inside the transaction that will apply the business change:&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_webhook_events&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;account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_id&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="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;3&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="k"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_id&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_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, &lt;code&gt;$1&lt;/code&gt;–&lt;code&gt;$3&lt;/code&gt; are bound parameters. An inserted row is returned; a conflicting row skipped by &lt;code&gt;DO NOTHING&lt;/code&gt; is not. &lt;a href="https://www.postgresql.org/docs/current/sql-insert.html" rel="noopener noreferrer"&gt;Source: PostgreSQL INSERT documentation&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Commit the receipt and business change together
&lt;/h2&gt;

&lt;p&gt;For a short operation entirely within one database, the handler can follow this structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;verify authenticity and validate the supported event

begin transaction on one database connection
try:
    inserted = insert event key, returning event_id

    if inserted:
        apply the local business change
        fail if required business preconditions are not met

    commit
catch:
    roll back
    return a failure response appropriate to the provider contract

return a success response accepted by the provider
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The marker must not be committed separately before the business change. Otherwise, a crash between them leaves an event marked as processed with its work missing.&lt;/p&gt;

&lt;p&gt;Conversely, committing the business change first and remembering the event later permits duplicate work after a crash.&lt;/p&gt;

&lt;p&gt;In the single-transaction design, a rollback removes both changes. After a successful commit, a lost HTTP response can cause another delivery, but the event key prevents this handler from reapplying that event. Commit uncertainty must also be treated as retryable, with the key resolving what happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Keep external side effects outside that guarantee
&lt;/h2&gt;

&lt;p&gt;A database rollback cannot undo an email or an HTTP call.&lt;/p&gt;

&lt;p&gt;When processing needs an external action, write an &lt;strong&gt;outbox record&lt;/strong&gt; alongside the local business change in the same transaction. A worker sends it afterward. The worker still needs retry handling: a crash after sending but before recording success can cause another send. Use a stable operation key and downstream idempotency where supported. &lt;a href="https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/transactional-outbox.html" rel="noopener noreferrer"&gt;Source: AWS transactional outbox guidance&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For slow processing, durably accept the event into an inbox or queue before acknowledging it, then process asynchronously with explicit retry and recovery states. An in-memory background task alone is not durable acceptance.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Test the failure boundaries
&lt;/h2&gt;

&lt;p&gt;Use these as test cases for an implementation, not as claimed results of this illustrative example:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Expected behavior&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Two workers receive the same event concurrently&lt;/td&gt;
&lt;td&gt;One committed local business application&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Crash before transaction commit&lt;/td&gt;
&lt;td&gt;Neither the marker nor business change persists&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Commit succeeds, but the response is lost&lt;/td&gt;
&lt;td&gt;Redelivery does not repeat the change&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two different events contain identical message text&lt;/td&gt;
&lt;td&gt;Both remain eligible for processing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An older state update arrives later&lt;/td&gt;
&lt;td&gt;Domain transition or version rules prevent invalid regression&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Outbox worker crashes after sending&lt;/td&gt;
&lt;td&gt;Stable downstream operation key limits duplicate effects&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Deduplication does not solve event ordering. Apply explicit state transitions or a provider-defined version check where available. Also choose a retention period deliberately: deleting event keys removes protection against sufficiently old replays.&lt;/p&gt;

&lt;p&gt;The useful guarantee is specific: one retained event key gates one committed local database operation. Extending that guarantee across queues, services, or external APIs requires additional design.&lt;/p&gt;




</description>
      <category>webdev</category>
      <category>postgres</category>
    </item>
  </channel>
</rss>
