<?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: FlowPatch Reliability</title>
    <description>The latest articles on DEV Community by FlowPatch Reliability (@flowpatchreliability).</description>
    <link>https://dev.to/flowpatchreliability</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%2F4108212%2F9733991b-0f07-4324-84c4-038b879fc495.png</url>
      <title>DEV Community: FlowPatch Reliability</title>
      <link>https://dev.to/flowpatchreliability</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/flowpatchreliability"/>
    <language>en</language>
    <item>
      <title>When an n8n API timeout may already have committed</title>
      <dc:creator>FlowPatch Reliability</dc:creator>
      <pubDate>Thu, 03 Sep 2026 16:51:28 +0000</pubDate>
      <link>https://dev.to/flowpatchreliability/when-an-n8n-api-timeout-may-already-have-committed-3k6b</link>
      <guid>https://dev.to/flowpatchreliability/when-an-n8n-api-timeout-may-already-have-committed-3k6b</guid>
      <description>&lt;p&gt;An n8n HTTP Request node can fail even when the remote system has already accepted the request.&lt;/p&gt;

&lt;p&gt;That is the most dangerous kind of failure: not a clean rejection, but an &lt;strong&gt;ambiguous commit&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Suppose a workflow does this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Receive a lead webhook.&lt;/li&gt;
&lt;li&gt;POST the lead to a CRM.&lt;/li&gt;
&lt;li&gt;Wait 30 seconds.&lt;/li&gt;
&lt;li&gt;The request times out.&lt;/li&gt;
&lt;li&gt;Retry the workflow.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The retry may be correct if the CRM never received the first request. It may create a duplicate if the CRM committed the lead at second 29 but the response never reached n8n.&lt;/p&gt;

&lt;p&gt;A green retry is not proof that the workflow is correct. It can mean the same business action happened twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule that prevents most duplicates
&lt;/h2&gt;

&lt;p&gt;For every side-effecting request, generate one stable operation identity &lt;strong&gt;before the first attempt&lt;/strong&gt; and reuse it for every retry.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;operation_key = "create-lead:" + source_event_id
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not generate the key inside the retrying node. Do not use the execution attempt ID if it changes across retries. The key must identify the intended business action, not the transport attempt.&lt;/p&gt;

&lt;p&gt;If the provider supports idempotency keys, send that value in the provider's documented header. If it does not, store the key and provider result in a durable table you control.&lt;/p&gt;

&lt;p&gt;A useful state model is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;prepared -&amp;gt; attempted -&amp;gt; confirmed
                    \-&amp;gt; ambiguous -&amp;gt; reconciled
                    \-&amp;gt; terminal_failure
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ambiguous&lt;/code&gt; is a real state. Treating a timeout as a simple &lt;code&gt;failed&lt;/code&gt; state erases the most important fact: the provider may have committed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the n8n workflow should persist
&lt;/h2&gt;

&lt;p&gt;Before calling the provider, persist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;stable operation key;&lt;/li&gt;
&lt;li&gt;source event ID;&lt;/li&gt;
&lt;li&gt;request fingerprint;&lt;/li&gt;
&lt;li&gt;provider name and endpoint;&lt;/li&gt;
&lt;li&gt;state = &lt;code&gt;prepared&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After sending, record the attempt. On a definite success, store the provider object ID and mark &lt;code&gt;confirmed&lt;/code&gt;. On a definite rejection, record the safe-to-retry or terminal result.&lt;/p&gt;

&lt;p&gt;On a timeout, broken connection, or malformed response, mark &lt;code&gt;ambiguous&lt;/code&gt;. Do not immediately create a new business operation.&lt;/p&gt;

&lt;p&gt;The next step should reconcile:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Look up the operation by provider idempotency key, external reference, or request fingerprint.&lt;/li&gt;
&lt;li&gt;If the provider object exists, store its ID and mark &lt;code&gt;confirmed&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;If it definitely does not exist, retry with the &lt;strong&gt;same&lt;/strong&gt; operation key.&lt;/li&gt;
&lt;li&gt;If the provider cannot answer conclusively, route to bounded delayed retry or operator review.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Webhook callbacks do not remove the need for state
&lt;/h2&gt;

&lt;p&gt;A common design is "POST the job, then wait for the callback." That still has two failure windows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The provider accepts the job, but the initial response is lost.&lt;/li&gt;
&lt;li&gt;The provider sends the callback, but your workflow crashes after the side effect and before durable acknowledgement.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Webhook handlers should durably accept first, deduplicate on the provider event ID, and make downstream side effects replay-safe. Returning &lt;code&gt;200&lt;/code&gt; before durable acceptance can lose an event. Returning an error after the side effect can invite a duplicate delivery.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five failure-path tests worth running
&lt;/h2&gt;

&lt;p&gt;The happy path is only one test. Add these:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Timeout after provider commit&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Simulate a committed object with a lost response. Verify the next run reconciles instead of creating a second object.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Duplicate source webhook&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Deliver the same source event twice. Verify one business operation and one stored provider ID.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Crash after side effect&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Stop execution after the provider accepts but before the workflow records success. Verify replay uses the same operation key.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Callback before listener registration&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Deliver the provider callback immediately. Verify it is durably stored even if the waiting execution has not registered its in-memory listener yet.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Permanent 4xx&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Return an authentication or validation error. Verify it does not enter the same retry loop as a transient 429 or 503.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  A quick boundary review
&lt;/h2&gt;

&lt;p&gt;For one n8n-to-provider boundary, write down four facts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;provider/API;&lt;/li&gt;
&lt;li&gt;trigger;&lt;/li&gt;
&lt;li&gt;observed failure;&lt;/li&gt;
&lt;li&gt;current retry behavior.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those four facts are usually enough to define the first failure map: where identity is created, what can commit, which outcomes are ambiguous, and how reconciliation should work.&lt;/p&gt;

&lt;p&gt;I offer a fixed &lt;strong&gt;$99 n8n/API failure-path diagnostic&lt;/strong&gt; for one boundary. It includes a boundary-specific risk matrix, retry and state rules, patch plan, failure-path tests, and an implementation estimate. Delivery is within two business days after written scope and cleared ACH; the fee is credited toward a $299 implementation patch purchased within 14 days.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://flowpatch-reliability.github.io/flowpatch/sample.html" rel="noopener noreferrer"&gt;View a sample diagnostic&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://community.n8n.io/t/for-hire-99-n8n-api-failure-path-diagnostic/310997" rel="noopener noreferrer"&gt;See the n8n Community Jobs listing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Contact: &lt;a href="mailto:flowpatchpilot@proton.me"&gt;flowpatchpilot@proton.me&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The service is independent and is not affiliated with n8n.&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>webhooks</category>
      <category>api</category>
      <category>devops</category>
    </item>
    <item>
      <title>The crash window that turns webhook retries into duplicate side effects</title>
      <dc:creator>FlowPatch Reliability</dc:creator>
      <pubDate>Thu, 03 Sep 2026 14:27:08 +0000</pubDate>
      <link>https://dev.to/flowpatchreliability/the-crash-window-that-turns-webhook-retries-into-duplicate-side-effects-mee</link>
      <guid>https://dev.to/flowpatchreliability/the-crash-window-that-turns-webhook-retries-into-duplicate-side-effects-mee</guid>
      <description>&lt;p&gt;A webhook handler can return &lt;code&gt;200 OK&lt;/code&gt;, log "processed", and still create the same charge, email, or entitlement twice.&lt;/p&gt;

&lt;p&gt;The bug usually lives in a small crash window between &lt;strong&gt;acceptance&lt;/strong&gt; and &lt;strong&gt;completion&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A provider only knows whether your endpoint accepted a delivery. It does not know whether your business operation completed exactly once. If the connection drops, your process crashes, or the response times out, the provider may retry. That retry is correct behavior. The duplicate side effect is our bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dangerous sequence
&lt;/h2&gt;

&lt;p&gt;A common handler looks harmless:&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleWebhook&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;ProviderEvent&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;seen&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&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;id&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;seen&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;sendWelcomeEmail&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;customerId&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;done&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now place a crash after &lt;code&gt;sendWelcomeEmail&lt;/code&gt; succeeds but before the insert commits.&lt;/p&gt;

&lt;p&gt;The provider sees no successful response and retries. The database still says the event is new, so the handler sends a second email. Replace email with &lt;code&gt;createShipment&lt;/code&gt;, &lt;code&gt;issueRefund&lt;/code&gt;, or &lt;code&gt;grantCredits&lt;/code&gt; and the incident becomes expensive.&lt;/p&gt;

&lt;p&gt;This is also why a check-then-insert guard is not enough. Two workers can receive the same event at nearly the same time. Both check, both see nothing, and both execute the effect before either inserts the marker.&lt;/p&gt;

&lt;h2&gt;
  
  
  Acceptance is not completion
&lt;/h2&gt;

&lt;p&gt;Separate the inbound transport decision from the business-work decision.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Verify the signature and parse the event.&lt;/li&gt;
&lt;li&gt;Atomically record the provider event ID under a unique constraint.&lt;/li&gt;
&lt;li&gt;Return a success response once durable acceptance is complete.&lt;/li&gt;
&lt;li&gt;Process the durable record asynchronously.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The unique insert decides which delivery owns the work. A duplicate delivery becomes a no-op at the database boundary rather than after an external side effect.&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;acceptWebhook&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;Buffer&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="nx"&gt;Headers&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;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;verifyAndParse&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;headers&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;inserted&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;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="k"&gt;async&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertIfAbsent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;provider&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&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;eventId&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;payload&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="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;attempts&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="p"&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="na"&gt;status&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="na"&gt;duplicate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;inserted&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;The database needs a unique key such as &lt;code&gt;(provider, event_id)&lt;/code&gt;. Application-level checks without a constraint still race.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make completion durable too
&lt;/h2&gt;

&lt;p&gt;The worker processing the inbox record needs explicit states. &lt;code&gt;pending&lt;/code&gt;, &lt;code&gt;leased&lt;/code&gt;, &lt;code&gt;completed&lt;/code&gt;, and &lt;code&gt;terminal_failure&lt;/code&gt; are more useful than a single boolean.&lt;/p&gt;

&lt;p&gt;A lease lets a crashed worker's job become eligible again. Store &lt;code&gt;lease_until&lt;/code&gt;, increment &lt;code&gt;attempts&lt;/code&gt;, and let another worker claim the row only after the lease expires. Do not hold a database transaction open while calling a remote provider.&lt;/p&gt;

&lt;p&gt;If processing must update local state and enqueue another action, use a transactional outbox:&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;await&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;transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accounts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;applyEntitlement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;plan&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;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;outbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;send_receipt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`receipt:&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;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;plan&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;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;markCompleted&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;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;The local state change, outbox entry, and completion marker now commit together. A separate dispatcher sends the receipt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stable idempotency keys cross the final boundary
&lt;/h2&gt;

&lt;p&gt;An outbox makes local intent durable, but the dispatcher can still crash after the remote provider accepts a request and before local completion is recorded.&lt;/p&gt;

&lt;p&gt;When the remote API supports idempotency, send a stable key derived from the logical operation, not from the attempt:&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;await&lt;/span&gt; &lt;span class="nx"&gt;emailProvider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;outbox&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every retry of the same logical operation must reuse the same key. A random key per attempt defeats the mechanism.&lt;/p&gt;

&lt;p&gt;When the remote API does not support idempotency, classify the operation honestly. Some actions are naturally safe to repeat. Some can be reconciled by querying the remote system. Some remain ambiguous and require a manual review state instead of blind retries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retry classification matters
&lt;/h2&gt;

&lt;p&gt;A retry policy should distinguish:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;transient failures: timeouts, connection resets, &lt;code&gt;429&lt;/code&gt;, and selected &lt;code&gt;5xx&lt;/code&gt; responses;&lt;/li&gt;
&lt;li&gt;permanent failures: invalid payloads, authentication failures, and most &lt;code&gt;4xx&lt;/code&gt; responses;&lt;/li&gt;
&lt;li&gt;ambiguous outcomes: the request may have succeeded, but the response was lost.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use bounded exponential backoff with jitter. Honor &lt;code&gt;Retry-After&lt;/code&gt;. After the attempt budget is exhausted, move the operation to a visible terminal state with enough context to investigate. Infinite retry is not resilience; it is a quiet denial-of-service against your own queue and the provider.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reconciliation closes the gap
&lt;/h2&gt;

&lt;p&gt;Even a strong design needs a repair loop. Periodically compare local records with the provider's authoritative state. Find accepted events stuck in &lt;code&gt;leased&lt;/code&gt;, outbox records with ambiguous outcomes, and local entitlements that disagree with payment state.&lt;/p&gt;

&lt;p&gt;Reconciliation turns an invisible inconsistency into a queued repair or an explicit alert.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the crash points, not only the happy path
&lt;/h2&gt;

&lt;p&gt;A useful failure-path suite kills the process at each boundary:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;before the inbox insert;&lt;/li&gt;
&lt;li&gt;after the insert but before the response;&lt;/li&gt;
&lt;li&gt;after claiming work but before the side effect;&lt;/li&gt;
&lt;li&gt;after the side effect but before completion is recorded;&lt;/li&gt;
&lt;li&gt;during outbox dispatch;&lt;/li&gt;
&lt;li&gt;while two workers race on the same event.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then replay the same provider event and assert the business invariant: one shipment, one entitlement transition, one refund, or one message.&lt;/p&gt;

&lt;p&gt;Exactly-once delivery is rarely available end to end. What you can build is durable acceptance, atomic local transitions, stable idempotency at remote boundaries, bounded retries, and reconciliation. That combination makes at-least-once delivery safe enough for real systems.&lt;/p&gt;

&lt;h2&gt;
  
  
  A concrete implementation example
&lt;/h2&gt;

&lt;p&gt;I applied the same contract in &lt;a href="https://github.com/Posnic/POS/pull/476" rel="noopener noreferrer"&gt;Posnic/POS PR #476&lt;/a&gt;, a focused webhook retry and dead-letter contribution now under maintainer review. I also isolated a separate &lt;a href="https://github.com/activepieces/activepieces/issues/13334#issuecomment-5528220420" rel="noopener noreferrer"&gt;lost sync-response race in Activepieces&lt;/a&gt;: a fast worker can publish before the API registers its listener, causing a false timeout after a successful run. The patch adds deterministic tests for timeout, &lt;code&gt;400&lt;/code&gt;, &lt;code&gt;500&lt;/code&gt;, success after retry, and permanent failure. It also verifies that retries reuse the same delivery ID, stop at a fixed attempt budget, persist only safe error categories, and strip shop/time details from dead-letter records.&lt;/p&gt;

&lt;p&gt;That distinction is worth testing explicitly: a transport timeout and a &lt;code&gt;500&lt;/code&gt; are candidates for bounded retry; most &lt;code&gt;4xx&lt;/code&gt; responses are permanent until the request changes. Treating them all alike either drops recoverable work or hammers a request that can never succeed.&lt;/p&gt;

&lt;p&gt;Use this &lt;a href="https://flowpatch-reliability.github.io/flowpatch/checklist.html" rel="noopener noreferrer"&gt;7-question preflight checklist&lt;/a&gt; to review an integration. If one provider boundary is already failing, FlowPatch offers a &lt;a href="https://flowpatch-reliability.github.io/flowpatch/" rel="noopener noreferrer"&gt;$99 fixed-scope failure-path diagnostic&lt;/a&gt; with a risk matrix, retry/state rules, patch plan, and failure-path tests.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>typescript</category>
      <category>backend</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
