<?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: FetchSandbox</title>
    <description>The latest articles on DEV Community by FetchSandbox (@fetchsandbox).</description>
    <link>https://dev.to/fetchsandbox</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%2F3830177%2Fe63c5a3c-3f48-4ae2-ad6b-ed510712a396.png</url>
      <title>DEV Community: FetchSandbox</title>
      <link>https://dev.to/fetchsandbox</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/fetchsandbox"/>
    <language>en</language>
    <item>
      <title>undefined === undefined — the auth bypass your AI wrote into your checkout</title>
      <dc:creator>FetchSandbox</dc:creator>
      <pubDate>Thu, 13 Aug 2026 17:40:08 +0000</pubDate>
      <link>https://dev.to/fetchsandbox/undefined-undefined-the-auth-bypass-your-ai-wrote-into-your-checkout-4j9g</link>
      <guid>https://dev.to/fetchsandbox/undefined-undefined-the-auth-bypass-your-ai-wrote-into-your-checkout-4j9g</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An auth check that uses &lt;code&gt;===&lt;/code&gt; where both sides can be &lt;code&gt;undefined&lt;/code&gt; is fail-open — a missing config turns it from locked to open.&lt;/li&gt;
&lt;li&gt;The one secret missing from &lt;code&gt;.env.example&lt;/code&gt; was the internal auth token. Unset in a deploy, &lt;code&gt;undefined === undefined&lt;/code&gt; let every anonymous request through.&lt;/li&gt;
&lt;li&gt;Default to deny: validate types, reject empty, compare in constant time, and write the test that sends no credentials with the env var unset.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;The coding agent handed me an auth middleware. It passed review. I almost shipped 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;function&lt;/span&gt; &lt;span class="nf"&gt;protect&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="nx"&gt;next&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;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="s1"&gt;x-internal-token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&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;INTERNAL_API_TOKEN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;requireAuth&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="nx"&gt;next&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;Four lines, and it reads fine. An internal service calls with a shared token and skips the login flow; everyone else falls through to real auth. Then I checked what happens when the token isn't set.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the check actually did
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;INTERNAL_API_TOKEN&lt;/code&gt; was the one secret missing from &lt;code&gt;.env.example&lt;/code&gt;. Every other key was there — Stripe, Clerk, Paddle — but not this one. So on a deploy where nobody thought to set it, &lt;code&gt;process.env.INTERNAL_API_TOKEN&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Now a normal browser request comes in. It doesn't send an &lt;code&gt;x-internal-token&lt;/code&gt; header, so &lt;code&gt;req.headers['x-internal-token']&lt;/code&gt; is also &lt;code&gt;undefined&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;undefined === undefined&lt;/code&gt; → &lt;code&gt;true&lt;/code&gt; → &lt;code&gt;return next()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Auth bypassed. Every anonymous request to &lt;code&gt;/checkout&lt;/code&gt; is treated as a trusted internal call. The endpoint is &lt;em&gt;safest when the token is set and wide open when it's missing&lt;/em&gt; — which is exactly backwards. A misconfigured auth check should get &lt;strong&gt;more&lt;/strong&gt; restrictive, not less.&lt;/p&gt;

&lt;p&gt;And there's a quieter problem even when the token &lt;em&gt;is&lt;/em&gt; set: &lt;code&gt;===&lt;/code&gt; on a secret isn't constant-time. It short-circuits on the first mismatched byte, so response timing leaks the token one character at a time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real check
&lt;/h2&gt;

&lt;p&gt;Fail closed:&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;crypto&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hasInternalToken&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&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;INTERNAL_API_TOKEN&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="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// no token configured → deny&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;provided&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-internal-token&lt;/span&gt;&lt;span class="dl"&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="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;provided&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;provided&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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="kc"&gt;false&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;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;provided&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;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expected&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;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;b&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="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&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;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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;protect&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="nx"&gt;next&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="nf"&gt;hasInternalToken&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="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;requireAuth&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="nx"&gt;next&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;No configured token means no bypass. An empty or missing header means no bypass. The compare is constant-time, and length is checked first (&lt;code&gt;timingSafeEqual&lt;/code&gt; throws on length mismatch). Then add &lt;code&gt;INTERNAL_API_TOKEN&lt;/code&gt; to &lt;code&gt;.env.example&lt;/code&gt; so it can never be silently omitted again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why agents write it this way
&lt;/h2&gt;

&lt;p&gt;The happy path looks correct. Token set, caller sends the right one, &lt;code&gt;x === y&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt; — review sees a plausible auth check and moves on. The model optimizes for "works when configured," and &lt;code&gt;===&lt;/code&gt; fails open by default. It never had to reason about the unset case, because the unset case isn't in the example the code was written against.&lt;/p&gt;

&lt;p&gt;Humans do this too — the fail-open default, the &lt;code&gt;===&lt;/code&gt; on a secret, the config that's "obviously" always set. Agents just do it faster and ship it greener.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fail closed, and prove it
&lt;/h2&gt;

&lt;p&gt;The tell isn't in the code. It's in the test you didn't write. Send a request with &lt;strong&gt;no credentials&lt;/strong&gt; and the env var &lt;strong&gt;unset&lt;/strong&gt;, and assert it's rejected:&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;delete&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;INTERNAL_API_TOKEN&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;res&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;request&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="s1"&gt;/checkout&lt;/span&gt;&lt;span class="dl"&gt;'&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="na"&gt;seats&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nf"&gt;expect&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="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;not&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBe&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="c1"&gt;// must be 401/302 — not "welcome in"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If an unauthenticated request with no token configured gets a &lt;code&gt;200&lt;/code&gt;, your auth is open — and no amount of green on the happy path will tell you. The bug lives entirely in the case the tests never exercise.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed in the habit
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Auth defaults to deny.&lt;/strong&gt; Every branch that grants access starts from "no."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate both sides&lt;/strong&gt; — type is string, length &amp;gt; 0. &lt;code&gt;undefined === undefined&lt;/code&gt; is not authentication.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Constant-time compare&lt;/strong&gt; for any secret (&lt;code&gt;crypto.timingSafeEqual&lt;/code&gt;), never &lt;code&gt;===&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the misconfigured-env test&lt;/strong&gt; — unset the token, send nothing, assert rejected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every secret the code reads goes in &lt;code&gt;.env.example&lt;/code&gt;.&lt;/strong&gt; A missing one isn't a config gap; here it was the whole lock.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I found this the way I find most of them: reproduced it on the real code — an anonymous request returned &lt;code&gt;200&lt;/code&gt; — applied the fix, and confirmed it flipped to a &lt;code&gt;302&lt;/code&gt;, with FetchSandbox. The bug was never in the happy path. It was in the case the code never had to handle.&lt;/p&gt;

&lt;p&gt;Which is the rule every auth check should start from: &lt;strong&gt;fail closed.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>node</category>
      <category>webdev</category>
      <category>ai</category>
    </item>
    <item>
      <title>Green passed. The fix granted zero seats.</title>
      <dc:creator>FetchSandbox</dc:creator>
      <pubDate>Tue, 11 Aug 2026 14:05:17 +0000</pubDate>
      <link>https://dev.to/fetchsandbox/green-passed-the-fix-granted-zero-seats-3n4m</link>
      <guid>https://dev.to/fetchsandbox/green-passed-the-fix-granted-zero-seats-3n4m</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Did the bug stop?" is a weak invariant. An over-suppressing fix — grant nothing — also stops the bug.&lt;/li&gt;
&lt;li&gt;Assert the exact correct end state: after N identical deliveries, seats == the purchased amount. Not more. Not zero.&lt;/li&gt;
&lt;li&gt;A verifier that cannot reject a deliberately broken fix is not a verifier. It is a green light.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;The coding agent handed me a patch. CI went green. I almost merged it.&lt;/p&gt;

&lt;p&gt;The original bug was ugly and familiar. A Stripe webhook handler granted seats on &lt;code&gt;invoice.paid&lt;/code&gt; (or whatever your equivalent is — &lt;code&gt;checkout.session.completed&lt;/code&gt;, &lt;code&gt;customer.subscription.updated&lt;/code&gt;, pick your poison). It was not idempotent. Stripe retried. The handler granted again.&lt;/p&gt;

&lt;p&gt;A five-seat purchase became 5, then 10, then 15. Same event. Same customer. Three deliveries.&lt;/p&gt;

&lt;p&gt;That is the bug everyone warns you about. At-least-once delivery. Dedupe on event id. &lt;code&gt;INSERT … ON CONFLICT DO NOTHING&lt;/code&gt;. You have heard the sermon.&lt;/p&gt;

&lt;p&gt;So I asked the agent to fix it. It wrote a patch. My verifier ran the retry series and graded the result &lt;strong&gt;green&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Then I looked at the seats.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;delivery     buggy handler     agent "fix"      what we wanted
1            5                 0                5
2            10                0                5
3            15                0                5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The count had stopped growing. True. It had also stopped granting.&lt;/p&gt;

&lt;p&gt;Customers who paid got nothing. Including on the first, legitimate delivery. The bug was "fixed" the way you fix a leaky pipe by shutting off the water main.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the check actually asserted
&lt;/h2&gt;

&lt;p&gt;The verifier was not stupid in a cartoon way. It did replay the webhook. It did look at a side effect. It did not just grep the diff for &lt;code&gt;idempotent&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It asserted the thing I had complained about: &lt;em&gt;the count should not keep climbing&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Something like 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="c1"&gt;// Weak: "the bug stopped"&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;series&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;seatsAfterEachDelivery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// e.g. [0, 0, 0]&lt;/span&gt;

&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;series&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="nf"&gt;toBeLessThanOrEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;series&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="nf"&gt;expect&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;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;series&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "stable across retries"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;[5, 10, 15]&lt;/code&gt; fails. Good.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;[5, 5, 5]&lt;/code&gt; passes. Also good.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;[0, 0, 0]&lt;/code&gt; passes. That is the hole.&lt;/p&gt;

&lt;p&gt;Any monotonicity check, any "didn't grow," any "delta is zero after the first call" that never pins the &lt;em&gt;value&lt;/em&gt; will accept the over-suppressing patch. Delete the grant. Swallow the event. Return 200 and write nothing. The graph is flat. Green.&lt;/p&gt;

&lt;p&gt;If you have ever written &lt;code&gt;expect(errors).toHaveLength(0)&lt;/code&gt; and then watched someone delete the code that could error, you have met this family.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real invariant
&lt;/h2&gt;

&lt;p&gt;Idempotency is not "nothing happens twice." It is "the same request leaves the system in the same correct state."&lt;/p&gt;

&lt;p&gt;For this handler, after N identical deliveries of a five-seat purchase, seats must equal &lt;strong&gt;exactly five&lt;/strong&gt;. Not fifteen. Not zero.&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="c1"&gt;// Exact: after N identical deliveries, seats == purchased amount&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;series&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;seatsAfterEachDelivery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;series&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one line rejects &lt;code&gt;[5, 10, 15]&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; &lt;code&gt;[0, 0, 0]&lt;/code&gt;. It accepts &lt;code&gt;[5, 5, 5]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;When I switched the assert, the zero-grant patch went red. The agent tried again. The patch that actually deduped — grant once, ignore the retries — produced &lt;code&gt;[5, 5, 5]&lt;/code&gt; and stayed green.&lt;/p&gt;

&lt;p&gt;Same deliveries. Same metric. Different question.&lt;/p&gt;

&lt;p&gt;"Did it stop doing the bad thing?" vs "Is the world in the state a correct implementation would leave it in?"&lt;/p&gt;

&lt;h2&gt;
  
  
  Why agents fail this way
&lt;/h2&gt;

&lt;p&gt;I do not think the model was being clever. I think it was being literal.&lt;/p&gt;

&lt;p&gt;You said: stop double-granting. The shortest path to "the number does not increase" is "the number never moves." Skip the write. Make the insert always look like a conflict. Short-circuit before &lt;code&gt;grantSeats&lt;/code&gt;. Plenty of ways to get a flat series.&lt;/p&gt;

&lt;p&gt;Humans do this too under time pressure. Feature flags that default off. &lt;code&gt;if (false)&lt;/code&gt; around the dangerous block. Tests that mock the collaborator into a no-op. Agents just do it faster, and they will happily stop at the first green.&lt;/p&gt;

&lt;p&gt;Your CI is an optimization target. If the loss function is "bug symptom gone," over-suppression is a local minimum. If the loss function is "exact end state," that minimum disappears.&lt;/p&gt;

&lt;p&gt;This is not a Stripe trivia item. It is any side-effecting handler you let an agent touch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A refund webhook that "fixes" double-refunds by refunding &lt;code&gt;$0&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A provisioner that "fixes" duplicate users by creating none.&lt;/li&gt;
&lt;li&gt;A retry queue that "fixes" duplicate emails by sending nothing, including the first time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The shape is always the same. Symptom: too much. Naive fix: zero. Correct fix: once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Positive control, negative control
&lt;/h2&gt;

&lt;p&gt;Here is the part I now require before I trust a verifier — mine, yours, an agent's, a CI job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Positive control.&lt;/strong&gt; A known-good implementation must pass. For me that was the idempotent grant: &lt;code&gt;[5, 5, 5]&lt;/code&gt;. If your exact-count assert fails on a patch you already believe is right, the assert is wrong, not the patch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Negative control.&lt;/strong&gt; A known-bad implementation must fail. I keep a deliberately broken patch around: the zero-grant one, or the original double-grant, or both.&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="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;seatsAfterEachDelivery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buggy&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="c1"&gt;// fail: [5, 10, 15]&lt;/span&gt;

&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;seatsAfterEachDelivery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;zeroGrant&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="c1"&gt;// fail: [0, 0, 0]   ← this is the control most suites skip&lt;/span&gt;

&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;seatsAfterEachDelivery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;idempotent&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="c1"&gt;// pass&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the zero-grant still greens, you do not have a proof. You have a check that the symptom you first noticed got quieter.&lt;/p&gt;

&lt;p&gt;I used to stop at the positive control. "The good fix passes, ship it." That is how &lt;code&gt;[0, 0, 0]&lt;/code&gt; got a green. The suite had never been shown a lying patch.&lt;/p&gt;

&lt;p&gt;This is the same idea as a mutation test, just less academic. You do not need a framework. You need one broken cousin of the fix, and a gate that refuses to call itself a verifier until that cousin fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed in the habit
&lt;/h2&gt;

&lt;p&gt;I still replay the webhook three times. That part was never the mistake. The mistake was scoring the replay with a bound instead of an equality.&lt;/p&gt;

&lt;p&gt;The checklist I use now, when an agent "fixes" a production-shaped bug:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Write the end state in English first. "After three identical &lt;code&gt;invoice.paid&lt;/code&gt; deliveries for a 5-seat price, &lt;code&gt;seat_limit == 5&lt;/code&gt;."&lt;/li&gt;
&lt;li&gt;Put that number in the assert. Not &lt;code&gt;&amp;lt;=&lt;/code&gt;. Not "unchanged after first." The number.&lt;/li&gt;
&lt;li&gt;Run the original bug. It must fail that assert.&lt;/li&gt;
&lt;li&gt;Run a spiteful fix (no-op, always-conflict, grant-zero). It must fail too.&lt;/li&gt;
&lt;li&gt;Then run the agent's patch.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 4 is the one I had skipped. It is also the cheapest. You can write the no-op in thirty seconds. If your suite cannot fail it, do not let the suite pass the agent.&lt;/p&gt;

&lt;p&gt;I hit this while building FetchSandbox, replaying Stripe deliveries against persistent seat state instead of a one-shot fixture.&lt;/p&gt;

&lt;p&gt;The transferable rule is not about Stripe, and it is not about my tooling. When you verify an AI-written fix, "did the bug stop?" is the wrong question. Ask whether the system is in the exact state a correct implementation would leave it in — and prove your question can still say no.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>testing</category>
      <category>webdev</category>
      <category>stripe</category>
    </item>
    <item>
      <title>You can't code-review a webhook retry</title>
      <dc:creator>FetchSandbox</dc:creator>
      <pubDate>Tue, 04 Aug 2026 17:51:20 +0000</pubDate>
      <link>https://dev.to/fetchsandbox/you-cant-code-review-a-webhook-retry-1gkk</link>
      <guid>https://dev.to/fetchsandbox/you-cant-code-review-a-webhook-retry-1gkk</guid>
      <description>&lt;p&gt;My coding agent wrote a Stripe webhook handler a few weeks ago. Signature check, event type check, fulfillment call, clean 200. The diff read like something I'd write on a good day. I approved it in about ninety seconds.&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="k"&gt;async &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="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;verifySignature&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="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;charge.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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;creditCustomer&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;span class="nx"&gt;res&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every line is correct. And the first time Stripe delivers that event twice — which it's allowed to do, and eventually will — the handler credits the customer twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading can't catch it
&lt;/h2&gt;

&lt;p&gt;The bug isn't on any line, so no amount of reading finds it. Each statement is fine on its own. What's wrong is an assumption living between the lines: that every event arrives exactly once. Stripe promises &lt;em&gt;at-least-once&lt;/em&gt; delivery. Retries and duplicates are documented, normal behavior.&lt;/p&gt;

&lt;p&gt;Code review checks the diff against the traffic you can imagine. I imagined one clean &lt;code&gt;charge.succeeded&lt;/code&gt;. So did the agent — the quickstart code it learned from imagined one too.&lt;/p&gt;

&lt;h2&gt;
  
  
  CI passed for the same reason
&lt;/h2&gt;

&lt;p&gt;My tests replayed the events I thought of. The fixture file had exactly one copy of each event, because I wrote the fixture file. Nobody writes the test where the same event lands twice nine minutes apart. The suite was my assumptions checking my assumptions.&lt;/p&gt;

&lt;p&gt;I spent three years on the developer platform of a very large payments company. This exact bug got past good reviewers more times than I can count. It was never a talent problem. Reading is the wrong tool for this class of bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The review that actually works
&lt;/h2&gt;

&lt;p&gt;What caught it was running the failure. Deliver the event, deliver it again, watch the customer get credited twice. Add the dedupe on the event ID, run it again, watch it hold. Ten minutes, and the review went from "looks right" to "proved right."&lt;/p&gt;

&lt;p&gt;That loop — reproduce the failure, fix it, rerun, keep the receipt — is what I built &lt;a href="https://fetchsandbox.com" rel="noopener noreferrer"&gt;FetchSandbox&lt;/a&gt; to run from your IDE over MCP. The sandbox fires the real charge lifecycle, including the duplicate delivery your fixture file doesn't have, so your agent proves the handler before your customers do.&lt;/p&gt;

&lt;p&gt;If you'd rather judge it on real code than my word, there's a webhook-dedupe bug planted in a Stripe app in our open playground, waiting to be caught: &lt;a href="https://github.com/fetchsandbox/playground" rel="noopener noreferrer"&gt;github.com/fetchsandbox/playground&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>stripe</category>
      <category>ai</category>
      <category>testing</category>
    </item>
    <item>
      <title>Why Paddle's subscription.activated arrives before subscription.created</title>
      <dc:creator>FetchSandbox</dc:creator>
      <pubDate>Thu, 30 Jul 2026 13:58:08 +0000</pubDate>
      <link>https://dev.to/fetchsandbox/why-paddles-subscriptionactivated-arrives-before-subscriptioncreated-dc0</link>
      <guid>https://dev.to/fetchsandbox/why-paddles-subscriptionactivated-arrives-before-subscriptioncreated-dc0</guid>
      <description>&lt;p&gt;&lt;em&gt;You'd think events fire in the order things happen. They don't.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I was building a Paddle integration last week. Subscription billing, nothing fancy. Customer clicks buy, Paddle handles checkout, my app gets webhooks and updates the database.&lt;/p&gt;

&lt;p&gt;The flow should be simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Customer completes checkout&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;subscription.created&lt;/code&gt; fires&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;subscription.activated&lt;/code&gt; fires&lt;/li&gt;
&lt;li&gt;My app inserts a row on &lt;code&gt;.created&lt;/code&gt;, updates status on &lt;code&gt;.activated&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's what I built. It worked great in my head.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happened
&lt;/h2&gt;

&lt;p&gt;In production, &lt;code&gt;subscription.activated&lt;/code&gt; arrived &lt;em&gt;before&lt;/em&gt; &lt;code&gt;subscription.created&lt;/code&gt; about 30% of the time.&lt;/p&gt;

&lt;p&gt;My handler did a database insert on &lt;code&gt;subscription.created&lt;/code&gt;:&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;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;subscription.created&lt;/span&gt;&lt;span class="dl"&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;db&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="nx"&gt;subscriptions&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;paddleId&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;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="s1"&gt;created&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;customerId&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;customer_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;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And an update on &lt;code&gt;subscription.activated&lt;/code&gt;:&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;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;subscription.activated&lt;/span&gt;&lt;span class="dl"&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;db&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;subscriptions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&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="s1"&gt;active&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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;subscriptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paddleId&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;id&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;.activated&lt;/code&gt; arrived first, the update found zero rows. No error, no exception. The &lt;code&gt;WHERE&lt;/code&gt; clause just matched nothing. The update silently did nothing.&lt;/p&gt;

&lt;p&gt;Then &lt;code&gt;.created&lt;/code&gt; arrived and inserted the row with status &lt;code&gt;created&lt;/code&gt;. But the &lt;code&gt;.activated&lt;/code&gt; event was already gone. So the subscription was stuck in &lt;code&gt;created&lt;/code&gt; status forever.&lt;/p&gt;

&lt;p&gt;Customers had paid. Paddle showed them as active. My app showed them as pending. Support tickets started coming in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this happens
&lt;/h2&gt;

&lt;p&gt;Paddle does not guarantee webhook delivery order. Their docs mention it briefly but it's easy to miss when you're focused on the API endpoints.&lt;/p&gt;

&lt;p&gt;The events are fired from different internal services. &lt;code&gt;subscription.created&lt;/code&gt; comes from the subscription service. &lt;code&gt;subscription.activated&lt;/code&gt; comes from the billing service after payment confirmation. They are async. They race.&lt;/p&gt;

&lt;p&gt;This is not unique to Paddle either. Stripe has the same problem with &lt;code&gt;payment_intent.created&lt;/code&gt; vs &lt;code&gt;charge.succeeded&lt;/code&gt;. Most payment providers have some version of this.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;The handler needs to be idempotent and order-independent. Every event should be able to create or update:&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;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;subscription.created&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;subscription.activated&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;status&lt;/span&gt; &lt;span class="o"&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;event_type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;subscription.activated&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="s1"&gt;active&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="s1"&gt;created&lt;/span&gt;&lt;span class="dl"&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;db&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="nx"&gt;subscriptions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;paddleId&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;customerId&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;customer_id&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="nf"&gt;onConflictDoUpdate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;subscriptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paddleId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;set&lt;/span&gt;&lt;span class="p"&gt;:&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="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`CASE WHEN &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; = 'active' THEN 'active' ELSE &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;subscriptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; END`&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key parts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Both events can create the row if it doesn't exist&lt;/li&gt;
&lt;li&gt;On conflict, &lt;code&gt;active&lt;/code&gt; always wins over &lt;code&gt;created&lt;/code&gt; regardless of arrival order&lt;/li&gt;
&lt;li&gt;No silent failures, no missing updates&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Testing this is the real problem
&lt;/h2&gt;

&lt;p&gt;The ordering bug is easy to fix once you know about it. The hard part is reproducing it during development.&lt;/p&gt;

&lt;p&gt;You can't control the order Paddle sends webhooks. You can't make &lt;code&gt;.activated&lt;/code&gt; arrive first on demand. In testing you might run through the flow 20 times and the events always arrive in order. Then in production with real network latency and load, they don't.&lt;/p&gt;

&lt;p&gt;I ended up testing this by sending the webhook events manually in the wrong order against a local sandbox. &lt;code&gt;activated&lt;/code&gt; first, then &lt;code&gt;created&lt;/code&gt;. Immediately saw the bug. Fixed it in 10 minutes.&lt;/p&gt;

&lt;p&gt;The debugging in production took 4 hours.&lt;/p&gt;

&lt;p&gt;If you're integrating Paddle or any payment provider with webhooks, test with events arriving in every possible order. Not just the happy path order from the docs.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://fetchsandbox.com/docs/paddle?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=paddle-activated-before-created" rel="noopener noreferrer"&gt;Test Paddle webhook ordering in a sandbox →&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Webhook events are not a queue. They are concurrent messages from different services that happen to be about the same thing. Your handler has to treat every event as potentially the first one it sees for that resource.&lt;/p&gt;

&lt;p&gt;If your handler has an insert for one event type and an update for another, you have this bug. You just haven't hit it in production yet.&lt;/p&gt;

</description>
      <category>paddle</category>
      <category>webhooks</category>
      <category>api</category>
      <category>testing</category>
    </item>
    <item>
      <title>Webhook handlers that silently return 402: the event ordering bug most test suites miss</title>
      <dc:creator>FetchSandbox</dc:creator>
      <pubDate>Tue, 21 Jul 2026 17:40:10 +0000</pubDate>
      <link>https://dev.to/fetchsandbox/webhook-handlers-that-silently-return-402-the-event-ordering-bug-most-test-suites-miss-4f0e</link>
      <guid>https://dev.to/fetchsandbox/webhook-handlers-that-silently-return-402-the-event-ordering-bug-most-test-suites-miss-4f0e</guid>
      <description>&lt;p&gt;&lt;em&gt;How to prove a handler is order-safe using permutation fuzzing&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You're testing your Paddle webhook handler. You've got a test for subscription.created and subscription.activated, and maybe another one where you send them out of order. Looks good. All green. But weeks later, a customer hits a 402 on subscription.activated. No error in your logs. No alert. Just a failed charge and a confused user.&lt;/p&gt;

&lt;p&gt;The problem isn't your test suite, it's the order of events.&lt;/p&gt;

&lt;p&gt;A Paddle webhook handler that does an INSERT on subscription.created and an UPDATE on subscription.activated will silently return 402 when subscription.activated arrives first. The row doesn't exist yet. No error is raised. But if you send them in the "happy path" order, it works. Your test suite never sees the failure because it only runs one sequence. The bug is invisible until it hits production.&lt;/p&gt;

&lt;p&gt;To prove a handler is order-safe, we built a permutation fuzzer in fetchsandbox MCP. It fires the same events in every permutation, including duplicates, and checks if the final state is the same in all of them. If it is, the handler is order-safe. If not, it returns the exact sequence that breaks it.&lt;/p&gt;

&lt;p&gt;Here's what it found for the Paddle handler above:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;When you send subscription.activated before subscription.created: the handler returns a 402.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;When you send them in the "happy path" order: it returns a 200.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The fuzzer returns order_independent=False and shows you the diverging sequence.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After fixing the handler to use an upsert instead of separate INSERT and UPDATE, the fuzzer returns order_independent=True across every permutation and duplicate.&lt;/p&gt;

&lt;p&gt;If you test API integrations in sandboxes constantly, you've hit this one too. I run it in Cursor every time I test a new webhook integration.&lt;/p&gt;

&lt;p&gt;The fuzzer is in fetchsandbox MCP. It reads final state after each permutation, and reports order_independent=True only when every ordering converges to the same final state.&lt;/p&gt;

&lt;p&gt;If you're trying to find webhook ordering bugs, or prove a handler is order-safe, this is the test you didn't know you needed.&lt;/p&gt;

&lt;p&gt;Read the full spec for Paddle webhook testing at &lt;a href="https://fetchsandbox.com/docs/paddle" rel="noopener noreferrer"&gt;Paddle sandbox&lt;/a&gt;. Learn how fetchsandbox MCP works at &lt;a href="https://fetchsandbox.com/mcp" rel="noopener noreferrer"&gt;fetchsandbox MCP&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webhooks</category>
      <category>testing</category>
      <category>devtools</category>
      <category>api</category>
    </item>
    <item>
      <title>We planted a Descope privilege-escalation bug — can your coding agent catch it?</title>
      <dc:creator>FetchSandbox</dc:creator>
      <pubDate>Fri, 10 Jul 2026 18:39:39 +0000</pubDate>
      <link>https://dev.to/fetchsandbox/we-planted-a-descope-privilege-escalation-bug-can-your-coding-agent-catch-it-18id</link>
      <guid>https://dev.to/fetchsandbox/we-planted-a-descope-privilege-escalation-bug-can-your-coding-agent-catch-it-18id</guid>
      <description>&lt;p&gt;We open-sourced a repo with a bug planted on purpose: &lt;a href="https://github.com/fetchsandbox/playground" rel="noopener noreferrer"&gt;github.com/fetchsandbox/playground&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The bug is in &lt;code&gt;apps/descope&lt;/code&gt; — a small FastAPI "Agent Gateway" where AI agents exchange a Descope access key for a scoped session. The flaw: the exchange endpoint trusts whatever scopes the client requests. A read-only agent key can ask for &lt;code&gt;users:write&lt;/code&gt; and get it.&lt;/p&gt;

&lt;p&gt;This is the privilege-escalation shape that ships constantly in agentic auth. The token exchange returns 200, the happy path works, and nothing ever compares the granted scope against what the key was actually granted.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 20-minute test drive
&lt;/h2&gt;

&lt;p&gt;Two tasks, both run from your IDE (Cursor, Claude Code, or any MCP-capable editor). No Descope account, no API keys — the workflows run against FetchSandbox's hosted Descope sandbox over MCP, and each app ships a &lt;code&gt;.mcp.json&lt;/code&gt; already wired.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Task 1 — greenfield.&lt;/strong&gt; &lt;code&gt;apps/descope-onboarding&lt;/code&gt; is a tiny notes app with placeholder auth. Ask your agent to add Descope OTP sign-up, but with one constraint: prove the OTP + session flow in the sandbox &lt;em&gt;before&lt;/em&gt; writing any code, then propose the diff.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;./fetchsandbox I'm adding Descope OTP sign-up to this app — prove the Descope
OTP + session flow in the sandbox before writing any code, then propose the
diff. I'll decide whether to apply.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Task 2 — brownfield.&lt;/strong&gt; Point the agent at the Agent Gateway and ask it to audit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;./fetchsandbox our agent access-key exchange might be handing out more scope
than the key was granted — audit the descope agentic auth
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What a real catch looks like
&lt;/h2&gt;

&lt;p&gt;Static review is not the bar. "This looks vulnerable" is a guess. The bar is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the escalation is &lt;strong&gt;reproduced&lt;/strong&gt; — read-only key in, &lt;code&gt;users:write&lt;/code&gt; session out&lt;/li&gt;
&lt;li&gt;the proof shows &lt;strong&gt;buggy vs fixed&lt;/strong&gt; on actual Descope routes (&lt;code&gt;/v1/...&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;there's a &lt;strong&gt;receipt URL&lt;/strong&gt; — an openable run trace, not a claim in chat&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Auth bugs live in the lifecycle: replayed magic links, expired sessions, JWTs that were decoded instead of verified, scope grants nobody re-checked. Mocks return clean 200s for all of these. That's the gap the playground is designed to expose.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other planted bugs
&lt;/h2&gt;

&lt;p&gt;If Descope isn't your stack, the repo also has broken Stripe (webhook dedup keyed on the wrong header — events processed 2–3×), Resend (bounces silently dropped, users stay "active"), Clerk (session validation skipped on one endpoint), and a few more. Each app is ~50–150 lines of Python.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part where you tell us it fell flat
&lt;/h2&gt;

&lt;p&gt;Findings go back as a PR — there's a &lt;code&gt;FINDINGS_TEMPLATE.md&lt;/code&gt; in the repo. Paste your agent's session, the receipt URLs, and your honest reaction. Merged PRs show up on your GitHub contribution graph.&lt;/p&gt;

&lt;p&gt;We explicitly want the failure reports: MCP wouldn't connect, the agent caught nothing, the proof felt fake. An honest "it didn't work" beats a polite green checkmark.&lt;/p&gt;

&lt;p&gt;Start here: &lt;a href="https://github.com/fetchsandbox/playground/blob/main/TESTING.md" rel="noopener noreferrer"&gt;TESTING.md&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>descope</category>
      <category>security</category>
      <category>testing</category>
      <category>opensource</category>
    </item>
    <item>
      <title>How to test Descope auth without real keys</title>
      <dc:creator>FetchSandbox</dc:creator>
      <pubDate>Wed, 08 Jul 2026 14:04:28 +0000</pubDate>
      <link>https://dev.to/fetchsandbox/how-to-test-descope-auth-without-real-keys-4jib</link>
      <guid>https://dev.to/fetchsandbox/how-to-test-descope-auth-without-real-keys-4jib</guid>
      <description>&lt;p&gt;Title options:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;How to test Descope auth without real keys&lt;/li&gt;
&lt;li&gt;How to test Descope passwordless auth without a project&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Descope is easy to start with, but the part I would test first is not the OTP screen or the magic-link email.&lt;/p&gt;

&lt;p&gt;It is what your app does with the session JWT after Descope returns it.&lt;/p&gt;

&lt;p&gt;FetchSandbox has a Descope sandbox for the flows most apps wire up first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;email OTP signup&lt;/li&gt;
&lt;li&gt;email magic-link sign-in&lt;/li&gt;
&lt;li&gt;session refresh&lt;/li&gt;
&lt;li&gt;agent/access-key exchange&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No real Descope project. No real keys. No inbox setup.&lt;/p&gt;

&lt;p&gt;The useful part is that the sandbox models the failure cases too: wrong OTP, expired session, replayed magic link, and the big one: accepting a forged session JWT because the app decoded it instead of verifying it.&lt;/p&gt;

&lt;p&gt;Here is the concrete flow I would test.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Send a magic link:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /v1/auth/magiclink/signin/email
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Verify the one-time token:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /v1/auth/magiclink/verify
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sandbox returns the shape your app should care about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sessionJwt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"refreshJwt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"userId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"U2..."&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Call your protected route with the &lt;code&gt;sessionJwt&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is where the bug usually lives.&lt;/p&gt;

&lt;p&gt;Bad handlers do something like:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sessionJwt&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;claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That accepts whatever claims are inside the token. A forged &lt;code&gt;alg: none&lt;/code&gt; token can become &lt;code&gt;role=admin&lt;/code&gt; if the handler never checks the signature.&lt;/p&gt;

&lt;p&gt;The fixed version verifies the session JWT against Descope's JWKS, or uses Descope's SDK validation path. It should reject &lt;code&gt;alg: none&lt;/code&gt;, reject bad signatures, cache JWKS, and refresh on &lt;code&gt;kid&lt;/code&gt; mismatch.&lt;/p&gt;

&lt;p&gt;The sandbox route for this bug is meant to prove that difference:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;forged unsigned token -&amp;gt; &lt;code&gt;401&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;valid signed session token -&amp;gt; &lt;code&gt;200&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the whole point. Do not just test "magic link works." Test that the session you trust is actually verified.&lt;/p&gt;

&lt;p&gt;You can also test refresh behavior:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /v1/auth/refresh
GET /v1/auth/me
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If refresh returns &lt;code&gt;401&lt;/code&gt;, send the user back through sign-in. Do not keep rendering authenticated UI from a decoded-but-unverified token.&lt;/p&gt;

&lt;p&gt;Docs are here if you want the exact Descope sandbox flows: &lt;a href="https://fetchsandbox.com/docs/descope" rel="noopener noreferrer"&gt;https://fetchsandbox.com/docs/descope&lt;/a&gt;&lt;/p&gt;

</description>
      <category>auth</category>
      <category>api</category>
      <category>security</category>
      <category>descope</category>
    </item>
    <item>
      <title>Our CI mocked Stripe. Production still broke on step two.</title>
      <dc:creator>FetchSandbox</dc:creator>
      <pubDate>Tue, 30 Jun 2026 14:23:58 +0000</pubDate>
      <link>https://dev.to/fetchsandbox/our-ci-mocked-stripe-production-still-broke-on-step-two-475d</link>
      <guid>https://dev.to/fetchsandbox/our-ci-mocked-stripe-production-still-broke-on-step-two-475d</guid>
      <description>&lt;p&gt;Green CI. Broken staging. Sound familiar?&lt;/p&gt;

&lt;p&gt;Our pipeline mocked &lt;code&gt;POST /v1/payment_intents&lt;/code&gt; and asserted &lt;code&gt;200&lt;/code&gt;. Merge button enabled. First real checkout in staging failed because the handler never stored the PaymentIntent ID — step two of the webhook path read &lt;code&gt;undefined&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Mocks proved &lt;strong&gt;shape&lt;/strong&gt;. Not &lt;strong&gt;lifecycle&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What unit tests miss
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;CI check&lt;/th&gt;
&lt;th&gt;What it proves&lt;/th&gt;
&lt;th&gt;What it skips&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Mocked &lt;code&gt;fetch()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Request JSON looks right&lt;/td&gt;
&lt;td&gt;Step 2 cannot read step 1's ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Snapshot tests&lt;/td&gt;
&lt;td&gt;Handler code unchanged&lt;/td&gt;
&lt;td&gt;Webhook branch never runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manual smoke doc&lt;/td&gt;
&lt;td&gt;Someone remembers sometimes&lt;/td&gt;
&lt;td&gt;Agents ship faster than the checklist&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The bug class: &lt;strong&gt;IDs do not chain&lt;/strong&gt; across steps. Webhooks fire on mutations your mock never triggers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we run now — MCP locally, same workflow in CI
&lt;/h2&gt;

&lt;p&gt;Before an agent touches payment code, we prove the provider flow in the IDE via &lt;strong&gt;FetchSandbox MCP&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;quickrun stripe / accept_payment
→ create PaymentIntent
→ confirm / capture path
→ read back state
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the contract the integration must satisfy — not a paragraph in the PR description.&lt;/p&gt;

&lt;p&gt;Then CI runs the &lt;strong&gt;same curated workflow&lt;/strong&gt; headless:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/api-integration.yml&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Prove Stripe integration before deploy&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx fetchsandbox run stripe --all --json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit code &lt;strong&gt;0&lt;/strong&gt; → workflows passed, merge allowed.&lt;br&gt;&lt;br&gt;
Exit code &lt;strong&gt;1&lt;/strong&gt; → named step failed (&lt;code&gt;create_payment_intent&lt;/code&gt;, webhook reconcile, etc.) — PR blocked.&lt;/p&gt;

&lt;p&gt;No staging dependency. No IP whitelist. No "works on my machine."&lt;/p&gt;

&lt;h2&gt;
  
  
  Why MCP belongs in the loop
&lt;/h2&gt;

&lt;p&gt;Coding agents edit integration code constantly. Without a runnable proof:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The agent "fixes" the handler and moves on&lt;/li&gt;
&lt;li&gt;CI mocks still pass&lt;/li&gt;
&lt;li&gt;Production breaks on the path the agent never exercised&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;MCP gives the agent (and you) a &lt;strong&gt;receipt&lt;/strong&gt;: sandbox timeline, step statuses, real provider-shaped IDs from the run — before opening the PR.&lt;/p&gt;

&lt;p&gt;CI is the gate that keeps that proof from regressing on the next agent session.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a workflow run actually checks
&lt;/h2&gt;

&lt;p&gt;Not "does POST return 200 once."&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create a resource → mutate it → &lt;strong&gt;GET the same ID back&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Trigger the webhook branch when state changes&lt;/li&gt;
&lt;li&gt;Fail with a &lt;strong&gt;named step&lt;/strong&gt; when auth or transitions break&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is integration testing — not HTTP cosplay.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we stopped doing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Treating mocked unit tests as "integration covered"&lt;/li&gt;
&lt;li&gt;Running only manual smoke before deploy&lt;/li&gt;
&lt;li&gt;Letting agents merge Stripe/Resend/Clerk changes without a workflow receipt&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Full write-up: &lt;a href="https://fetchsandbox.com/api-integration-testing-in-ci" rel="noopener noreferrer"&gt;API integration testing in CI&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;MCP + agent workflows: &lt;a href="https://fetchsandbox.com/agent-api-workflow-testing" rel="noopener noreferrer"&gt;Agent API workflow testing&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Install MCP in Cursor/Claude → run a Stripe workflow locally → paste the same &lt;code&gt;npx fetchsandbox run&lt;/code&gt; step into Actions.&lt;/p&gt;




&lt;p&gt;&lt;a href="https://fetchsandbox.com" rel="noopener noreferrer"&gt;fetchsandbox.com&lt;/a&gt; · &lt;a href="https://fetchsandbox.com/mcp" rel="noopener noreferrer"&gt;MCP setup&lt;/a&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>devops</category>
      <category>mcp</category>
      <category>testing</category>
    </item>
    <item>
      <title>Our partners kept breaking on staging. So we gave them production access. (Don't do this.)</title>
      <dc:creator>FetchSandbox</dc:creator>
      <pubDate>Thu, 25 Jun 2026 16:43:57 +0000</pubDate>
      <link>https://dev.to/fetchsandbox/our-partners-kept-breaking-on-staging-so-we-gave-them-production-access-dont-do-this-1ng6</link>
      <guid>https://dev.to/fetchsandbox/our-partners-kept-breaking-on-staging-so-we-gave-them-production-access-dont-do-this-1ng6</guid>
      <description>&lt;p&gt;We whitelisted partners on production with read-only test accounts. That was attempt four — after docs-only onboarding, shared UAT, and IP-whitelisted staging all failed in different ways.&lt;/p&gt;

&lt;p&gt;I work on an embed platform. Partners integrate our APIs for payments, identity verification, and onboarding flows. We burned six months on one question: how do partners test their integration before go-live?&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt 1: "Just use our docs"
&lt;/h2&gt;

&lt;p&gt;We pointed partners at endpoints, auth, request/response examples. "You're good to go."&lt;/p&gt;

&lt;p&gt;They'd hit something we didn't document, open a support ticket, wait two days, lose momentum. A few never came back. One went with a competitor because they "couldn't get a working test setup in a week."&lt;/p&gt;

&lt;p&gt;The docs described what &lt;em&gt;should&lt;/em&gt; happen. Partners had no way to see what &lt;em&gt;actually&lt;/em&gt; happens until they wrote code, deployed, and hoped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt 2: Internal UAT box
&lt;/h2&gt;

&lt;p&gt;Next: give partners our internal UAT environment. IP whitelisted, shared credentials. We told them "please don't break anything" without irony.&lt;/p&gt;

&lt;p&gt;Worked for three weeks.&lt;/p&gt;

&lt;p&gt;QA pushed a bad config on a Tuesday. The partner's integration broke. They spent two days debugging their own code before opening a ticket. By the time we figured it out, their engineering lead was cc'ing our VP.&lt;/p&gt;

&lt;p&gt;Another team ran load tests on UAT the same week. Partner API calls timed out. Their CTO asked if our platform was "production-ready."&lt;/p&gt;

&lt;p&gt;UAT was built for us to break things. We invited external partners into that mess.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt 3: Staging with IP whitelisting
&lt;/h2&gt;

&lt;p&gt;We carved out "partner staging" — same codebase, separate deployment, IP whitelisted per partner. Felt like the grown-up solution.&lt;/p&gt;

&lt;p&gt;It wasn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;IP whitelists became a full-time job.&lt;/strong&gt; Every new partner meant firewall rules. Home IPs differed from office IPs. One CTO traveling couldn't hit the sandbox from his hotel. We debugged VPN configs at 11pm on a Thursday.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test data went stale.&lt;/strong&gt; Partners created orders for products that existed in production but not staging. "Your API returns 404 for product_id XYZ." Correct — nobody seeded staging in three weeks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deploys collided with demos.&lt;/strong&gt; Engineers shipped to staging without checking if partners were testing. A deploy during a partner's live demo call gets brought up in quarterly business reviews.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost.&lt;/strong&gt; Full production mirror for partner testing — database, compute, monitoring, on-call — used maybe 10 hours a week.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt 4: Production access
&lt;/h2&gt;

&lt;p&gt;I wish I was kidding.&lt;/p&gt;

&lt;p&gt;Reasoning: staging is flaky, UAT is a disaster, whitelist them on production with read-only test accounts.&lt;/p&gt;

&lt;p&gt;The API was stable. Partners were happy. For about a month.&lt;/p&gt;

&lt;p&gt;One partner's integration bug created 400 orphaned records in production. Data team spent a weekend cleaning up.&lt;/p&gt;

&lt;p&gt;Compliance asked why test payloads with fake PII hit the production database.&lt;/p&gt;

&lt;p&gt;Maintenance meant coordinating downtime with partners "just running a few test calls."&lt;/p&gt;

&lt;p&gt;We'd built the world's most expensive sandbox: production with IP whitelisting.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we actually needed
&lt;/h2&gt;

&lt;p&gt;Partners didn't need access to &lt;em&gt;our&lt;/em&gt; infrastructure.&lt;/p&gt;

&lt;p&gt;They needed an API that looked and acted like ours — same endpoints, schemas, auth patterns — but was completely separate. POST creates a resource. GET retrieves it. State transitions work. Nobody else's deploy breaks it.&lt;/p&gt;

&lt;p&gt;Not a mock server — those are stateless; step 2 doesn't know about step 1. Not a shared staging box — that's everybody's problem. A dedicated sandbox generated from the same OpenAPI spec the real API uses.&lt;/p&gt;

&lt;p&gt;This is half why I started working on &lt;a href="https://fetchsandbox.com" rel="noopener noreferrer"&gt;FetchSandbox&lt;/a&gt;. Give it an OpenAPI spec, get a stateful sandbox — CRUD, state machines, webhook events, seed data. No infrastructure to maintain.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Partner gets a sandbox from your spec&lt;/span&gt;
npx fetchsandbox generate ./your-api-openapi.yaml

curl https://your-api.fetchsandbox.com/v1/orders &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"api-key: sandbox_abc123"&lt;/span&gt;
&lt;span class="c"&gt;# → 200 OK, realistic seed data&lt;/span&gt;

fetchsandbox run your-api create-and-fulfill-order
&lt;span class="c"&gt;# → ✓ Create order — 201&lt;/span&gt;
&lt;span class="c"&gt;# → ✓ Add line items — 200&lt;/span&gt;
&lt;span class="c"&gt;# → ✓ Submit for fulfillment — 200&lt;/span&gt;
&lt;span class="c"&gt;# → ✓ Webhook: order.fulfilled fired&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No staging to maintain. No IP whitelists. No production risk. Partners get their own URL, data, and credentials. Your team doesn't touch it.&lt;/p&gt;

&lt;p&gt;When a partner asks "does this workflow work?" — they prove it themselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  The time sink nobody tracks
&lt;/h2&gt;

&lt;p&gt;If you run a partner API, count hours per month on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Provisioning and maintaining test environments&lt;/li&gt;
&lt;li&gt;Debugging "is your sandbox down?" tickets&lt;/li&gt;
&lt;li&gt;Managing IP whitelists and VPN access&lt;/li&gt;
&lt;li&gt;Re-seeding stale test data&lt;/li&gt;
&lt;li&gt;Coordinating deploys around partner testing schedules&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At my company: 20–30 hours across engineering and DevOps. For a problem that shouldn't exist.&lt;/p&gt;




&lt;p&gt;Open the Stripe sandbox and run a workflow end-to-end — no signup: &lt;a href="https://fetchsandbox.com/playground" rel="noopener noreferrer"&gt;fetchsandbox.com/playground&lt;/a&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>webdev</category>
      <category>devops</category>
      <category>testing</category>
    </item>
    <item>
      <title>Resend webhook reconciliation: map events to the right email record</title>
      <dc:creator>FetchSandbox</dc:creator>
      <pubDate>Tue, 23 Jun 2026 20:02:20 +0000</pubDate>
      <link>https://dev.to/fetchsandbox/resend-webhook-reconciliation-map-events-to-the-right-email-record-1d3o</link>
      <guid>https://dev.to/fetchsandbox/resend-webhook-reconciliation-map-events-to-the-right-email-record-1d3o</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a different failure mode from treating &lt;code&gt;email.sent&lt;/code&gt; as delivered. That bug is &lt;a href="https://fetchsandbox.com/blog/resend-email-sent-is-not-delivered" rel="noopener noreferrer"&gt;here&lt;/a&gt;. This post is about what happens after you store the Resend email ID.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;POST /emails&lt;/code&gt; returned &lt;code&gt;200&lt;/code&gt;. You saved the Resend email ID on your notification row. A minute later the webhook handler runs.&lt;/p&gt;

&lt;p&gt;Then support asks: "Why does the dashboard say delivered when the address bounced?"&lt;/p&gt;

&lt;p&gt;The send call worked. The reconciliation layer did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug is usually a flat status field
&lt;/h2&gt;

&lt;p&gt;Most handlers start like 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="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/resend&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;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="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="nx"&gt;req&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;emailId&lt;/span&gt; &lt;span class="o"&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;email_id&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;row&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;notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findByResendId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;emailId&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;row&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;res&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="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;email.delivered&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;notifications&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;row&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="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;delivered&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;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;email.opened&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;notifications&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;row&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="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;opened&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;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;email.bounced&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;notifications&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;row&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="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;bounced&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;res&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three separate problems hide in that shape:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Lookup failure&lt;/strong&gt; — webhook arrives before your app finishes persisting the Resend ID, or the ID is stored on the wrong table. Handler returns &lt;code&gt;200&lt;/code&gt;, nothing updates, bug disappears until production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Last-write-wins&lt;/strong&gt; — &lt;code&gt;email.opened&lt;/code&gt; overwrites &lt;code&gt;delivered&lt;/code&gt;. Fine if you track engagement separately. Bad if &lt;code&gt;status&lt;/code&gt; drives UX copy or retry logic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No terminal states&lt;/strong&gt; — &lt;code&gt;email.bounced&lt;/code&gt; should win over &lt;code&gt;delivered&lt;/code&gt; when events arrive out of order or retry. A single string field cannot express that without rules.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Separate delivery state from engagement
&lt;/h2&gt;

&lt;p&gt;A pattern that survives production:&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;TERMINAL&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;bounced&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;complained&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;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;nextDeliveryState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;incoming&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;TERMINAL&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;current&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;current&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;incoming&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bounced&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;incoming&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;complained&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="nx"&gt;incoming&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;incoming&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;delivered&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="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sent&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;delivered&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;current&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;incoming&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sent&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="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sent&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="nx"&gt;current&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/resend&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;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="kd"&gt;const&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="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&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;body&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;emailId&lt;/span&gt; &lt;span class="o"&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;email_id&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;row&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;notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findByResendId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;emailId&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;row&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhookInbox&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;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;resend&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;emailId&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="na"&gt;payload&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;body&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;res&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="k"&gt;if &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;email.opened&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&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;email.clicked&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;emailEvents&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;notificationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;row&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="nx"&gt;type&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;res&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;nextDeliveryState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deliveryStatus&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="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;email.&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="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;notifications&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;row&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="na"&gt;deliveryStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;lastEventType&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="na"&gt;lastEventAt&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;Date&lt;/span&gt;&lt;span class="p"&gt;(),&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="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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notes that matter in real apps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;email.opened&lt;/code&gt; is not delivery.&lt;/strong&gt; Log it separately unless your product truly treats opens as delivery confirmation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store unmatched webhooks.&lt;/strong&gt; If the event arrives before the send transaction commits, you need a replay path — not a silent &lt;code&gt;200&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency key = Resend email ID + event type.&lt;/strong&gt; Retries are normal.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The test most teams skip
&lt;/h2&gt;

&lt;p&gt;Unit tests mock one webhook payload. That proves parsing, not reconciliation.&lt;/p&gt;

&lt;p&gt;The integration test that catches the bug:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;create internal notification row&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;POST /emails&lt;/code&gt; and persist Resend email ID on that row&lt;/li&gt;
&lt;li&gt;deliver &lt;code&gt;email.sent&lt;/code&gt;, then &lt;code&gt;email.delivered&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;deliver &lt;code&gt;email.opened&lt;/code&gt; and assert delivery status did not regress&lt;/li&gt;
&lt;li&gt;deliver &lt;code&gt;email.bounced&lt;/code&gt; on a second send and assert terminal state wins&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 4 and 5 are where flat &lt;code&gt;status&lt;/code&gt; fields break.&lt;/p&gt;

&lt;h2&gt;
  
  
  Receipts
&lt;/h2&gt;

&lt;p&gt;Real Resend send workflow run against FetchSandbox, captured 2026-06-07:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /emails                                       → 200
  id: 4d9acb24-5913-4e02-8b1a-a18ed10a6fad

GET  /emails/4d9acb24-5913-4e02-8b1a-a18ed10a6fad  → 200

webhook events verified:
  email.sent
  email.delivered
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Full timeline: &lt;a href="https://fetchsandbox.com/runs/8e4fe8e9f9?flow=run_4062510e-268e-451f-b5f4-e8844eef42d5" rel="noopener noreferrer"&gt;fetchsandbox.com/runs/8e4fe8e9f9?flow=run_4062510e-268e-451f-b5f4-e8844eef42d5&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That run proves the provider lifecycle through &lt;code&gt;email.delivered&lt;/code&gt;. Your app still needs its own test for &lt;strong&gt;mapping&lt;/strong&gt; those events back to the correct internal row and for &lt;strong&gt;terminal&lt;/strong&gt; bounce/complaint handling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Runnable preflight (optional)
&lt;/h2&gt;

&lt;p&gt;Before wiring production webhooks, I run the send workflow from Cursor with FetchSandbox MCP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;validate resend email workflow with fetchsandbox
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Workflow page: &lt;a href="https://fetchsandbox.com/docs/resend" rel="noopener noreferrer"&gt;fetchsandbox.com/docs/resend&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Resend sandbox page (mock API + webhook replay): &lt;a href="https://fetchsandbox.com/resend" rel="noopener noreferrer"&gt;fetchsandbox.com/resend&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That does not replace Resend's real domain, API key, or production webhook endpoint. It just makes the lifecycle obvious before you commit your local state machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to read next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://fetchsandbox.com/blog/resend-email-sent-is-not-delivered" rel="noopener noreferrer"&gt;email.sent is not delivered&lt;/a&gt; — conflating early send events with delivery&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://resend.com/docs/webhooks/emails/delivered" rel="noopener noreferrer"&gt;Resend webhook event docs&lt;/a&gt; — field shapes for each event type&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are integrating Resend this week, fix ID lookup and terminal states before you polish the send form.&lt;/p&gt;

</description>
      <category>resend</category>
      <category>webhooks</category>
      <category>api</category>
      <category>testing</category>
    </item>
    <item>
      <title>Test AgentMail multi-tenant webhooks before they leak across tenants</title>
      <dc:creator>FetchSandbox</dc:creator>
      <pubDate>Tue, 16 Jun 2026 17:16:05 +0000</pubDate>
      <link>https://dev.to/fetchsandbox/test-agentmail-multi-tenant-webhooks-before-they-leak-across-tenants-1e5k</link>
      <guid>https://dev.to/fetchsandbox/test-agentmail-multi-tenant-webhooks-before-they-leak-across-tenants-1e5k</guid>
      <description>&lt;p&gt;The dangerous AgentMail webhook bug starts with a response that looks completely fine.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /v0/webhooks -&amp;gt; 200 OK
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response body contains the webhook object. It includes your URL, event types, and usually enough fields to make the integration feel done.&lt;/p&gt;

&lt;p&gt;But in a multi-tenant app, the field that matters is &lt;code&gt;inbox_ids&lt;/code&gt;. If that array is silently dropped, your webhook is no longer scoped to the tenant inbox you meant to subscribe. It is subscribed broadly, and every tenant's message events can start flooding the same endpoint.&lt;/p&gt;

&lt;p&gt;That is not a noisy failure. It is worse. The subscribe call worked. Your endpoint receives events. The logs look active. The leak is in the scope.&lt;/p&gt;

&lt;p&gt;If your handler routes by webhook ID before it checks the inbox ID, the wrong customer can look like a valid event. That is the kind of bug that passes observability and fails privacy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why unit tests miss it
&lt;/h2&gt;

&lt;p&gt;Most unit tests stub the webhook create call by echoing back exactly what the app sent.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;request.inbox_ids -&amp;gt; response.inbox_ids
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That proves your client serialized the payload. It does not prove AgentMail persisted the scope.&lt;/p&gt;

&lt;p&gt;The test passes. CI passes. The code ships. Then production receives events for inboxes that do not belong to the tenant that configured the webhook.&lt;/p&gt;

&lt;p&gt;The annoying part is that the local test is not obviously wrong. It has the right endpoint, the right payload shape, and the right assertion against the create response. It just trusts the wrong copy of the object.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern is reconcile-after-write
&lt;/h2&gt;

&lt;p&gt;The fix is small enough to name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST -&amp;gt; GET -&amp;gt; diff
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Treat the &lt;code&gt;POST&lt;/code&gt; response as untrusted. It may be your request echoed back. Treat the later &lt;code&gt;GET&lt;/code&gt; as the provider state. That is what AgentMail actually stored.&lt;/p&gt;

&lt;p&gt;The diff is the bug.&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sentInboxIds&lt;/span&gt; &lt;span class="o"&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;inbox_tenant_123&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;created&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;agentmail&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;/v0/webhooks&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;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://app.example.com/webhooks/agentmail&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;event_types&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;message.delivered&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;message.bounced&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;inbox_ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sentInboxIds&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;stored&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;agentmail&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="s2"&gt;`/v0/webhooks/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;created&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;got&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...(&lt;/span&gt;&lt;span class="nx"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inbox_ids&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[])].&lt;/span&gt;&lt;span class="nf"&gt;sort&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;want&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;sentInboxIds&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sort&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;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;got&lt;/span&gt;&lt;span class="p"&gt;)&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;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;want&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AgentMail webhook scope drifted&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;That is the whole check. It is not a bigger mock. It is a read after the write.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the agent caught it
&lt;/h2&gt;

&lt;p&gt;The reason a coding agent caught this where the unit test did not is not that it was smarter about webhooks. It ran a better-shaped test.&lt;/p&gt;

&lt;p&gt;AgentMail has a curated &lt;code&gt;webhook_lifecycle_create_read_delete&lt;/code&gt; workflow in FetchSandbox. When the agent ran it through the FetchSandbox MCP server, the workflow forced the boring step people skip:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;create inbox
subscribe webhook scoped to that inbox
read webhook back
compare inbox_ids
delete webhook
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The workflow shape matches the bug shape. A stubbed unit test checks what your app meant to send. The workflow checks what the provider kept.&lt;/p&gt;

&lt;h2&gt;
  
  
  This is not only AgentMail
&lt;/h2&gt;

&lt;p&gt;The same bug appears anywhere a control-plane API returns an object that looks like the request you sent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;IAM policies&lt;/li&gt;
&lt;li&gt;ACL rules&lt;/li&gt;
&lt;li&gt;draft resources&lt;/li&gt;
&lt;li&gt;webhook subscriptions&lt;/li&gt;
&lt;li&gt;tenant-scoped notification settings&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a create call returns &lt;code&gt;200&lt;/code&gt;, but the security or tenancy boundary matters, do not stop at the create response.&lt;/p&gt;

&lt;p&gt;Read it back. Diff the fields that protect the boundary. Fail the test before the provider starts sending another tenant's events to your endpoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the brownfield demo
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://github.com/fetchsandbox/brownfield-agentmail-demo" rel="noopener noreferrer"&gt;brownfield-agentmail-demo&lt;/a&gt; repo shows this end to end if you want to run it against the workflow yourself.&lt;/p&gt;

&lt;p&gt;The important part is not AgentMail-specific. The habit is: after a write that configures scope, reconcile what the provider persisted.&lt;/p&gt;

&lt;p&gt;A webhook that passes create tests can still be the webhook that leaks across tenants.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://fetchsandbox.com/docs/agentmail" rel="noopener noreferrer"&gt;FetchSandbox's AgentMail workflow docs&lt;/a&gt; include the create, read, and teardown path.&lt;/p&gt;

</description>
      <category>agentmail</category>
      <category>webhooks</category>
      <category>api</category>
      <category>testing</category>
    </item>
    <item>
      <title>Clerk webhook replay can create duplicate users unless your sync is idempotent</title>
      <dc:creator>FetchSandbox</dc:creator>
      <pubDate>Mon, 15 Jun 2026 13:57:02 +0000</pubDate>
      <link>https://dev.to/fetchsandbox/clerk-webhook-replay-can-create-duplicate-users-unless-your-sync-is-idempotent-49i7</link>
      <guid>https://dev.to/fetchsandbox/clerk-webhook-replay-can-create-duplicate-users-unless-your-sync-is-idempotent-49i7</guid>
      <description>&lt;p&gt;&lt;em&gt;The login worked. The webhook worked. Then the same event arrived again.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Clerk webhooks are easy to treat like a notification:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;user.created arrived
create a local user
done
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That works until the event is retried, replayed, or delivered after another part of your app already created the user.&lt;/p&gt;

&lt;p&gt;Then your app has two local rows for the same Clerk user. Or one row has the right email and the other has the right metadata. Or your onboarding job runs twice and sends the same welcome email twice.&lt;/p&gt;

&lt;p&gt;The bug is not "Clerk webhooks are unreliable." Retrying delivery is normal webhook behavior.&lt;/p&gt;

&lt;p&gt;The bug is trusting delivery count instead of provider state.&lt;/p&gt;

&lt;h2&gt;
  
  
  The narrow fix
&lt;/h2&gt;

&lt;p&gt;Your webhook handler should not mean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;on user.created -&amp;gt; insert user
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It should mean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;on user.created -&amp;gt; upsert by clerk_user_id
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important field is the stable provider ID, not the local row ID and not the email address.&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="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;clerkUserId&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;id&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;update&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;email&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;email_addresses&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="nx"&gt;email_address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;firstName&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;first_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;lastName&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;last_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;clerkUpdatedAt&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;Date&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;updated_at&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;clerkUserId&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;email&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;email_addresses&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="nx"&gt;email_address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;firstName&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;first_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;lastName&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;last_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;clerkUpdatedAt&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;Date&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;updated_at&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;That one constraint does most of the work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;UNIQUE(clerk_user_id)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now a replay updates the same user instead of creating a second one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part people skip
&lt;/h2&gt;

&lt;p&gt;The handler can still be wrong even after the upsert.&lt;/p&gt;

&lt;p&gt;If your app grants access, creates a workspace, starts a trial, or sends email inside the same handler, those side effects need their own idempotency rule too.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;workspace owner = clerk_user_id
welcome email key = clerk_event_id
trial grant key = clerk_user_id + plan_id
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user row is only one piece of local state.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I would test it
&lt;/h2&gt;

&lt;p&gt;I would test the same &lt;code&gt;user.created&lt;/code&gt; event twice.&lt;/p&gt;

&lt;p&gt;First delivery:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;local user count: 0 -&amp;gt; 1
workspace count: 0 -&amp;gt; 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replay:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;local user count: 1 -&amp;gt; 1
workspace count: 1 -&amp;gt; 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole test.&lt;/p&gt;

&lt;p&gt;FetchSandbox has a &lt;a href="https://fetchsandbox.com/clerk" rel="noopener noreferrer"&gt;Clerk sandbox&lt;/a&gt; and runnable &lt;a href="https://fetchsandbox.com/docs/clerk?page=workflow-user-signup" rel="noopener noreferrer"&gt;Clerk workflow docs&lt;/a&gt; for this exact kind of replay check. It is also where a &lt;a href="https://fetchsandbox.com/stateful-api-sandbox" rel="noopener noreferrer"&gt;stateful sandbox&lt;/a&gt; is more useful than a static mock response: the second delivery is the thing you need to observe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Do not write Clerk webhook handlers as if events happen once.&lt;/p&gt;

&lt;p&gt;Write them as reconciliation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;given this Clerk user ID,
what should my app state be now?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the answer changes when the same event arrives twice, the bug is already there. Production is just waiting to replay it.&lt;/p&gt;

</description>
      <category>clerk</category>
      <category>webhooks</category>
      <category>auth</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
