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

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

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

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

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

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

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

&lt;p&gt;My handler did a database insert on &lt;code&gt;subscription.created&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;subscription.created&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;subscriptions&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;paddleId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;created&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And an update on &lt;code&gt;subscription.activated&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;subscription.activated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;subscriptions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;subscriptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paddleId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

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

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

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

&lt;p&gt;The handler needs to be idempotent and order-independent. Every event should be able to create or update:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;subscription.created&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;subscription.activated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;event_type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;subscription.activated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; 
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;created&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;subscriptions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;paddleId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onConflictDoUpdate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;subscriptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paddleId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;set&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; 
        &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`CASE WHEN &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; = 'active' THEN 'active' ELSE &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;subscriptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; END`&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

&lt;p&gt;Bad handlers do something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sessionJwt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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




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

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

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

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

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

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

&lt;p&gt;Most handlers start like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/webhooks/resend&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;emailId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findByResendId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;emailId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;email.delivered&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;delivered&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;email.opened&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;opened&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;email.bounced&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bounced&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

&lt;p&gt;A pattern that survives production:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TERMINAL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bounced&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;complained&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;nextDeliveryState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;incoming&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;TERMINAL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;incoming&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bounced&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;incoming&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;complained&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;incoming&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;incoming&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;delivered&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;delivered&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;incoming&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/webhooks/resend&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;emailId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findByResendId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;emailId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhookInbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;resend&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;emailId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;email.opened&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;email.clicked&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;emailEvents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;notificationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;nextDeliveryState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deliveryStatus&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;email.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;deliveryStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;lastEventType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;lastEventAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

&lt;/div&gt;



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

&lt;p&gt;The diff is the bug.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sentInboxIds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;inbox_tenant_123&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;created&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;agentmail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/v0/webhooks&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://app.example.com/webhooks/agentmail&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;event_types&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message.delivered&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message.bounced&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;inbox_ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sentInboxIds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stored&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;agentmail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/v0/webhooks/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;created&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;got&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...(&lt;/span&gt;&lt;span class="nx"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inbox_ids&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[])].&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;want&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;sentInboxIds&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;got&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;want&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AgentMail webhook scope drifted&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole check. It is not a bigger mock. It is a read after the write.&lt;/p&gt;

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



&lt;p&gt;The important field is the stable provider ID, not the local row ID and not the email address.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;clerkUserId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;update&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email_addresses&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;email_address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;firstName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;first_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;lastName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;last_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;clerkUpdatedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;updated_at&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;clerkUserId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email_addresses&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;email_address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;firstName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;first_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;lastName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;last_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;clerkUpdatedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;updated_at&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one constraint does most of the work:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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

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

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

&lt;/div&gt;



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

</description>
      <category>clerk</category>
      <category>webhooks</category>
      <category>auth</category>
      <category>webdev</category>
    </item>
    <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>
  </channel>
</rss>
