<?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>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>
    <item>
      <title>WorkOS directory sync webhooks need reconciliation after Okta group renames</title>
      <dc:creator>FetchSandbox</dc:creator>
      <pubDate>Thu, 11 Jun 2026 14:25:12 +0000</pubDate>
      <link>https://dev.to/fetchsandbox/workos-directory-sync-webhooks-need-reconciliation-after-okta-group-renames-4bfk</link>
      <guid>https://dev.to/fetchsandbox/workos-directory-sync-webhooks-need-reconciliation-after-okta-group-renames-4bfk</guid>
      <description>&lt;p&gt;The bug shows up two minutes after the customer says, "we only renamed the group."&lt;/p&gt;

&lt;p&gt;In Okta, the admin changed &lt;code&gt;Enterprise Admins&lt;/code&gt; to &lt;code&gt;Platform Admins&lt;/code&gt;. WorkOS sent &lt;code&gt;dsync.group.updated&lt;/code&gt;. Your webhook handler saw the new name. The cache updated. Everything looked normal.&lt;/p&gt;

&lt;p&gt;Then support gets the screenshot: half the enterprise customer's users can access the gated feature. Half can't. The senior employees are the first ones broken because they sit in the group your app treats as the entitlement source.&lt;/p&gt;

&lt;h2&gt;
  
  
  The webhook is not the final state
&lt;/h2&gt;

&lt;p&gt;The tempting handler is simple:&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;type&lt;/span&gt; &lt;span class="nx"&gt;WorkOSGroupUpdated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dsync.group.updated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleGroupUpdated&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;WorkOSGroupUpdated&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;groupsCache&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="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="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="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;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;updatedAt&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That works only if the webhook payload is the same thing as the directory state your feature gate should trust.&lt;/p&gt;

&lt;p&gt;It is not.&lt;/p&gt;

&lt;p&gt;Okta sends SCIM PATCH operations to WorkOS in batches that do not necessarily match the order an admin sees in Okta's UI. WorkOS directory sync webhooks fire as individual events. They tell you something changed. They do not give you a complete, ordered snapshot of the group and every user's current membership.&lt;/p&gt;

&lt;p&gt;If your app updates downstream feature gating from the webhook payload, it can partially reconcile. One cache entry has the new group name. Another user's group membership still reflects the old world. The feature gate now depends on which stale value a request happened to read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat dsync webhooks as invalidation
&lt;/h2&gt;

&lt;p&gt;For feature gating, a &lt;code&gt;dsync.group.updated&lt;/code&gt; or &lt;code&gt;dsync.user.updated&lt;/code&gt; webhook should invalidate local state. It should not be the state.&lt;/p&gt;

&lt;p&gt;The safer handler does two reads after every relevant webhook:&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;type&lt;/span&gt; &lt;span class="nx"&gt;DSyncEvent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dsync.group.updated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dsync.user.updated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleDirectorySyncEvent&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;DSyncEvent&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;event&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dsync.group.updated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;group&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;workos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;directorySync&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDirectoryGroup&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;users&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;workos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;directorySync&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listDirectoryUsers&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;directory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;directoryId&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;featureGateStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reconcileDirectoryGroup&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;groupId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;group&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;groupName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;users&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;users&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="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;event&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dsync.user.updated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;users&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;workos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;directorySync&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listDirectoryUsers&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;featureGateStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reconcileDirectoryUsers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;users&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="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is not the SDK shape. It is the rule: ignore the webhook payload's &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;groups&lt;/code&gt; for downstream feature gating. Use the payload ID to know what to re-fetch. Then read from &lt;code&gt;GET /directory_groups/{id}&lt;/code&gt; and &lt;code&gt;GET /directory_users&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Those reads are the source of truth your cache reconciles against.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to test
&lt;/h2&gt;

&lt;p&gt;The test is not "did we receive &lt;code&gt;dsync.group.updated&lt;/code&gt;?"&lt;/p&gt;

&lt;p&gt;The test is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start with a group that controls a feature gate.&lt;/li&gt;
&lt;li&gt;Rename the group in the identity provider.&lt;/li&gt;
&lt;li&gt;Receive the group update webhook.&lt;/li&gt;
&lt;li&gt;Re-fetch the group by ID.&lt;/li&gt;
&lt;li&gt;Re-fetch directory users.&lt;/li&gt;
&lt;li&gt;Rebuild the local entitlement view from the reads, not the webhook payload.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is the path that catches the bug. A unit test around the webhook JSON does not.&lt;/p&gt;

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

&lt;p&gt;Here's a real WorkOS directory group reconciliation workflow run against the FetchSandbox sandbox, captured 2026-06-11:&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;GET /directories → 200
GET /directory_groups → 200
GET /directory_groups/54483a43-5333-489d-8930-2a549bce4de9 → 200
GET /directory_users → 200
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sandbox ID: &lt;code&gt;a8d236f155&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Flow run ID: &lt;code&gt;run_227a570b-1c7d-42d8-ad8d-af1c09c67b60&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Full timeline: &lt;a href="https://fetchsandbox.com/runs/a8d236f155?flow=run_227a570b-1c7d-42d8-ad8d-af1c09c67b60" rel="noopener noreferrer"&gt;fetchsandbox.com/runs/a8d236f155?flow=run_227a570b-1c7d-42d8-ad8d-af1c09c67b60&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The group ID &lt;code&gt;54483a43-5333-489d-8930-2a549bce4de9&lt;/code&gt; is the value the webhook should use to trigger reconciliation. The follow-up &lt;code&gt;GET /directory_users&lt;/code&gt; is what keeps feature gating from depending on a partial webhook payload.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to test this without waiting on Okta
&lt;/h2&gt;

&lt;p&gt;For WorkOS directory sync, the finish line is not "webhook received." It is "the app rebuilt its local entitlement view from the directory state."&lt;/p&gt;

&lt;p&gt;We use &lt;a href="https://fetchsandbox.com/docs/workos" rel="noopener noreferrer"&gt;FetchSandbox&lt;/a&gt; to run the directory group reconciliation workflow before wiring the production handler. The point is not to replace Okta or WorkOS testing. The point is to make the cache rule obvious before a real enterprise customer renames the group your feature gate depends on.&lt;/p&gt;

</description>
      <category>workos</category>
      <category>scim</category>
      <category>webhooks</category>
      <category>testing</category>
    </item>
    <item>
      <title>Clerk JWT 401 on the server? Check these 3 env vars first</title>
      <dc:creator>FetchSandbox</dc:creator>
      <pubDate>Tue, 09 Jun 2026 14:58:41 +0000</pubDate>
      <link>https://dev.to/fetchsandbox/clerk-jwt-401-on-the-server-check-these-3-env-vars-first-nl</link>
      <guid>https://dev.to/fetchsandbox/clerk-jwt-401-on-the-server-check-these-3-env-vars-first-nl</guid>
      <description>&lt;p&gt;A Clerk login can look fine in the browser and still fail on your server with a &lt;code&gt;401&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The frontend has a user. &lt;code&gt;getToken()&lt;/code&gt; returns something. The request includes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Authorization: Bearer &amp;lt;token&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then FastAPI, Express, or your API route rejects it.&lt;/p&gt;

&lt;p&gt;Before rewriting your auth code, check this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CLERK_PUBLISHABLE_KEY
CLERK_SECRET_KEY
CLERK_JWT_ISSUER
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They need to come from the same Clerk instance.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;This can happen when you have multiple Clerk projects open:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Frontend:
  CLERK_PUBLISHABLE_KEY = pk_test_from_project_A

Backend:
  CLERK_SECRET_KEY = sk_test_from_project_B
  CLERK_JWT_ISSUER = https://project-c.clerk.accounts.dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each value can look valid by itself.&lt;/p&gt;

&lt;p&gt;But the token was minted by one Clerk instance, and your backend is trying to verify it against another one. That is enough for server-side verification to fail.&lt;/p&gt;

&lt;p&gt;You might see:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;JWT issuer mismatch
invalid issuer
JWKS key not found
invalid signature
401 unauthorized
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Different library, same root issue.&lt;/p&gt;

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

&lt;p&gt;Open one Clerk dashboard project and copy all auth values again from the same place.&lt;/p&gt;

&lt;p&gt;Then restart both servers.&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;# frontend&lt;/span&gt;
&lt;span class="nv"&gt;NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;pk_test_...

&lt;span class="c"&gt;# backend&lt;/span&gt;
&lt;span class="nv"&gt;CLERK_SECRET_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sk_test_...
&lt;span class="nv"&gt;CLERK_JWT_ISSUER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://same-instance.clerk.accounts.dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not only refresh the browser. Dev servers often keep old env values until the process restarts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why agents miss this
&lt;/h2&gt;

&lt;p&gt;An AI coding agent can write the shape of a Clerk integration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;add middleware
read bearer token
verify JWT
sync user
protect route
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But it will not know you pasted keys from two Clerk projects unless it checks the running path.&lt;/p&gt;

&lt;p&gt;This is why I like running a small auth workflow before wiring the app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Receipt
&lt;/h2&gt;

&lt;p&gt;I ran FetchSandbox's Clerk &lt;code&gt;user_signup&lt;/code&gt; workflow as the receipt layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Workflow: clerk/user_signup
Result: passed
Steps: 4/4
Duration: 77.44 ms
Verified webhooks: user.created, user.updated
Timeline: https://fetchsandbox.com/runs/7aa06cff84?flow=run_b8399f77-352c-4fe4-ad50-24e3060f8d8d
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That workflow proves the user lifecycle surface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /users
GET /users/{id}
PATCH /users/{id}
verify user.created + user.updated
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It does not replace your real Clerk project. It gives your agent or teammate a runnable auth flow before they touch app code.&lt;/p&gt;

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

&lt;p&gt;If Clerk works in the browser but your server returns &lt;code&gt;401&lt;/code&gt;, check the boring thing first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;publishable key
secret key
issuer URL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same instance. Same environment. Then restart.&lt;/p&gt;

&lt;p&gt;FetchSandbox MCP setup if you want the agent to run the workflow first:&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;"mcpServers"&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;"fetchsandbox"&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;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&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="s2"&gt;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fetchsandbox-mcp"&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="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;



</description>
      <category>clerk</category>
      <category>auth</category>
      <category>jwt</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Test Clerk auth lifecycle flows without production users</title>
      <dc:creator>FetchSandbox</dc:creator>
      <pubDate>Tue, 02 Jun 2026 04:22:48 +0000</pubDate>
      <link>https://dev.to/fetchsandbox/test-clerk-auth-lifecycle-flows-without-production-users-7cd</link>
      <guid>https://dev.to/fetchsandbox/test-clerk-auth-lifecycle-flows-without-production-users-7cd</guid>
      <description>&lt;p&gt;Most auth bugs do not come from the login button.&lt;/p&gt;

&lt;p&gt;They show up after login:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a user is created in Clerk but not in your app database&lt;/li&gt;
&lt;li&gt;a session expires while your UI still thinks the user is active&lt;/li&gt;
&lt;li&gt;a webhook arrives late or twice&lt;/li&gt;
&lt;li&gt;a deleted user still has local app state&lt;/li&gt;
&lt;li&gt;JWT verification works locally, then fails once keys rotate&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the part I want API integration agents to test before they write app code.&lt;/p&gt;

&lt;p&gt;I have been working on FetchSandbox MCP support for Clerk-style auth workflows so an IDE agent can run the auth lifecycle in a sandbox first, then wire the app around the behavior it just proved.&lt;/p&gt;

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

&lt;p&gt;For a Clerk integration, the useful test is not just:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Can I create a user?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The useful test is closer to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Can my app survive the auth lifecycle?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That includes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create or receive a user&lt;/li&gt;
&lt;li&gt;Create a session&lt;/li&gt;
&lt;li&gt;Verify the token/session state&lt;/li&gt;
&lt;li&gt;Receive a webhook event&lt;/li&gt;
&lt;li&gt;Reconcile that event into app state&lt;/li&gt;
&lt;li&gt;End or revoke the session&lt;/li&gt;
&lt;li&gt;Confirm the app no longer treats the user as active&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In real apps, each of those steps tends to live in a different place: middleware, webhook handlers, user tables, session checks, background jobs, and sometimes frontend state.&lt;/p&gt;

&lt;p&gt;That is exactly where AI coding agents need more than docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where normal AI coding breaks down
&lt;/h2&gt;

&lt;p&gt;If you ask an agent to "add Clerk auth," it can usually generate plausible code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Add middleware.
Read userId.
Create a webhook route.
Verify signatures.
Sync the user.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is useful, but it is not proof.&lt;/p&gt;

&lt;p&gt;The agent still has to guess:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;which event shape matters&lt;/li&gt;
&lt;li&gt;what your app should store&lt;/li&gt;
&lt;li&gt;what should happen when the same event arrives again&lt;/li&gt;
&lt;li&gt;what happens when the session ends&lt;/li&gt;
&lt;li&gt;whether user deletion should cascade, archive, or soft-disable local data&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are integration questions, not syntax questions.&lt;/p&gt;

&lt;h2&gt;
  
  
  What FetchSandbox MCP changes
&lt;/h2&gt;

&lt;p&gt;FetchSandbox gives an IDE agent a runnable API workflow environment.&lt;/p&gt;

&lt;p&gt;Instead of only reading docs, the agent can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;route the user's intent to a Clerk-style workflow&lt;/li&gt;
&lt;li&gt;execute the auth lifecycle in a sandbox&lt;/li&gt;
&lt;li&gt;inspect request and response payloads&lt;/li&gt;
&lt;li&gt;inspect webhook event shapes&lt;/li&gt;
&lt;li&gt;identify which app files need to handle each transition&lt;/li&gt;
&lt;li&gt;summarize proof and next steps before editing code&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The ideal loop looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;developer intent
↓
FetchSandbox MCP route
↓
run auth workflow
↓
inspect payloads and state transitions
↓
wire app code
↓
summarize proof + remaining edge cases
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That makes the agent less likely to invent a happy-path-only integration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Example prompt
&lt;/h2&gt;

&lt;p&gt;From Cursor, Claude Code, or another MCP-capable IDE:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Help me integrate Clerk auth into this app.
Before editing code, use FetchSandbox to prove the user/session lifecycle and webhook payload shape.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is "before editing code."&lt;/p&gt;

&lt;p&gt;For auth, that order matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure modes worth testing
&lt;/h2&gt;

&lt;p&gt;For a Clerk-style integration, I would rather see an agent test these before it changes the app:&lt;/p&gt;

&lt;h3&gt;
  
  
  User created
&lt;/h3&gt;

&lt;p&gt;Does the app create or update the local user record correctly?&lt;/p&gt;

&lt;h3&gt;
  
  
  Session created
&lt;/h3&gt;

&lt;p&gt;Does the app treat the session as active only when the provider state says it is active?&lt;/p&gt;

&lt;h3&gt;
  
  
  Session ended
&lt;/h3&gt;

&lt;p&gt;Does the app stop trusting stale session state?&lt;/p&gt;

&lt;h3&gt;
  
  
  User deleted
&lt;/h3&gt;

&lt;p&gt;Does the app remove access without accidentally deleting data that should be retained?&lt;/p&gt;

&lt;h3&gt;
  
  
  Webhook replay
&lt;/h3&gt;

&lt;p&gt;If the same webhook arrives twice, does the app stay idempotent?&lt;/p&gt;

&lt;h3&gt;
  
  
  Signature verification
&lt;/h3&gt;

&lt;p&gt;Does the webhook handler verify the signed payload before trusting the event?&lt;/p&gt;

&lt;p&gt;None of these are solved by a code snippet alone.&lt;/p&gt;

&lt;p&gt;They need a workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters for AI agents
&lt;/h2&gt;

&lt;p&gt;AI coding agents are very good at producing integration code.&lt;/p&gt;

&lt;p&gt;But API integrations need an execution layer.&lt;/p&gt;

&lt;p&gt;Without that layer, the agent is forced to infer behavior from docs and examples. With FetchSandbox MCP, the agent can run a workflow first and then use the result as context.&lt;/p&gt;

&lt;p&gt;The difference is subtle but important:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"Here is auth code that should work."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"Here is the auth lifecycle I ran, the payloads I saw, and the code paths I wired."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the workflow I want for API integrations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;Add FetchSandbox MCP to your IDE:&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;"mcpServers"&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;"fetchsandbox"&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;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&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="s2"&gt;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fetchsandbox-mcp"&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="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;p&gt;Then ask the agent to prove the provider workflow before implementation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Use FetchSandbox MCP to test the Clerk auth lifecycle before wiring the app.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;FetchSandbox:&lt;/p&gt;

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

&lt;p&gt;MCP:&lt;/p&gt;

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

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

&lt;p&gt;Auth integrations do not end at login.&lt;/p&gt;

&lt;p&gt;The lifecycle is the contract:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;users&lt;/li&gt;
&lt;li&gt;sessions&lt;/li&gt;
&lt;li&gt;tokens&lt;/li&gt;
&lt;li&gt;webhooks&lt;/li&gt;
&lt;li&gt;app-state reconciliation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If an AI agent is going to wire auth into your app, it should be able to run that lifecycle first.&lt;/p&gt;

&lt;p&gt;Connect the provider.&lt;/p&gt;

&lt;p&gt;Prove the workflow.&lt;/p&gt;

&lt;p&gt;Ship the integration.&lt;/p&gt;

</description>
      <category>auth</category>
      <category>mcp</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Brownfield Slack alerts: a 6-minute guided MCP run on Stripe + Resend webhooks</title>
      <dc:creator>FetchSandbox</dc:creator>
      <pubDate>Wed, 27 May 2026 14:45:18 +0000</pubDate>
      <link>https://dev.to/fetchsandbox/brownfield-slack-alerts-a-6-minute-guided-mcp-run-on-stripe-resend-webhooks-b4c</link>
      <guid>https://dev.to/fetchsandbox/brownfield-slack-alerts-a-6-minute-guided-mcp-run-on-stripe-resend-webhooks-b4c</guid>
      <description>&lt;p&gt;I recorded a &lt;strong&gt;raw ~6.5 minute&lt;/strong&gt; screen capture of my IDE running a full FetchSandbox MCP guided flow. No polish, no voiceover script — just what happens when you ask an agent to add &lt;strong&gt;Slack notifications for failed payments&lt;/strong&gt; in a repo that &lt;strong&gt;already&lt;/strong&gt; has Stripe checkout, Clerk JWT verification, and Resend receipt webhooks.&lt;/p&gt;

&lt;p&gt;The video:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;YOUTUBE_URL&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;This post is the written version of that session, with the parts that are easier to scan in prose than in a screencast.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ask (and why it is not a greenfield task)
&lt;/h2&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;add slack notifications for failed payments — should plug into the existing stripe + resend webhook handlers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repo: a small Next.js 15 + FastAPI demo (&lt;code&gt;stripe-checkout-demo&lt;/code&gt;) that had seen &lt;strong&gt;three prior integration sessions&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Session&lt;/th&gt;
&lt;th&gt;What landed&lt;/th&gt;
&lt;th&gt;Where Slack attaches&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Stripe seed&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;POST /create-payment-intent&lt;/code&gt;, &lt;code&gt;POST /webhook&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;payment_intent.payment_failed&lt;/code&gt; branch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clerk&lt;/td&gt;
&lt;td&gt;JWT dep, &lt;code&gt;POST /clerk-webhook&lt;/code&gt; (Svix)&lt;/td&gt;
&lt;td&gt;(out of scope for this alert)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resend&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;send_receipt()&lt;/code&gt;, &lt;code&gt;POST /resend-webhook&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;bounce/complaint branches + receipt try/except&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Slack is the &lt;strong&gt;fourth layer&lt;/strong&gt;. The agent cannot treat this like a tutorial repo with one file and one webhook. It has to find the exact lines where failures today only &lt;code&gt;print()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That repo reading is why a full guided run is &lt;strong&gt;~6–7 minutes&lt;/strong&gt; in the recording — not because MCP is slow, but because &lt;strong&gt;brownfield integration is mostly comprehension&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What “guided” means in practice
&lt;/h2&gt;

&lt;p&gt;FetchSandbox MCP does not jump straight to &lt;code&gt;POST /chat.completions&lt;/code&gt; style codegen. The flow I used (fs-router skill) looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;intake → introspect repo → comprehend stack + failure surfaces → guide() routing → discovery questions → prove upstream contract → (then) propose changes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 1 — Introspect
&lt;/h3&gt;

&lt;p&gt;The introspect script scanned the repo and returned framework hints (Next.js ^15, FastAPI backend) and SDK signals for Stripe in &lt;code&gt;server/main.py&lt;/code&gt;. The intent string mentioned Resend too; Slack is not in the FetchSandbox brain catalog, so it never appears as a routable spec.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — Comprehend (the load-bearing step)
&lt;/h3&gt;

&lt;p&gt;The agent catalogued &lt;strong&gt;where alerts would attach&lt;/strong&gt;, not a new &lt;code&gt;/slack-webhook&lt;/code&gt; route:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;th&gt;Today&lt;/th&gt;
&lt;th&gt;Location&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;payment_intent.payment_failed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;print()&lt;/code&gt; only&lt;/td&gt;
&lt;td&gt;webhook handler ~L95–98&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;email.bounced&lt;/code&gt; / &lt;code&gt;email.complained&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;print()&lt;/code&gt; + TODO&lt;/td&gt;
&lt;td&gt;Resend handler ~L139–145&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Receipt send raises (Resend SDK)&lt;/td&gt;
&lt;td&gt;swallowed with &lt;code&gt;print()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;inside Stripe success path ~L92–94&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The user named Stripe + Resend handlers. The comprehend step also flagged the &lt;strong&gt;receipt try/except&lt;/strong&gt; as a third visibility gap — correct webhook hygiene (do not fail Stripe retries) but operationally invisible without something like Slack.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3 — &lt;code&gt;guide()&lt;/code&gt; routing
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;guide(intent="add slack notifications for failed payments — plug into existing stripe + resend webhook handlers...")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;spec:&lt;/strong&gt; &lt;code&gt;stripe&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;workflow:&lt;/strong&gt; &lt;code&gt;accept_payment&lt;/code&gt; (Tier-1 default)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;confidence:&lt;/strong&gt; 0.85&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;reasoning:&lt;/strong&gt; no &lt;code&gt;slack&lt;/code&gt; spec; Stripe is the upstream that emits the event Slack will format&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That routing is the right call, but it needs to be said plainly: &lt;strong&gt;FetchSandbox proves the upstream event, not the Slack POST.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4 — Discovery (failures tab matters)
&lt;/h3&gt;

&lt;p&gt;Multi-tab confirmation:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Answer&lt;/th&gt;
&lt;th&gt;Implies&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Customer geo&lt;/td&gt;
&lt;td&gt;US only&lt;/td&gt;
&lt;td&gt;no 3DS scenario&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Capture&lt;/td&gt;
&lt;td&gt;Auto on confirm&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;accept_payment&lt;/code&gt; workflow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failure modes&lt;/td&gt;
&lt;td&gt;Declined card (~60s)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;payment_declined&lt;/code&gt; scenario&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you skip the failures tab, you get a happy-path proof that does not exercise the webhook your Slack message cares about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5 — Proof run (and the honest limit)
&lt;/h2&gt;

&lt;p&gt;Delegated to fs-prove-payments: &lt;code&gt;spec=stripe&lt;/code&gt;, &lt;code&gt;workflow=accept_payment&lt;/code&gt;, &lt;code&gt;scenario=payment_declined&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Sandbox import reused the bundled Stripe spec with hand-curated workflows. Run result on paper: &lt;strong&gt;6/6 steps passed&lt;/strong&gt;, webhooks verified: &lt;code&gt;payment_intent.created&lt;/code&gt;, &lt;code&gt;payment_intent.succeeded&lt;/code&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Asked for&lt;/th&gt;
&lt;th&gt;Got&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;payment_declined&lt;/code&gt; → &lt;code&gt;payment_intent.payment_failed&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Terminal &lt;code&gt;succeeded&lt;/code&gt;, success webhooks&lt;/td&gt;
&lt;td&gt;Curated &lt;code&gt;accept_payment&lt;/code&gt; uses a deterministic pass card; scenario hook exists in the engine but this Tier-1 workflow does not exercise it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same class of limit as other Stripe sessions: the &lt;strong&gt;catalog workflow&lt;/strong&gt; is a fast preflight, not a substitute for driving a real decline.&lt;/p&gt;

&lt;p&gt;To prove the handler your Slack code will sit in:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;What it proves&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;stripe listen --forward-to localhost:8000/webhook&lt;/code&gt; + card &lt;code&gt;4000000000000002&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Full PI flow → &lt;code&gt;payment_intent.payment_failed&lt;/code&gt; at your handler&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;stripe trigger payment_intent.payment_failed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fast iteration on Slack message template from a realistic payload&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What the agent got right without me asking
&lt;/h2&gt;

&lt;p&gt;Five things worth stealing for your own brownfield prompts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No new endpoint&lt;/strong&gt; — side effects only at existing handler lines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Third target surfaced&lt;/strong&gt; — receipt-send swallow, not only the two webhooks the user named.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Routing limitation named&lt;/strong&gt; — upstream Stripe proof, Slack downstream on you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency applied to Slack&lt;/strong&gt; — Stripe retries on 5xx; use &lt;code&gt;pi.id&lt;/code&gt; as dedupe key for duplicate alerts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resend proof deferred&lt;/strong&gt; — one workflow per delegation; bounce/complaint shapes need a separate Resend run.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What was not done in this recording
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;No Slack diff applied yet (proof handed back; propose-changes step not shown in the artifact).&lt;/li&gt;
&lt;li&gt;No Resend proof run for &lt;code&gt;email.bounced&lt;/code&gt; / &lt;code&gt;email.complained&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;No Slack spec proof (none in catalog).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Expected next moves from the session:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Propose &lt;code&gt;payment_intent.payment_failed&lt;/code&gt; → Slack at L95–98 (&lt;code&gt;SLACK_WEBHOOK_URL&lt;/code&gt; env).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;stripe trigger payment_intent.payment_failed&lt;/code&gt; before editing message text.&lt;/li&gt;
&lt;li&gt;Separate Resend proof for bounce/complaint templates.&lt;/li&gt;
&lt;li&gt;Ship payment-failed path first; Resend alerts in a follow-up PR.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  MCP setup (unchanged from shorter demos)
&lt;/h2&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;"mcpServers"&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;"fetchsandbox"&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;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&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="s2"&gt;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fetchsandbox-mcp"&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="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;p&gt;Cursor: &lt;code&gt;~/.cursor/mcp.json&lt;/code&gt;, restart IDE, enable server in Settings → MCP.&lt;/p&gt;

&lt;p&gt;Optional skills:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sL&lt;/span&gt; https://fetchsandbox.com/skills/install.sh | bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;Docs tell your agent what &lt;strong&gt;should&lt;/strong&gt; happen. Runnable workflows tell it what &lt;strong&gt;did&lt;/strong&gt; happen in a sandbox — and brownfield comprehend tells it &lt;strong&gt;where your code already handles failures&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;For Slack-on-Stripe, the valuable proof is not “can we call chat.postMessage” in FetchSandbox. It is: &lt;strong&gt;when &lt;code&gt;payment_intent.payment_failed&lt;/code&gt; arrives, does your handler have the fields you need for an alert, and have you tested that path with a real or triggered event?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Try FetchSandbox: &lt;a href="https://fetchsandbox.com" rel="noopener noreferrer"&gt;https://fetchsandbox.com&lt;/a&gt;&lt;br&gt;&lt;br&gt;
Stripe workflows: &lt;a href="https://fetchsandbox.com/docs/stripe" rel="noopener noreferrer"&gt;https://fetchsandbox.com/docs/stripe&lt;/a&gt;  &lt;/p&gt;

&lt;p&gt;Watch the raw demo: &lt;a href="https://youtu.be/AB36LXwfoTQ" rel="noopener noreferrer"&gt;https://youtu.be/AB36LXwfoTQ&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you have done brownfield API work with agents, I am curious: should the default flow always &lt;strong&gt;map existing handlers&lt;/strong&gt; before any proof run, or do you prefer prove-first on greenfield repos?&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>stripe</category>
      <category>webhooks</category>
      <category>api</category>
    </item>
    <item>
      <title>Test Resend webhook reconciliation before production</title>
      <dc:creator>FetchSandbox</dc:creator>
      <pubDate>Fri, 22 May 2026 03:59:54 +0000</pubDate>
      <link>https://dev.to/fetchsandbox/test-resend-webhook-reconciliation-before-production-19k5</link>
      <guid>https://dev.to/fetchsandbox/test-resend-webhook-reconciliation-before-production-19k5</guid>
      <description>&lt;p&gt;I was trying the Resend API for a small product flow, and the first &lt;code&gt;POST /emails&lt;/code&gt; call was not the part that worried me most.&lt;/p&gt;

&lt;p&gt;That call gives you an email ID.&lt;/p&gt;

&lt;p&gt;The part I care about is what happens later, when events come back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;email.delivered
email.bounced
email.opened
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Resend has docs for these webhook events:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;email.delivered&lt;/code&gt;: &lt;a href="https://resend.com/docs/webhooks/emails/delivered" rel="noopener noreferrer"&gt;https://resend.com/docs/webhooks/emails/delivered&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;email.bounced&lt;/code&gt;: &lt;a href="https://resend.com/docs/webhooks/emails/bounced" rel="noopener noreferrer"&gt;https://resend.com/docs/webhooks/emails/bounced&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;email.opened&lt;/code&gt;: &lt;a href="https://resend.com/docs/webhooks/emails/opened" rel="noopener noreferrer"&gt;https://resend.com/docs/webhooks/emails/opened&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The docs are useful. But the integration question is not just "what fields are in the payload?"&lt;/p&gt;

&lt;p&gt;The real question is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;can my app map this event back to the right email record?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The small bug that becomes annoying later
&lt;/h2&gt;

&lt;p&gt;Most apps do something like this when sending email:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;create internal notification record
send email with Resend
store resend email id
wait for webhook events
update notification status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Simple enough.&lt;/p&gt;

&lt;p&gt;But the webhook arrives later, outside the request that sent the email.&lt;/p&gt;

&lt;p&gt;So now your app needs to answer a few things correctly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;which internal record owns this Resend email ID?&lt;/li&gt;
&lt;li&gt;should &lt;code&gt;email.opened&lt;/code&gt; change the same status as &lt;code&gt;email.delivered&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;what happens if &lt;code&gt;email.bounced&lt;/code&gt; arrives after you already marked the email as sent?&lt;/li&gt;
&lt;li&gt;do you store the raw event payload for debugging?&lt;/li&gt;
&lt;li&gt;can you replay the event without creating duplicate state changes?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is hard in theory.&lt;/p&gt;

&lt;p&gt;It is just easy to skip until production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing only the send call is not enough
&lt;/h2&gt;

&lt;p&gt;If you test only this:&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
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;you have proven that Resend accepted the request.&lt;/p&gt;

&lt;p&gt;You have not proven your app can handle the lifecycle around that request.&lt;/p&gt;

&lt;p&gt;For a real product, I want to test this chain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;send email
store provider email id
receive delivered event
update internal status
receive opened or bounced event
reconcile final state
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the integration.&lt;/p&gt;

&lt;p&gt;The endpoint is just one step inside it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is awkward to test early
&lt;/h2&gt;

&lt;p&gt;To test this against the real provider path, you usually need:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Resend API key
verified sender domain
public webhook endpoint
local tunnel
event logging
test recipient
some app state to update
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That setup is reasonable before production.&lt;/p&gt;

&lt;p&gt;But when I am still exploring the API, I do not want to create all of that just to understand whether the workflow makes sense.&lt;/p&gt;

&lt;p&gt;I want to run the shape of the workflow first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The workflow I wanted from my IDE
&lt;/h2&gt;

&lt;p&gt;At that point I wanted Cursor or Claude to run a small workflow for me:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;send test email
capture email id
simulate delivered event
simulate opened or bounced event
verify app-facing state
show what changed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I tried this through FetchSandbox MCP because it gives the agent a runnable API workflow instead of only docs to read.&lt;/p&gt;

&lt;p&gt;This is not replacing Resend's real environment. I would still need the real API key, real domain, production webhook endpoint, and provider limits before shipping.&lt;/p&gt;

&lt;p&gt;The point is earlier than that.&lt;/p&gt;

&lt;p&gt;Before wiring the real app, I wanted the agent to understand the lifecycle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;request -&amp;gt; provider id -&amp;gt; webhook event -&amp;gt; internal state
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What made the validation useful
&lt;/h2&gt;

&lt;p&gt;What helped was not a fake "success" message.&lt;/p&gt;

&lt;p&gt;It was seeing a small report closer to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PASS POST /emails returned email id
PASS internal record stored provider id
PASS email.delivered event matched provider id
PASS notification status changed to delivered
PASS email.opened event was recorded without duplicating the send
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or if it fails:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FAIL webhook event could not be matched to an internal record
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the kind of failure I would rather see before production, not after users are asking why they never got an email.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try the workflow
&lt;/h2&gt;

&lt;p&gt;If you are exploring Resend, this is the workflow page I used:&lt;/p&gt;

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

&lt;p&gt;From an MCP client like Cursor or Claude, the ask is simple:&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;The workflow should not stop at &lt;code&gt;POST /emails -&amp;gt; 200&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It should prove the lifecycle after the send call makes sense.&lt;/p&gt;

&lt;p&gt;That is where most integration bugs hide.&lt;/p&gt;

</description>
      <category>resend</category>
      <category>webhooks</category>
      <category>api</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
