<?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: sms-florin</title>
    <description>The latest articles on DEV Community by sms-florin (@flovoice53tech).</description>
    <link>https://dev.to/flovoice53tech</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%2F4045659%2F295b27d3-a935-4087-b35a-c1ca91669c5b.png</url>
      <title>DEV Community: sms-florin</title>
      <link>https://dev.to/flovoice53tech</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/flovoice53tech"/>
    <language>en</language>
    <item>
      <title>Before you submit your MCP server to Claude's Connectors Directory: a checklist from Anthropic's own review criteria</title>
      <dc:creator>sms-florin</dc:creator>
      <pubDate>Fri, 13 Nov 2026 05:11:29 +0000</pubDate>
      <link>https://dev.to/flovoice53tech/before-you-submit-your-mcp-server-to-claudes-connectors-directory-a-checklist-from-anthropics-4ab0</link>
      <guid>https://dev.to/flovoice53tech/before-you-submit-your-mcp-server-to-claudes-connectors-directory-a-checklist-from-anthropics-4ab0</guid>
      <description>&lt;p&gt;I have two MCP servers waiting in the Connectors Directory review queue right now, so I went through Anthropic's published criteria line by line and turned them into a checklist. Everything below comes from the official docs (links at the end). I do not know the inside of the review, only what Anthropic publishes, and I cannot promise acceptance for anyone, including myself.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Split read and write tools.&lt;/strong&gt; A single tool that accepts both safe HTTP methods (GET, HEAD, OPTIONS) and unsafe ones (POST, PUT, PATCH, DELETE) is rejected. No catch-all &lt;code&gt;api_request&lt;/code&gt; tool with a &lt;code&gt;method&lt;/code&gt; parameter. Writing "safe versus unsafe" in the description does not count; the operations must be separate tools, ideally split further into create, update and delete.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Annotate every tool.&lt;/strong&gt; Each tool needs a &lt;code&gt;title&lt;/code&gt; and &lt;code&gt;readOnlyHint: true&lt;/code&gt; or &lt;code&gt;destructiveHint: true&lt;/code&gt;. Read-only tools can run without per-call confirmation, destructive ones always prompt.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Name the API in custom query tools.&lt;/strong&gt; If a tool takes freeform paths, query strings or bodies, its description must link to or name the target API. "Makes a request to the API" fails.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Keep tool names to 64 characters or fewer&lt;/strong&gt; and descriptions accurate. The description has to match what the tool really does.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Remove anything that tells Claude how to behave.&lt;/strong&gt; Descriptions are rejected if they tell Claude to call tools the user did not ask for, interfere with other tools, pull instructions from external sources, hide or encode instructions, override system instructions or promote products.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Make errors useful.&lt;/strong&gt; "Internal Server Error" with no detail fails. Validate inputs and return messages that say what to fix. Do not return a full database dump when a summary was asked for.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Do not collect more than the tool needs&lt;/strong&gt;, and never query Claude's memory, chat history or the user's files.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Prepare the submission pieces:&lt;/strong&gt; an HTTPS remote URL, OAuth 2.0 if tools act on a user account, a fully populated test account, public documentation by your publish date, a privacy policy, a support contact and an icon.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Test every tool twice:&lt;/strong&gt; in MCP Inspector and as a custom connector inside Claude. The portal asks you to confirm it.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Security belongs on the same list
&lt;/h2&gt;

&lt;p&gt;Anthropic requires servers to meet its security standards and to respond to security issues promptly. The two classic mistakes in MCP code are passing a caller's URL straight to &lt;code&gt;fetch&lt;/code&gt; (server-side request forgery) and building a shell command from a tool argument. A free static scanner I wrote checks for both, plus leaked keys: &lt;a href="https://gitlab.com/florin-builds/mcp-ssrf-scan" rel="noopener noreferrer"&gt;https://gitlab.com/florin-builds/mcp-ssrf-scan&lt;/a&gt;. It finds patterns, not proof. On 36 public servers it produced mostly noise, so read every hit.&lt;/p&gt;

&lt;p&gt;If you would rather have a person do the pass, I offer a fixed-price review: &lt;a href="https://audit.flo-voice1.com" rel="noopener noreferrer"&gt;https://audit.flo-voice1.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Sources: Anthropic docs, "Connector pre-submission checklist" and "Submit a connector to the directory" (&lt;a href="https://claude.com/docs/connectors/building/submission" rel="noopener noreferrer"&gt;https://claude.com/docs/connectors/building/submission&lt;/a&gt;).&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>security</category>
      <category>claude</category>
    </item>
    <item>
      <title>I told my own AI agent to steal a password. 21 attacks, 0 escapes. I still didn't place.</title>
      <dc:creator>sms-florin</dc:creator>
      <pubDate>Wed, 11 Nov 2026 15:58:11 +0000</pubDate>
      <link>https://dev.to/flovoice53tech/i-told-my-own-ai-agent-to-steal-a-password-21-attacks-0-escapes-i-still-didnt-place-6h8</link>
      <guid>https://dev.to/flovoice53tech/i-told-my-own-ai-agent-to-steal-a-password-21-attacks-0-escapes-i-still-didnt-place-6h8</guid>
      <description>&lt;p&gt;I built Signup Sandbox for the Vultr Agent Rush hackathon. It is an AI agent that walks a website's signup like a new user: fills the form, reads the verification email from a real inbox, enters the code, and tells you where it breaks ("the confirmation link returns HTTP 500"). The browser runs in a throwaway sandbox that can reach only the site under test.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the sandbox does
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Browser on gVisor, no capabilities, read-only filesystem, per-run network with no route out.&lt;/li&gt;
&lt;li&gt;Only exit is an egress proxy that allows the site under test, refuses private addresses (including the cloud metadata service) and checks the name inside every HTTPS tunnel.&lt;/li&gt;
&lt;li&gt;The LLM key never enters the sandbox; the model only ever sees placeholders like {{password}}, never the real one.&lt;/li&gt;
&lt;li&gt;Everything is deleted after each run.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How I tested it
&lt;/h2&gt;

&lt;p&gt;A model-free benchmark fires 21 attack pages (beacons, WebSockets, redirects, the metadata service, secrets in DNS lookups, domain fronting, direct host connections). Escapes are counted by my own HTTP and DNS canaries outside the sandbox. Result: 0 requests out, 0 DNS lookups, 0 host ports, while the outside controls were counted. In red-team mode a second model, told to obey the page, tried to send the test password and read the metadata service. Both were blocked.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I got wrong
&lt;/h2&gt;

&lt;p&gt;It did not place. 351 projects were submitted. The top three also contained agents in sandboxes, but around a problem with real stakes (untrusted pull requests, payment fraud, supplier passwords) and with a with/without comparison. One of them measured a browser holding the password reaching an unbound host 15 of 15 times, versus 0 of 15 with their design. My benchmark says the door is closed, but it has no baseline showing what an open door looks like, and what I protected was a throwaway test password on the customer's own signup page.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I take from it
&lt;/h2&gt;

&lt;p&gt;Pick the buyer with an expensive problem first, then the tech. Measure "without us vs with us", not just "0 escapes". Read the other entries before the last day.&lt;/p&gt;

&lt;p&gt;Code (MIT): &lt;a href="https://github.com/flovoice53-tech/signup-sandbox" rel="noopener noreferrer"&gt;https://github.com/flovoice53-tech/signup-sandbox&lt;/a&gt;&lt;br&gt;
Live demo: &lt;a href="https://sandbox.flo-voice1.com" rel="noopener noreferrer"&gt;https://sandbox.flo-voice1.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>docker</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I scanned 36 public MCP servers for SSRF. 482 findings, 0 real bugs.</title>
      <dc:creator>sms-florin</dc:creator>
      <pubDate>Wed, 11 Nov 2026 14:35:22 +0000</pubDate>
      <link>https://dev.to/flovoice53tech/i-scanned-36-public-mcp-servers-for-ssrf-482-findings-0-real-bugs-4kh9</link>
      <guid>https://dev.to/flovoice53tech/i-scanned-36-public-mcp-servers-for-ssrf-482-findings-0-real-bugs-4kh9</guid>
      <description>&lt;p&gt;I built a small static scanner for MCP servers after I found a real SSRF bug in one of my own webhook routes. Then I ran it on 36 public MCP servers to see how bad the ecosystem looked.&lt;/p&gt;

&lt;p&gt;First result: 482 findings. Second result, after I read them: almost none were real.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the noise was
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;e2e and test helpers that call &lt;code&gt;exec()&lt;/code&gt; on purpose&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;db.exec()&lt;/code&gt; and &lt;code&gt;RegExp.exec()&lt;/code&gt;, which share a name with &lt;code&gt;child_process.exec&lt;/code&gt; but have nothing to do with it&lt;/li&gt;
&lt;li&gt;strings and comments that contain "http://" or a secret-looking token&lt;/li&gt;
&lt;li&gt;placeholder keys like &lt;code&gt;sk_test_xxx&lt;/code&gt; in READMEs and examples&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I changed
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Skip test, e2e and scripts directories (opt back in with &lt;code&gt;--include-tests&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Flag SSRF only when the caller controls the host of the URL, not just a path or query&lt;/li&gt;
&lt;li&gt;Ignore matches inside strings and comments&lt;/li&gt;
&lt;li&gt;Flag exec-injection only for &lt;code&gt;child_process&lt;/code&gt; and &lt;code&gt;new Function&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Filter placeholder secrets&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same 36 repos after the fix: 28 findings. I read all of them. They are shell strings that interpolate paths or dates. None are exploitable as far as I can tell, and I did not find anything worth disclosing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I take from it
&lt;/h2&gt;

&lt;p&gt;A regex scanner will happily produce a scary number. If I had published "482 issues in 36 MCP servers" it would have been wrong. Counting findings is not the same as counting vulnerabilities, so any "X% of MCP servers are vulnerable" figure is only as good as the triage behind it.&lt;/p&gt;

&lt;p&gt;The tool is free and open source: &lt;code&gt;npx @sms-florin/mcp-ssrf-scan &amp;lt;path&amp;gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://gitlab.com/florin-builds/mcp-ssrf-scan" rel="noopener noreferrer"&gt;https://gitlab.com/florin-builds/mcp-ssrf-scan&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you would rather have a person read your server and check it against Anthropic's Connectors Directory criteria, I offer a fixed-price review: &lt;a href="https://audit.flo-voice1.com" rel="noopener noreferrer"&gt;https://audit.flo-voice1.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>security</category>
      <category>node</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Replacing Mailinator in your CI tests: private inboxes, no signup</title>
      <dc:creator>sms-florin</dc:creator>
      <pubDate>Wed, 11 Nov 2026 14:02:07 +0000</pubDate>
      <link>https://dev.to/flovoice53tech/replacing-mailinator-in-your-ci-tests-private-inboxes-no-signup-2856</link>
      <guid>https://dev.to/flovoice53tech/replacing-mailinator-in-your-ci-tests-private-inboxes-no-signup-2856</guid>
      <description>&lt;p&gt;Mailinator is great for eyeballing one email. In automated tests it has two problems: the free inboxes are public, and API access needs a paid plan or approval. If your test reads a verification code from a public inbox, anyone who guesses the address can read it, or land on the same inbox as your test.&lt;/p&gt;

&lt;p&gt;What I use instead is &lt;a href="https://receivemail.dev" rel="noopener noreferrer"&gt;receivemail.dev&lt;/a&gt;: one request creates a random address plus a secret, and only the secret can read the mail. No signup, no key.&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;test&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s2"&gt;"playwright-receivemail"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"new user verifies their email"&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="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mailbox&lt;/span&gt; &lt;span class="p"&gt;}&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"/signup"&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="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Email"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mailbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;address&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="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"button"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"Create account"&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;click&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="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;mailbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;\&lt;span class="n"&gt;b&lt;/span&gt;\&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;\&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&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="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Verification code"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&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="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;Tradeoffs: messages live 60 minutes on the free tier, there is no custom domain, and it's email only. Side-by-side with Mailinator, Mailosaur and others: &lt;a href="https://receivemail.dev/compare/mailinator-alternative" rel="noopener noreferrer"&gt;https://receivemail.dev/compare/mailinator-alternative&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I built it, so weigh that accordingly.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>playwright</category>
      <category>cypress</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to Check That Your OTP SMS and Emails Actually Arrive (Synthetic Monitoring in CI)</title>
      <dc:creator>sms-florin</dc:creator>
      <pubDate>Thu, 01 Oct 2026 08:00:37 +0000</pubDate>
      <link>https://dev.to/flovoice53tech/how-to-check-that-your-otp-sms-and-emails-actually-arrive-synthetic-monitoring-in-ci-3npa</link>
      <guid>https://dev.to/flovoice53tech/how-to-check-that-your-otp-sms-and-emails-actually-arrive-synthetic-monitoring-in-ci-3npa</guid>
      <description>&lt;p&gt;Your uptime monitor says the login API returned 200. Your SMS provider's dashboard says "sent". And yet users can't log in, because the verification code never showed up.&lt;/p&gt;

&lt;p&gt;That gap is common and hard to see: an expired sender ID, a template that started landing in spam, a carrier quietly filtering a route, a provider incident where the API keeps returning success. Delivery receipts help, but they come from the same system you're trying to verify.&lt;/p&gt;

&lt;p&gt;The reliable way to know is a &lt;strong&gt;synthetic check&lt;/strong&gt;: on a schedule, trigger a real OTP send to a real phone number or inbox you control, and measure whether (and how fast) it arrives.&lt;/p&gt;

&lt;p&gt;This post shows how to run that check from CI or cron. Disclosure: I built &lt;a href="https://otpwatch.flo-voice1.com" rel="noopener noreferrer"&gt;otp-watch&lt;/a&gt;, the small API used below, because I wanted this for my own projects and didn't find a simple version.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the check works
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Ask for a target: a fresh email inbox or a real UK mobile number.&lt;/li&gt;
&lt;li&gt;Trigger your own app's OTP send to that target (the same code path real users hit).&lt;/li&gt;
&lt;li&gt;Poll until the result is &lt;code&gt;received&lt;/code&gt; (with latency in ms) or &lt;code&gt;timed_out&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Fail the job on &lt;code&gt;timed_out&lt;/code&gt;, so your existing CI alerting does the paging.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  1. Get an API key
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://otpwatch.flo-voice1.com/keys &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'content-type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"email":"you@example.com"}'&lt;/span&gt;
&lt;span class="c"&gt;# =&amp;gt; {"key":"..."}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. Start a check
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://otpwatch.flo-voice1.com/checks &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$OTP_WATCH_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'content-type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"channel":"email","timeoutSeconds":120}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response gives you a check id and the target to send to:&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;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"GEUOHivIN-F_i4AN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"channel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pending"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"target"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0a625bab1a59@receivemail.dev"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"timeoutSeconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;120&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;Use &lt;code&gt;"channel":"sms"&lt;/code&gt; and &lt;code&gt;target&lt;/code&gt; is a real UK mobile number on a physical SIM (not VoIP), so it receives codes the way your users' phones do.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Trigger your send, then poll
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://otpwatch.flo-voice1.com/checks/&lt;span class="nv"&gt;$ID&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$OTP_WATCH_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# =&amp;gt; {"status":"received","latencyMs":4210,...}&lt;/span&gt;
&lt;span class="c"&gt;# or {"status":"timed_out",...}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;timeoutSeconds&lt;/code&gt; can be anything from 10 to 600 (default 120).&lt;/p&gt;

&lt;h2&gt;
  
  
  A complete CI step
&lt;/h2&gt;

&lt;p&gt;This is the whole thing as a shell step you can drop into GitHub Actions, GitLab CI, or a cron job. Replace the middle line with however your app triggers a verification send (a test endpoint, a signup call, a Playwright run).&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="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;span class="nv"&gt;API&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://otpwatch.flo-voice1.com

&lt;span class="nv"&gt;CHECK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nv"&gt;$API&lt;/span&gt;/checks &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$OTP_WATCH_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'content-type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"channel":"email","timeoutSeconds":120}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CHECK&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; .id&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;TARGET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CHECK&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; .target&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Trigger YOUR app's OTP send to $TARGET here, e.g.:&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://your-app.example/api/test/send-otp &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;to&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$TARGET&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;}"&lt;/span&gt;

&lt;span class="k"&gt;until &lt;/span&gt;&lt;span class="nv"&gt;S&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nv"&gt;$API&lt;/span&gt;/checks/&lt;span class="nv"&gt;$ID&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$OTP_WATCH_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; .status&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$S&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; pending &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;5
&lt;span class="k"&gt;done

&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"OTP delivery: &lt;/span&gt;&lt;span class="nv"&gt;$S&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$S&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; received &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it every 15 or 30 minutes and you'll know about a broken OTP path before your users tell you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Email checks are free and unlimited (rate-limited to 30 checks per hour per key).&lt;/li&gt;
&lt;li&gt;Each key gets 3 free one-off SMS checks; &lt;code&gt;GET /usage&lt;/code&gt; shows what's left.&lt;/li&gt;
&lt;li&gt;For frequent SMS checks there's a monitor: one dedicated number for 30 days, unlimited checks against it, €29/month. Reusing one number is also what makes scheduled SMS checks practical, since you're not renting a new number every run.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What this doesn't replace
&lt;/h2&gt;

&lt;p&gt;A synthetic check tells you the path works for one test number or inbox. It doesn't replace your provider's delivery receipts or per-country monitoring at scale. It's the cheap, independent "does a real code actually arrive" signal that's missing from most setups.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://otpwatch.flo-voice1.com" rel="noopener noreferrer"&gt;otp-watch&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://otpwatch.flo-voice1.com/faq" rel="noopener noreferrer"&gt;FAQ: pricing, timeouts, CI usage, limits&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://otpwatch.flo-voice1.com/guides/monitoring-otp-delivery" rel="noopener noreferrer"&gt;Guide: monitoring OTP delivery&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Questions or edge cases from your own OTP setup are very welcome in the comments.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>devops</category>
      <category>monitoring</category>
      <category>api</category>
    </item>
    <item>
      <title>Testing Email Verification in Playwright with a Free Disposable Inbox (No Signup, No API Key)</title>
      <dc:creator>sms-florin</dc:creator>
      <pubDate>Thu, 01 Oct 2026 08:00:01 +0000</pubDate>
      <link>https://dev.to/flovoice53tech/testing-email-verification-in-playwright-with-a-free-disposable-inbox-no-signup-no-api-key-1efl</link>
      <guid>https://dev.to/flovoice53tech/testing-email-verification-in-playwright-with-a-free-disposable-inbox-no-signup-no-api-key-1efl</guid>
      <description>&lt;p&gt;Most Playwright suites stop right before the hard part of a signup flow: the "confirm your email" link, the emailed 6-digit code, the password-reset mail. The usual reasons are that you need a real inbox your test can read, and every option seems to want an account, an API key, or a paid plan.&lt;/p&gt;

&lt;p&gt;This post shows how to test those flows end to end with a disposable inbox that needs &lt;strong&gt;no signup and no API key&lt;/strong&gt;. Full disclosure: I built the inbox service (&lt;a href="https://receivemail.dev" rel="noopener noreferrer"&gt;receivemail.dev&lt;/a&gt;) and the small Playwright fixture used below, because I wanted exactly this for my own projects.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea: one fresh inbox per test
&lt;/h2&gt;

&lt;p&gt;The pattern that keeps email tests reliable:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create a brand-new inbox for each test (no shared state, no stale mail from a previous run).&lt;/li&gt;
&lt;li&gt;Sign up in your app with that address.&lt;/li&gt;
&lt;li&gt;Poll the inbox until the expected email arrives, with a timeout.&lt;/li&gt;
&lt;li&gt;Pull the link or code out of the body and finish the flow.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i &lt;span class="nt"&gt;-D&lt;/span&gt; playwright-receivemail
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;@playwright/test&lt;/code&gt; is a peer dependency, so nothing else to add.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test a "verify your email" link
&lt;/h2&gt;

&lt;p&gt;Import &lt;code&gt;test&lt;/code&gt; and &lt;code&gt;expect&lt;/code&gt; from the package instead of &lt;code&gt;@playwright/test&lt;/code&gt;. You get a &lt;code&gt;mailbox&lt;/code&gt; fixture: a fresh &lt;code&gt;@receivemail.dev&lt;/code&gt; address for every test.&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;playwright-receivemail&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;a new user can verify their email&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;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mailbox&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/signup&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByLabel&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="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mailbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;address&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Password&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;test-Pa55w0rd&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;button&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;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Create account&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// Wait for the email, then pull the verification link out of it&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;link&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;mailbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/https:&lt;/span&gt;&lt;span class="se"&gt;\/\/[^\s&lt;/span&gt;&lt;span class="sr"&gt;"&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;verify&lt;/span&gt;&lt;span class="se"&gt;[^\s&lt;/span&gt;&lt;span class="sr"&gt;"&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&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;subjectContains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;verify&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByText&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 verified&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBeVisible&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;Adjust the regex and the final assertion to your app's link format and confirmation text.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test an emailed OTP code
&lt;/h2&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;code&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;mailbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b\d{6}\b&lt;/span&gt;&lt;span class="sr"&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Verification code&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Assert on the whole message
&lt;/h2&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;msg&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;mailbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;fromContains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;noreply@yourapp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Welcome&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;waitForMessage&lt;/code&gt; polls every 2 seconds and throws after 30 seconds by default (both configurable). By default it also ignores anything that was already in the mailbox before the test started, so a re-used address can't make a test pass on yesterday's email.&lt;/p&gt;

&lt;h2&gt;
  
  
  Already extending &lt;code&gt;test&lt;/code&gt;?
&lt;/h2&gt;

&lt;p&gt;Compose it instead of replacing your own fixtures:&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;test&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@playwright/test&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;withReceivemail&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;playwright-receivemail&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;test&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;withReceivemail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ttlMinutes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mailboxes live 15 minutes by default (1 to 60 configurable), which is plenty for a CI run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Without the fixture (any test runner)
&lt;/h2&gt;

&lt;p&gt;The fixture is a thin wrapper over two HTTP calls, so you can use the same inbox from Cypress, Jest, a shell script, or anything else:&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;# 1. create an inbox: returns { address, expiresAt, secret }&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://receivemail.dev/mailboxes

&lt;span class="c"&gt;# 2. read it (the secret from step 1 is the bearer token)&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://receivemail.dev/mailboxes/&amp;lt;address&amp;gt;/messages &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"authorization: Bearer &amp;lt;secret&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's also a Cypress plugin (&lt;code&gt;cypress-receivemail&lt;/code&gt;) with &lt;code&gt;cy.createMailbox()&lt;/code&gt; and &lt;code&gt;cy.waitForEmail()&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  When not to use this
&lt;/h2&gt;

&lt;p&gt;This is built for test environments: short-lived inboxes for test accounts. Each inbox can only be read with the secret returned when it was created, but expired inboxes and their mail are wiped in a daily cleanup, so don't point real users or anything you need to keep at it. If you need long retention or an SLA, use a paid service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://receivemail.dev" rel="noopener noreferrer"&gt;receivemail.dev&lt;/a&gt;: the inbox API, free&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.npmjs.com/package/playwright-receivemail" rel="noopener noreferrer"&gt;playwright-receivemail on npm&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.npmjs.com/package/cypress-receivemail" rel="noopener noreferrer"&gt;cypress-receivemail on npm&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you try it, I'd like to hear what broke or what was missing.&lt;/p&gt;

</description>
      <category>playwright</category>
      <category>testing</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I added WebMCP to a live Stripe checkout in ~40 lines</title>
      <dc:creator>sms-florin</dc:creator>
      <pubDate>Wed, 02 Sep 2026 05:35:13 +0000</pubDate>
      <link>https://dev.to/flovoice53tech/i-added-webmcp-to-a-live-stripe-checkout-in-40-lines-4ekk</link>
      <guid>https://dev.to/flovoice53tech/i-added-webmcp-to-a-live-stripe-checkout-in-40-lines-4ekk</guid>
      <description>&lt;p&gt;Most "an AI agent buys something on a website" demos are toy stores built for the demo. I wanted to know if &lt;a href="https://github.com/webmachinelearning/webmcp" rel="noopener noreferrer"&gt;WebMCP&lt;/a&gt; could go onto a product that already takes real money — without rebuilding the checkout and without opening a security hole.&lt;/p&gt;

&lt;p&gt;So I put it on &lt;a href="https://flo-voice1.com/esim" rel="noopener noreferrer"&gt;my eSIM store&lt;/a&gt;. It sells data plans, has live Stripe billing, and has paying customers. Here's what that took.&lt;/p&gt;

&lt;h2&gt;
  
  
  What WebMCP is, in one paragraph
&lt;/h2&gt;

&lt;p&gt;A web page calls &lt;code&gt;document.modelContext.registerTool()&lt;/code&gt; to hand a browsing AI agent named, callable actions — instead of the agent reading your HTML and guessing which button to click. It's a W3C Community Group standard; ChatGPT's in-app browser added support in August 2026. No &lt;code&gt;.well-known&lt;/code&gt; file, no manifest, no separate server. It's client-side JS.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two tools
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;list_esim_plans&lt;/code&gt; — returns the live plan catalog (country, data, duration, price).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;buy_esim_plan&lt;/code&gt; — takes a plan slug, creates a &lt;strong&gt;real Stripe Checkout session&lt;/strong&gt;, and returns the URL to the agent. No money moves until a human enters card details on Stripe's own page. The tool &lt;em&gt;starts&lt;/em&gt; a purchase; it doesn't complete a charge.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An agent goes from "find me a 7-day UK data plan" to a fully-formed Stripe checkout in two calls, no DOM scraping.&lt;/p&gt;

&lt;h2&gt;
  
  
  The registration
&lt;/h2&gt;

&lt;p&gt;One client component, mounted on the page. On mount it feature-detects &lt;code&gt;document.modelContext&lt;/code&gt; and, if present, registers the two tools. If absent — every normal browser — it's a silent no-op, so it ships to production safely. The whole file is ~40 lines of registration plus two &lt;code&gt;execute()&lt;/code&gt; functions that each do one &lt;code&gt;fetch&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Full source: &lt;a href="https://github.com/flovoice53-tech/sms-florin-webmcp-demo" rel="noopener noreferrer"&gt;https://github.com/flovoice53-tech/sms-florin-webmcp-demo&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The one design decision that mattered
&lt;/h2&gt;

&lt;p&gt;Not the protocol — the protocol is tiny. The decision was: &lt;strong&gt;the agent path reuses the human path.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The human "Buy" button and the &lt;code&gt;buy_esim_plan&lt;/code&gt; tool both call the same &lt;code&gt;createCheckoutSession()&lt;/code&gt; function. Same pricing, same metadata, same Stripe config. There is no separate "agent checkout" that can drift out of sync with the real one. The tool's endpoint just returns the session URL as JSON instead of doing a server-side redirect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Safety
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Feature-detected: no effect on any non-WebMCP browser.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;buy_esim_plan&lt;/code&gt; cannot move money — worst case is a created-but-unpaid Stripe session that expires on its own.&lt;/li&gt;
&lt;li&gt;The backing endpoints (&lt;code&gt;GET /api/v1/esim-catalog&lt;/code&gt;, &lt;code&gt;POST /api/v1/esim-checkout&lt;/code&gt;) are unauthenticated — same guest-checkout model the human form already uses — but IP rate-limited.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Result
&lt;/h2&gt;

&lt;p&gt;It's live at &lt;a href="https://flo-voice1.com/esim" rel="noopener noreferrer"&gt;https://flo-voice1.com/esim&lt;/a&gt; right now. Integration code (MIT): &lt;a href="https://github.com/flovoice53-tech/sms-florin-webmcp-demo" rel="noopener noreferrer"&gt;https://github.com/flovoice53-tech/sms-florin-webmcp-demo&lt;/a&gt; . 2-minute walkthrough: &lt;a href="https://youtu.be/yd-2FVXbW8s" rel="noopener noreferrer"&gt;https://youtu.be/yd-2FVXbW8s&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you're wrapping an existing product: don't fork your checkout for the agent. Make the agent call the same function your button does.&lt;/p&gt;

</description>
      <category>webmcp</category>
      <category>ai</category>
      <category>javascript</category>
      <category>showdev</category>
    </item>
    <item>
      <title>WebMCP is not MCP: I wired real Stripe checkout into a browser AI tool, here's the code</title>
      <dc:creator>sms-florin</dc:creator>
      <pubDate>Sun, 30 Aug 2026 06:20:43 +0000</pubDate>
      <link>https://dev.to/flovoice53tech/webmcp-is-not-mcp-i-wired-real-stripe-checkout-into-a-browser-ai-tool-heres-the-code-546j</link>
      <guid>https://dev.to/flovoice53tech/webmcp-is-not-mcp-i-wired-real-stripe-checkout-into-a-browser-ai-tool-heres-the-code-546j</guid>
      <description>&lt;h2&gt;
  
  
  WebMCP is not MCP: I wired real Stripe checkout into a browser AI tool, here's the code
&lt;/h2&gt;

&lt;p&gt;WebMCP shipped as a spec four days ago (August 25, 2026, W3C Web Machine Learning Community Group), and the same day OpenAI added support for it in ChatGPT Desktop's browser. I spent yesterday wiring it into a live product — real inventory, real Stripe checkout, real money — instead of a toy demo, and I want to write down what that actually looks like in code, because most of what's out there right now is spec text and announcement posts.&lt;/p&gt;

&lt;h3&gt;
  
  
  WebMCP is not MCP. The name is doing you a disservice.
&lt;/h3&gt;

&lt;p&gt;MCP (Model Context Protocol, Anthropic, late 2024) is a client-server protocol: your AI client — Claude Desktop, an IDE, whatever — connects out to a server process you run, over stdio or HTTP, and that server exposes tools. The server has no idea it's running inside a browser or a chat client. It just answers JSON-RPC calls.&lt;/p&gt;

&lt;p&gt;WebMCP flips that. It's a browser API: &lt;code&gt;document.modelContext.registerTool()&lt;/code&gt;. A &lt;strong&gt;web page&lt;/strong&gt;, running in a user's actual browser tab, registers tools directly with whatever AI agent is embedded in or driving that browser. There's no separate server process, no protocol handshake, no client you have to configure to "connect" to your site. If the page is open and the browser is WebMCP-aware, the tools exist. If it's a normal browser, &lt;code&gt;document.modelContext&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt; and nothing happens — it's naturally feature-detected, not an opt-in flag you flip.&lt;/p&gt;

&lt;p&gt;Practically: MCP is "give an AI client a new server to talk to." WebMCP is "give this specific page's visitor's agent something to click for them, without it having to guess at your DOM." Same three letters in the acronym, unrelated plumbing.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I built: two tools on a real product page
&lt;/h3&gt;

&lt;p&gt;sms-florin sells phone numbers and eSIMs — the eSIM catalog page (&lt;code&gt;/esim&lt;/code&gt;) is real: 10 SKUs across 5 countries, live Stripe Checkout, no test mode. I added a client component that registers two tools the moment the page mounts, and does nothing if &lt;code&gt;document.modelContext&lt;/code&gt; isn't there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/app/esim/webmcp-tools.tsx&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;WebmcpTools&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;useEffect&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;modelContext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;modelContext&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;registerTool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(...)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;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;modelContext&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;modelContext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;controller&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;AbortController&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nx"&gt;modelContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerTool&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;list_esim_plans&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
          &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;List available eSIM data plans (country, data amount, duration, price in USD).&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;inputSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;properties&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="na"&gt;execute&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="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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/v1/esim-catalog&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="k"&gt;await&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;json&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nx"&gt;modelContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerTool&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;buy_esim_plan&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
          &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Start a real purchase for an eSIM plan by its slug (from list_esim_plans). &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;Navigates to a real Stripe checkout page — no charge happens until a human &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;enters card details there.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;inputSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Plan slug, e.g. 'uk-1gb-7d'&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;required&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;slug&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;execute&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;slug&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/v1/esim-checkout&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;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;headers&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;content-type&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;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="na"&gt;body&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;slug&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;data&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;checkoutUrl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&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;checkoutUrl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`Redirecting to checkout: &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;checkoutUrl&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="p"&gt;}&lt;/span&gt;
          &lt;span class="k"&gt;return&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;error&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Could not start checkout.&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="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&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;Two things worth calling out. First, &lt;code&gt;registerTool&lt;/code&gt;'s &lt;code&gt;execute&lt;/code&gt; runs &lt;em&gt;in the page's own JS context&lt;/em&gt;, with the page's own cookies, origin, and CSP — it's not a sandboxed RPC to some remote definition. Second, the &lt;code&gt;AbortController&lt;/code&gt; cleanup matters more than it looks: if this were a SPA route and the component unmounted, stale tools with closures over a dead page would otherwise sit registered.&lt;/p&gt;

&lt;p&gt;Both tools call &lt;strong&gt;the same public JSON endpoints the page itself would use if this were rendered as an API instead of HTML&lt;/strong&gt; — &lt;code&gt;GET /api/v1/esim-catalog&lt;/code&gt; and &lt;code&gt;POST /api/v1/esim-checkout&lt;/code&gt;. I didn't invent a new surface for the agent; I exposed the existing data and existing checkout flow as JSON, because a WebMCP tool's &lt;code&gt;execute()&lt;/code&gt; needs to &lt;code&gt;return&lt;/code&gt; a value or navigate — it can't do a server-side redirect the way a Next.js form action can.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the checkout logic lives in one shared function
&lt;/h3&gt;

&lt;p&gt;The interesting design decision wasn't the tool registration, it was making sure the AI-agent path and the human path are &lt;em&gt;provably&lt;/em&gt; the same Stripe session logic, not two implementations that can drift.&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="c1"&gt;// src/lib/esim-checkout.ts&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;createEsimCheckoutSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;slug&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="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&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="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;plan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getEsimPlanBySlug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&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;plan&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;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;Invalid plan.&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;planLabel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;label&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="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataAmount&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="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;durationDays&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; days`&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;stripe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getStripe&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;session&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;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;checkout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sessions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;payment&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;payment_method_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;card&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;billing_address_collection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;required&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;line_items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
      &lt;span class="na"&gt;price_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;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;usd&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;product_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;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`eSIM — &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;planLabel&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;unit_amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;priceCents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;esim&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;packageCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;packageCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;planLabel&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;success_url&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="nx"&gt;APP_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/esim/success?session_id={CHECKOUT_SESSION_ID}`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;cancel_url&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="nx"&gt;APP_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/esim`&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;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;Could not create the payment session.&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="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="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&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;This one function is called from two places: the existing server action behind the human "Buy" button on &lt;code&gt;/esim&lt;/code&gt; (which redirects the browser directly, Next.js-style), and the new &lt;code&gt;/api/v1/esim-checkout&lt;/code&gt; route (which the &lt;code&gt;buy_esim_plan&lt;/code&gt; tool calls, and which returns the URL as JSON instead of redirecting, because a &lt;code&gt;fetch()&lt;/code&gt; inside &lt;code&gt;execute()&lt;/code&gt; needs a value back, not a 303).&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="c1"&gt;// src/app/api/v1/esim-checkout/route.ts&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;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&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;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getClientIp&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="nf"&gt;checkRateLimit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`esim-checkout:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ip&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="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&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;rate limited&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;429&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;body&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;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&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;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buyEsimSchema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;safeParse&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;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;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&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;Invalid plan.&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&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;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createEsimCheckoutSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parsed&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;slug&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&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="mi"&gt;400&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;checkoutUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&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;Same Zod validation (&lt;code&gt;buyEsimSchema&lt;/code&gt;), same rate limiting (&lt;code&gt;checkRateLimit&lt;/code&gt;, keyed on IP, 10/hour on checkout, 60/minute on catalog reads), same Stripe session creation. The only fork in the road is the last line: redirect vs. JSON. Everything a human path already had to get right — bad slugs, abuse, Stripe error handling — the agent path gets for free, because it's not a separate implementation, it's the same function with a thinner wrapper.&lt;/p&gt;

&lt;p&gt;The catalog endpoint is the boring half, deliberately:&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="c1"&gt;// src/app/api/v1/esim-catalog/route.ts&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;GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&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;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getClientIp&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="nf"&gt;checkRateLimit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`api:esim-catalog:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ip&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="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&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;rate limited&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;plans&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ESIM_CATALOG&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;dataAmount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataAmount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;durationDays&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;durationDays&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;priceCents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;priceCents&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's the exact same &lt;code&gt;ESIM_CATALOG&lt;/code&gt; array that renders the pricing cards on the page, just mapped to JSON instead of JSX. No agent-only pricing, no shadow catalog that can go stale relative to what a human sees.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why "no toy demo" is the whole point
&lt;/h3&gt;

&lt;p&gt;It would be easy to build a WebMCP tool that calls a mock endpoint and prints a fake confirmation — a lot of the example repos floating around right now do exactly that, understandably, since the spec is four days old. I wanted to know what actually breaks when the tool is wired to a live Stripe account instead.&lt;/p&gt;

&lt;p&gt;The honest answer: nothing broke, because the design constraint I put on myself was that &lt;strong&gt;no money moves inside &lt;code&gt;execute()&lt;/code&gt;&lt;/strong&gt;. &lt;code&gt;buy_esim_plan&lt;/code&gt; does not charge a card. It creates a Stripe Checkout Session and hands back a URL. The agent (or the human driving it) still lands on checkout.stripe.com, still has to type in a real card number, still sees Stripe's own fraud checks and 3-D Secure flow. The WebMCP tool's entire job is to save the human the five clicks of picking a plan from a page — it deliberately stops exactly at the boundary where PCI scope and "did a human actually authorize this charge" would become real questions. That boundary isn't a workaround I bolted on for the demo; it's just where a Checkout Session naturally ends and Stripe's hosted page begins, so it cost nothing to keep the agent path honest.&lt;/p&gt;

&lt;p&gt;That's also why I didn't try to make the tool "smarter" — no auto-selecting a plan based on inferred intent, no skipping the redirect. A &lt;code&gt;buy_esim_plan&lt;/code&gt; tool that silently picked the most expensive plan or found a way to skip the human at the card page would be a much more interesting attack surface than a useful feature.&lt;/p&gt;

&lt;h3&gt;
  
  
  Code and live product
&lt;/h3&gt;

&lt;p&gt;Demo repo (extracted, minimal, MIT): &lt;a href="https://github.com/flovoice53-tech/sms-florin-webmcp-demo" rel="noopener noreferrer"&gt;https://github.com/flovoice53-tech/sms-florin-webmcp-demo&lt;/a&gt;&lt;br&gt;
Live page these tools are actually registered on: &lt;a href="https://flo-voice1.com/esim" rel="noopener noreferrer"&gt;https://flo-voice1.com/esim&lt;/a&gt; (open it in a WebMCP-aware browser and &lt;code&gt;document.modelContext&lt;/code&gt; will be populated; anywhere else, the page just works like a normal Next.js page, because that's all it is otherwise).&lt;/p&gt;

&lt;p&gt;If you're building on WebMCP and hit anything about the &lt;code&gt;execute()&lt;/code&gt;-can't-redirect constraint, or how you're structuring shared logic between the human path and the tool path, I'd genuinely like to compare notes — leave a comment.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>javascript</category>
      <category>opensource</category>
    </item>
    <item>
      <title>A real inbox for your test suite, no signup: I built receivemail.dev</title>
      <dc:creator>sms-florin</dc:creator>
      <pubDate>Fri, 28 Aug 2026 06:36:24 +0000</pubDate>
      <link>https://dev.to/flovoice53tech/a-real-inbox-for-your-test-suite-no-signup-i-built-receivemaildev-3ikm</link>
      <guid>https://dev.to/flovoice53tech/a-real-inbox-for-your-test-suite-no-signup-i-built-receivemaildev-3ikm</guid>
      <description>&lt;p&gt;Ever needed to test a signup flow, a "verify your email" link, or a password reset — and had to either burn a real personal inbox or wire up some heavyweight mail-testing service just to check one email arrived?&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://receivemail.dev" rel="noopener noreferrer"&gt;receivemail.dev&lt;/a&gt; for exactly that. One API call gets you a real, receiving email address. No signup, no account, no SMTP config.&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;# create a mailbox (default: expires in 15 min)&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://receivemail.dev/mailboxes
&lt;span class="c"&gt;# =&amp;gt; {"address":"a1b2c3d4e5f6@receivemail.dev","expiresAt":"...","secret":"..."}&lt;/span&gt;

&lt;span class="c"&gt;# trigger your app's email, then poll:&lt;/span&gt;
curl https://receivemail.dev/mailboxes/a1b2c3d4e5f6@receivemail.dev/messages &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;secret from above&amp;gt;"&lt;/span&gt;
&lt;span class="c"&gt;# =&amp;gt; {"address":"...","messages":[{"from_address":"...","subject":"...","body_text":"..."}]}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;secret&lt;/code&gt; returned at creation is the only credential — there's no login. It's the same "just enough auth, no account system" pattern as a lot of the disposable-testing tools I like using myself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How it's built&lt;/strong&gt;: fully serverless — Cloudflare Email Routing receives the actual mail, a Worker parses it and stores it in D1, a small REST API serves it back. No shared infra with anything else I run, so it's isolated by design.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's free.&lt;/strong&gt; Cloudflare's free tier (100k Worker requests/day, 5M D1 reads/day, unlimited Email Routing) covers this comfortably at the traffic I'd expect, so there's no billing to worry about — 20 mailbox creates/min per IP, 60-minute max lifetime, that's the whole limit.&lt;/p&gt;

&lt;p&gt;Source: &lt;a href="https://github.com/flovoice53-tech/receivemail-worker" rel="noopener noreferrer"&gt;https://github.com/flovoice53-tech/receivemail-worker&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I also build &lt;a href="https://flo-voice1.com" rel="noopener noreferrer"&gt;sms-florin&lt;/a&gt; (rent a real UK phone number for SMS/OTP testing) and &lt;a href="https://otpwatch.flo-voice1.com" rel="noopener noreferrer"&gt;otp-watch&lt;/a&gt; (synthetic monitoring — does your OTP actually arrive, and how fast?) — this fills the email-side gap in the same "test real verification flows without burning a real identity" story.&lt;/p&gt;

&lt;p&gt;Feedback welcome, especially if you hit the rate limits and need something higher-volume.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>api</category>
      <category>opensource</category>
      <category>testing</category>
    </item>
    <item>
      <title>Your uptime monitor checks if your API returns 200. It doesn't check if your OTP actually arrives.</title>
      <dc:creator>sms-florin</dc:creator>
      <pubDate>Thu, 27 Aug 2026 02:48:49 +0000</pubDate>
      <link>https://dev.to/flovoice53tech/your-uptime-monitor-checks-if-your-api-returns-200-it-doesnt-check-if-your-otp-actually-arrives-4fje</link>
      <guid>https://dev.to/flovoice53tech/your-uptime-monitor-checks-if-your-api-returns-200-it-doesnt-check-if-your-otp-actually-arrives-4fje</guid>
      <description>&lt;p&gt;Every synthetic-monitoring tool (Checkly, Pingdom, Better Stack, Datadog Synthetics) can tell you your signup endpoint responded correctly. None of them tell you whether the SMS or email your OTP provider promised to send actually showed up in a real inbox or a real phone — or how long it took. You usually find out the same way your users do: a support ticket, or a spike in abandoned signups you can't explain.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;&lt;a href="https://github.com/flovoice53-tech/otp-watch" rel="noopener noreferrer"&gt;otp-watch&lt;/a&gt;&lt;/strong&gt; — a small hosted service that answers exactly that question, live at &lt;strong&gt;&lt;a href="https://otpwatch.flo-voice1.com" rel="noopener noreferrer"&gt;https://otpwatch.flo-voice1.com&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;You ask it for a real target, trigger your own verification send to it (same as you would for a real user), then poll for what happened:&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;# get a key, no signup&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://otpwatch.flo-voice1.com/keys &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"email":"you@example.com"}'&lt;/span&gt;

&lt;span class="c"&gt;# start a check&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://otpwatch.flo-voice1.com/checks &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer otpw_..."&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"channel":"sms","timeoutSeconds":120}'&lt;/span&gt;
&lt;span class="c"&gt;# =&amp;gt; {"id":"...", "target":"+447...", "status":"pending"}&lt;/span&gt;

&lt;span class="c"&gt;# now trigger your own OTP send to that number/address, then poll:&lt;/span&gt;
curl https://otpwatch.flo-voice1.com/checks/&amp;lt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer otpw_..."&lt;/span&gt;
&lt;span class="c"&gt;# =&amp;gt; {"status":"received", "latencyMs":4213}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;channel&lt;/code&gt; is &lt;code&gt;sms&lt;/code&gt; (a real UK phone number) or &lt;code&gt;email&lt;/code&gt; (a real disposable mailbox). Whichever you pick, otp-watch doesn't touch your app at all — it just answers "did anything arrive at this target, and when."&lt;/p&gt;

&lt;h2&gt;
  
  
  What it deliberately doesn't do
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;No scheduling — bring your own cron/CI. A GitHub Actions job that runs every 30 min and fails on &lt;code&gt;timed_out&lt;/code&gt; &lt;em&gt;is&lt;/em&gt; the alerting; I didn't want to rebuild PagerDuty for v1.&lt;/li&gt;
&lt;li&gt;No webhooks yet — you poll &lt;code&gt;GET /checks/:id&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;SMS is UK-only for now (real GOIP hardware behind it, not a VoIP/virtual number — same infrastructure as &lt;a href="https://flo-voice1.com" rel="noopener noreferrer"&gt;sms-florin&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;No billing yet. It's free while unproven — each SMS check rents a real number, so the cost is real, just absorbed on my side for now.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scoped this tightly on purpose (built in about a day) rather than a full monitoring platform — happy to extend it (more countries, webhooks, scheduling) if there's real interest.&lt;/p&gt;

&lt;p&gt;If you're also testing signup flows with an AI agent, &lt;a href="https://github.com/flovoice53-tech/agent-identity-mcp" rel="noopener noreferrer"&gt;agent-identity-mcp&lt;/a&gt; uses the same underlying phone/email primitives from the agent side — disposable identity for testing vs. delivery monitoring for production are two sides of the same infrastructure.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/flovoice53-tech/otp-watch" rel="noopener noreferrer"&gt;https://github.com/flovoice53-tech/otp-watch&lt;/a&gt;&lt;br&gt;
Live: &lt;a href="https://otpwatch.flo-voice1.com" rel="noopener noreferrer"&gt;https://otpwatch.flo-voice1.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>api</category>
      <category>monitoring</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Testing an AI shopping agent's checkout flow? There's no sandbox for that yet — so I built one</title>
      <dc:creator>sms-florin</dc:creator>
      <pubDate>Wed, 26 Aug 2026 06:46:59 +0000</pubDate>
      <link>https://dev.to/flovoice53tech/testing-an-ai-shopping-agents-checkout-flow-theres-no-sandbox-for-that-yet-so-i-built-one-dgo</link>
      <guid>https://dev.to/flovoice53tech/testing-an-ai-shopping-agents-checkout-flow-theres-no-sandbox-for-that-yet-so-i-built-one-dgo</guid>
      <description>&lt;p&gt;If you're building or evaluating an AI agent that can shop and check out on its own, you've probably run into the new "agentic commerce" protocols: &lt;a href="https://github.com/agentic-commerce-protocol/agentic-commerce-protocol" rel="noopener noreferrer"&gt;ACP&lt;/a&gt; (OpenAI + Stripe + Meta), AP2 (Google), and UCP. They define how an agent talks to a merchant to create a checkout session, apply a payment token, and get an order back.&lt;/p&gt;

&lt;p&gt;Stripe's own test mode covers the &lt;em&gt;payment&lt;/em&gt; half fine — test cards, test API keys. But there's no hosted "fake merchant" you can point your agent at to verify the &lt;em&gt;protocol&lt;/em&gt; half: does your agent correctly create a session, handle a 422 idempotency conflict, parse the order response, retry politely? You either mock it yourself from the spec, or risk finding out against a real merchant.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;&lt;a href="https://github.com/flovoice53-tech/acp-sandbox" rel="noopener noreferrer"&gt;acp-sandbox&lt;/a&gt;&lt;/strong&gt; — a small hosted mock merchant implementing the ACP checkout API, live at &lt;strong&gt;&lt;a href="https://acp-sandbox.flo-voice1.com" rel="noopener noreferrer"&gt;https://acp-sandbox.flo-voice1.com&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;It implements the real &lt;code&gt;checkout_sessions&lt;/code&gt; lifecycle from ACP's &lt;a href="https://github.com/agentic-commerce-protocol/agentic-commerce-protocol/tree/main/spec/2026-04-17" rel="noopener noreferrer"&gt;2026-04-17 spec&lt;/a&gt;: create, retrieve, update, complete, cancel. Responses match the actual &lt;code&gt;CheckoutSession&lt;/code&gt;/&lt;code&gt;Order&lt;/code&gt;/&lt;code&gt;Error&lt;/code&gt; schemas for the fields it supports — I pulled the OpenAPI spec directly rather than guessing field names.&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;# get a test key, no signup&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://acp-sandbox.flo-voice1.com/keys &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"email":"you@example.com"}'&lt;/span&gt;

&lt;span class="c"&gt;# create a session against the demo catalog&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://acp-sandbox.flo-voice1.com/checkout_sessions &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer acps_test_..."&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"line_items":[{"id":"item_demo_headphones","quantity":1}],"currency":"usd"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every request/response is logged per API key (&lt;code&gt;GET /logs&lt;/code&gt;), so you can see exactly what your agent sent when something doesn't work.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it deliberately doesn't do (yet)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;No real payment processing — &lt;code&gt;complete&lt;/code&gt; always succeeds once you send &lt;em&gt;any&lt;/em&gt; &lt;code&gt;payment_data&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;No OAuth &lt;code&gt;delegate_authentication&lt;/code&gt; flow.&lt;/li&gt;
&lt;li&gt;No fulfillment options (shipping/pickup) — every session goes straight to &lt;code&gt;ready_for_payment&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Fixed demo catalog (4 items), not a real product feed — feed ingestion is a merchant-onboarding concern, not something your checkout-testing agent needs to exercise.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;ACP is still beta (5 spec revisions in about 7 months), so this will need upkeep as the protocol moves. Scoped tightly on purpose rather than trying to cover AP2/UCP too — happy to extend it if there's real interest.&lt;/p&gt;

&lt;p&gt;If you're also building &lt;a href="https://github.com/flovoice53-tech/agent-identity-mcp" rel="noopener noreferrer"&gt;agent-identity-mcp&lt;/a&gt; or similar (disposable email/phone for AI agents to use as a buyer identity), it pairs directly with this — identity on one side, a merchant to check out with on the other.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/flovoice53-tech/acp-sandbox" rel="noopener noreferrer"&gt;https://github.com/flovoice53-tech/acp-sandbox&lt;/a&gt;&lt;br&gt;
Live: &lt;a href="https://acp-sandbox.flo-voice1.com" rel="noopener noreferrer"&gt;https://acp-sandbox.flo-voice1.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>webdev</category>
      <category>api</category>
    </item>
    <item>
      <title>How to Set Up Two-Factor Authentication Without Using Your Personal Phone Number</title>
      <dc:creator>sms-florin</dc:creator>
      <pubDate>Wed, 26 Aug 2026 05:55:45 +0000</pubDate>
      <link>https://dev.to/flovoice53tech/how-to-set-up-two-factor-authentication-without-using-your-personal-phone-number-3j78</link>
      <guid>https://dev.to/flovoice53tech/how-to-set-up-two-factor-authentication-without-using-your-personal-phone-number-3j78</guid>
      <description>&lt;p&gt;Many account ecosystems push new and recovered accounts toward two-factor authentication, which requires at least one trusted phone number to receive verification codes. That's inconvenient if you're setting up a test account for development, managing an account for a device pool, or don't want a personal account tied to your own number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Steps
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Rent a number — an instant rental covers the initial verification.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;During account setup (or in the account's security settings), add the rented number as a trusted phone number and request a code.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;It sends a verification code by SMS within seconds on a working number.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Enter the code from your rental's status page to complete verification.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Instant or monthly?
&lt;/h2&gt;

&lt;p&gt;An instant rental covers the initial setup. If you plan to keep the trusted number on the account long-term (some ecosystems occasionally re-verify it), a monthly rental keeps it reachable instead of a one-off.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>api</category>
      <category>tutorial</category>
      <category>privacy</category>
    </item>
  </channel>
</rss>
