<?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>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>
    <item>
      <title>How to Verify an International Trading Platform Without Using Your Personal Phone Number</title>
      <dc:creator>sms-florin</dc:creator>
      <pubDate>Wed, 26 Aug 2026 05:54:23 +0000</pubDate>
      <link>https://dev.to/flovoice53tech/how-to-verify-an-international-trading-platform-without-using-your-personal-phone-number-15ob</link>
      <guid>https://dev.to/flovoice53tech/how-to-verify-an-international-trading-platform-without-using-your-personal-phone-number-15ob</guid>
      <description>&lt;p&gt;Many international trading platforms ask for phone verification at signup and can prompt for it again on login from a new device or location. As with any financial platform, that permanently links your real number to a financial account — not ideal if you're testing, running more than one account, or would rather keep that number private.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Rent a number for the platform you're signing up with — an instant rental is enough to get through signup.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Enter the rented number, with country code, on the platform's phone-verification step.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;It sends an SMS code, typically within seconds.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Enter the code shown on your rental's status page to finish verifying.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;Instant covers a single signup. If you're keeping the account long-term and expect the platform to re-verify by SMS on future logins, a monthly rental keeps the same number reachable instead of expiring right after the first code.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>api</category>
      <category>tutorial</category>
      <category>privacy</category>
    </item>
    <item>
      <title>How to Verify a Social Network Account Without Using Your Personal Phone Number</title>
      <dc:creator>sms-florin</dc:creator>
      <pubDate>Wed, 26 Aug 2026 05:54:22 +0000</pubDate>
      <link>https://dev.to/flovoice53tech/how-to-verify-a-social-network-account-without-using-your-personal-phone-number-2j6n</link>
      <guid>https://dev.to/flovoice53tech/how-to-verify-a-social-network-account-without-using-your-personal-phone-number-2j6n</guid>
      <description>&lt;p&gt;Most large social networks let you sign up with either an email or a phone number, but they frequently prompt for phone verification anyway — especially on new accounts, when logging in from a new device, or after an automatic security flag asks you to "confirm it's really you." That's inconvenient if you're testing a signup flow, managing a second account for a project, or don't want a social network holding your personal number.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Rent a number for the social network you're signing up with — an instant rental covers the one-time verification.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;When the platform asks for a phone number (at signup, or in a "confirm your identity" security prompt), enter the rented number with its country code.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;It sends a verification code by SMS, usually within seconds.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Enter the code from your rental's status page to finish verifying.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;An instant rental is enough for a one-time signup or security check. If you expect the platform to text the same number again later (repeated login-approval prompts on a new account are common), a monthly rental keeps it reachable instead of releasing it right after the first code.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>api</category>
      <category>tutorial</category>
      <category>privacy</category>
    </item>
    <item>
      <title>How to Verify a Productivity Suite Account Without Using Your Personal Phone Number</title>
      <dc:creator>sms-florin</dc:creator>
      <pubDate>Wed, 26 Aug 2026 05:53:24 +0000</pubDate>
      <link>https://dev.to/flovoice53tech/how-to-verify-a-productivity-suite-account-without-using-your-personal-phone-number-5a31</link>
      <guid>https://dev.to/flovoice53tech/how-to-verify-a-productivity-suite-account-without-using-your-personal-phone-number-5a31</guid>
      <description>&lt;p&gt;Many productivity suites let you sign up with just an email, but frequently ask for a phone number anyway — during signup as an extra security step, or later when a login looks unusual and it wants to "verify it's you." That ties a real number to the account just to get past a routine check, which is a problem if you're testing a signup flow, managing more than one account, or don't want it tied to your personal number.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Rent a number for the account you're setting up — an instant rental covers the one-time verification.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;When it asks for a phone number (at signup, or in a security-verification prompt), enter the rented number with its country code.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;It sends a verification code by SMS, usually within seconds.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Enter the code from your rental's status page to finish verifying.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;An instant rental is enough for a one-time signup or security check. If you expect repeated verification prompts on the same account, a monthly rental keeps the number reachable instead of releasing it right after the first code.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>api</category>
      <category>tutorial</category>
      <category>privacy</category>
    </item>
    <item>
      <title>How to Verify a Photo-Sharing App Without Using Your Personal Phone Number</title>
      <dc:creator>sms-florin</dc:creator>
      <pubDate>Wed, 26 Aug 2026 05:52:48 +0000</pubDate>
      <link>https://dev.to/flovoice53tech/how-to-verify-a-photo-sharing-app-without-using-your-personal-phone-number-4i7b</link>
      <guid>https://dev.to/flovoice53tech/how-to-verify-a-photo-sharing-app-without-using-your-personal-phone-number-4i7b</guid>
      <description>&lt;p&gt;Popular photo-sharing apps increasingly ask for phone verification at signup, after a suspicious-login flag, or when creating an additional account on the same device. If you manage a second account for a business, a project, or testing, tying every one of them to your own number gets messy fast — and their own device/number fingerprinting can flag multiple accounts sharing a number differently than it flags a fresh one.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Rent a number for the app — an instant rental covers a one-time signup or recovery code.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Enter the number (with country code) wherever the app's flow is asking for it — signup, "Confirm your number", or the account-recovery form.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The app sends a 6-digit code by SMS, usually within a few seconds.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Enter the code from your rental's status page to finish verification.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Instant vs. monthly
&lt;/h2&gt;

&lt;p&gt;An instant rental is fine for a one-off signup. If the account is for an ongoing project (a business page, a bot-adjacent account you'll need to re-verify into later), a monthly rental keeps the same number reserved so a future 'suspicious login' prompt can text the same number again instead of locking you out with no way to receive the new code.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>api</category>
      <category>tutorial</category>
      <category>privacy</category>
    </item>
    <item>
      <title>How to Verify an Online Payment Account Without Using Your Personal Phone Number</title>
      <dc:creator>sms-florin</dc:creator>
      <pubDate>Wed, 26 Aug 2026 05:52:45 +0000</pubDate>
      <link>https://dev.to/flovoice53tech/how-to-verify-an-online-payment-account-without-using-your-personal-phone-number-20km</link>
      <guid>https://dev.to/flovoice53tech/how-to-verify-an-online-payment-account-without-using-your-personal-phone-number-20km</guid>
      <description>&lt;p&gt;Online payment platforms often ask for phone verification when opening a new account, and again for certain security prompts later (a login from an unrecognized device, enabling two-factor authentication). Freelancers and sellers running a separate business account, or anyone testing a checkout integration, often want that number to be independent of their personal line.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Rent a number for the payment platform you're signing up with — an instant rental is enough for the initial verification code.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Enter the number (with country code) in the platform's phone-verification step, during signup or when prompted for a security check.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;It texts a verification code, usually within seconds.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Enter the code from your rental's status page to finish.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Worth knowing for a business account
&lt;/h2&gt;

&lt;p&gt;If this number is for an ongoing business account rather than a one-off signup, a monthly rental keeps it reserved so the platform can text the same number again for future security checks, instead of the number becoming unreachable right after the first code.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>api</category>
      <category>tutorial</category>
      <category>privacy</category>
    </item>
    <item>
      <title>How to Verify an Online Shopping Account Without Using Your Personal Phone Number</title>
      <dc:creator>sms-florin</dc:creator>
      <pubDate>Wed, 26 Aug 2026 05:52:08 +0000</pubDate>
      <link>https://dev.to/flovoice53tech/how-to-verify-an-online-shopping-account-without-using-your-personal-phone-number-4a1</link>
      <guid>https://dev.to/flovoice53tech/how-to-verify-an-online-shopping-account-without-using-your-personal-phone-number-4a1</guid>
      <description>&lt;p&gt;Most large shopping platforms don't require a phone number to create an account, but they push two-step verification hard during signup and account-security prompts, and enabling it (or resolving a suspicious-activity check) means handing over a number that gets an SMS code. Not ideal if you're testing a signup or checkout flow, running a second account, or don't want a shopping platform holding your personal number.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Rent a number for the shopping platform you're signing up with — an instant rental covers the one-time verification.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;On the platform's two-step verification or security-check screen, enter the rented number with its country code.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;It sends a verification code by SMS, usually within seconds.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Enter the code from your rental's status page to finish verifying.&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 a one-time setup. If you expect the platform to text the same number again later — repeated two-step prompts are common on an account used for testing — a monthly rental keeps it reachable instead of releasing it right after the first code.&lt;/p&gt;

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