<?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</title>
    <description>The most recent home feed on DEV Community.</description>
    <link>https://dev.to</link>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed"/>
    <language>en</language>
    <item>
      <title>Routing Cloudflare Workers KV &amp; Edge Form Submissions to Slack: A Serverless Speed-to-Lead Playbook</title>
      <dc:creator>InstaChime</dc:creator>
      <pubDate>Thu, 12 Nov 2026 01:48:28 +0000</pubDate>
      <link>https://dev.to/instachime/routing-cloudflare-workers-kv-edge-form-submissions-to-slack-a-serverless-speed-to-lead-playbook-162p</link>
      <guid>https://dev.to/instachime/routing-cloudflare-workers-kv-edge-form-submissions-to-slack-a-serverless-speed-to-lead-playbook-162p</guid>
      <description>&lt;p&gt;In B2B sales, how quickly you respond to an inbound lead matters a great deal. The research is older than most vendors admit, but its direction hasn’t been contradicted.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;InsideSales.com / MIT Lead Response Management study (2007):&lt;/strong&gt; Across more than 15,000 leads, contacting a web lead within five minutes made a team about &lt;strong&gt;21 times more likely to qualify it&lt;/strong&gt; than waiting 30 minutes. The same study found roughly 100 times higher odds of making contact at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Harvard Business Review (Oldroyd, McElheran and Elkington, 2011):&lt;/strong&gt; The authors audited 2,241 US companies by submitting web inquiries. Firms that responded within an hour were nearly &lt;strong&gt;seven times more likely to qualify the lead&lt;/strong&gt; than firms that waited longer. The average first response across the audited companies was about &lt;strong&gt;42 hours&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Treat these as directional rather than precise forecasts. The data is 15 to 19 years old, was gathered in specific B2B contexts, and doesn’t promise the same multiplier for your funnel. The consistent lesson is that the first minutes count, and many teams lose them to plumbing rather than to people.&lt;/p&gt;

&lt;p&gt;A typical form-to-Slack path crosses a CMS or application server, a database write, an automation platform such as Zapier or Make, and finally the chat API. Each hop adds latency and a failure point. Some automation triggers are also polling-based, which can add minutes of delay depending on the app and plan.&lt;/p&gt;

&lt;p&gt;Running the logic on Cloudflare Workers removes the origin server from the path. Cloudflare says its network sits within roughly &lt;a href="https://www.cloudflare.com/en-us/network/" rel="noopener noreferrer"&gt;50 milliseconds of about 95% of the world’s Internet-connected population&lt;/a&gt;, across 330+ cities. That figure describes network proximity, not the latency of your whole request, but it means your code runs close to the visitor.&lt;/p&gt;

&lt;p&gt;This playbook builds a small pipeline with &lt;strong&gt;Cloudflare Workers&lt;/strong&gt;, &lt;strong&gt;Workers KV&lt;/strong&gt;, and &lt;strong&gt;Slack Incoming Webhooks&lt;/strong&gt;. The form submission gets a response in a few hundred milliseconds. The Slack alert and the lead record are handled in the background, immediately after the response.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Architecture Overview
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Traditional vs. edge-native flow
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Traditional:&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
&lt;code&gt;Browser → DNS → origin server (CMS / Node / Django) → database write → queue or automation platform → Slack API&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Edge-native:&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
&lt;code&gt;Browser → Cloudflare Worker (validate, rate limit, dedupe) → 202 Accepted to browser&lt;/code&gt;, then in the background: &lt;code&gt;Slack webhook + KV writes&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                  +-----------------------------+
                  |  Browser: form submission   |
                  +-----------------------------+
                                |  HTTPS POST (JSON)
                                v
+------------------------------------------------------------------+
| Cloudflare Worker (runs in the data center nearest the visitor)  |
|                                                                  |
|  1. CORS / method check                                          |
|  2. Rate-limit binding (per IP, per location)                    |
|  3. Validate payload (Zod)                                       |
|  4. Best-effort duplicate check (Workers KV read)                |
|  5. Return 202 Accepted to the browser                           |
|                                                                  |
|  ctx.waitUntil(  (runs after the response is sent)               |
|     A: POST to Slack Incoming Webhook                            |
|     B: put lead record in Workers KV (30-day TTL)                |
|     C: put duplicate-marker in Workers KV (10-min TTL)           |
|  )                                                               |
+------------------------------------------------------------------+
            |                                   |
            v                                   v
   Slack channel (alert)              Workers KV (lead store)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why this design is fast
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Short critical path.&lt;/strong&gt; The browser waits only for validation and one KV read, not for Slack or any database write. A fast response also means the form UI can confirm immediately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Minimal cold-start penalty.&lt;/strong&gt; Workers run in V8 isolates rather than containers, so they start in a few milliseconds. The cold-start delays familiar from container-based serverless runtimes are largely absent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pre-processing at the edge.&lt;/strong&gt; Validation, spam throttling, and basic enrichment happen before any data reaches your CRM or chat tool.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One honest caveat: the response time you measure covers &lt;em&gt;form to acknowledgment&lt;/em&gt;. The time until the Slack message appears is separate. It depends on Slack’s API and on your background work completing, and you should measure it separately (see section 7).&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Prerequisites and Setup
&lt;/h2&gt;

&lt;p&gt;You need a Cloudflare account, Node.js and npm. Wrangler, Cloudflare’s CLI, &lt;a href="https://developers.cloudflare.com/workers/wrangler/install-and-update/" rel="noopener noreferrer"&gt;supports the Current, Active and Maintenance releases of Node.js&lt;/a&gt;, so any currently supported version works. You also need a Slack workspace where you can create an app.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Create the Worker project
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm create cloudflare@latest -- edge-speed-to-lead
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When prompted, choose a basic “Hello World” Worker and TypeScript. The scaffold installs Wrangler and creates &lt;code&gt;src/index.ts&lt;/code&gt; and a &lt;code&gt;wrangler.jsonc&lt;/code&gt; file.&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;cd&lt;/span&gt; edge-speed-to-lead
npm install zod
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;npm install zod&lt;/code&gt; currently installs Zod 4, and the code below uses its API. Zod 4 moved string formats such as &lt;code&gt;z.email()&lt;/code&gt; to the top level, and &lt;code&gt;z.string().email()&lt;/code&gt; still works but is deprecated.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Create a KV namespace
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx wrangler kv namespace create LEADS_STORE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wrangler prints a binding snippet containing the namespace ID. Add it to your configuration as shown in step 4.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Create the Slack Incoming Webhook
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;a href="https://api.slack.com/apps" rel="noopener noreferrer"&gt;api.slack.com/apps&lt;/a&gt; and choose &lt;strong&gt;Create New App → From scratch&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Name the app (for example, “Edge Speed-to-Lead”) and pick your workspace.&lt;/li&gt;
&lt;li&gt;Under &lt;strong&gt;Features&lt;/strong&gt;, open &lt;strong&gt;Incoming Webhooks&lt;/strong&gt; and switch it on.&lt;/li&gt;
&lt;li&gt;Choose &lt;strong&gt;Add New Webhook to Workspace&lt;/strong&gt;, select the channel for lead alerts, and copy the webhook URL.&lt;/li&gt;
&lt;li&gt;Store the URL as an encrypted secret, never in source control:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx wrangler secret put SLACK_WEBHOOK_URL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The webhook URL is both the endpoint and the credential. Anyone who has it can post to that channel, so treat it like a password. For local development, put it in a &lt;code&gt;.dev.vars&lt;/code&gt; file that is excluded from git.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Configure &lt;code&gt;wrangler.jsonc&lt;/code&gt;
&lt;/h3&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="nl"&gt;"$schema"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"node_modules/wrangler/config-schema.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"edge-speed-to-lead"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"main"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"src/index.ts"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="err"&gt;//&lt;/span&gt; &lt;span class="n"&gt;Keep&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;compatibility_date&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;scaffold&lt;/span&gt; &lt;span class="n"&gt;generated&lt;/span&gt; &lt;span class="n"&gt;for&lt;/span&gt; &lt;span class="n"&gt;you&lt;/span&gt;
  &lt;span class="nl"&gt;"compatibility_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"YYYY-MM-DD"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"observability"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;"enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"vars"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;"ALLOWED_ORIGIN"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"https://www.example.com"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"kv_namespaces"&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="nl"&gt;"binding"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"LEADS_STORE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"YOUR_KV_NAMESPACE_ID"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"ratelimits"&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="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"LEAD_RATE_LIMITER"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;"namespace_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"1001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;"simple"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;"limit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;"period"&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="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;ratelimits&lt;/code&gt; binding &lt;a href="https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/" rel="noopener noreferrer"&gt;requires a recent Wrangler (4.36.0 or later)&lt;/a&gt;. &lt;code&gt;namespace_id&lt;/code&gt; is an integer string you choose, unique within your account. &lt;code&gt;period&lt;/code&gt; can only be &lt;code&gt;10&lt;/code&gt; or &lt;code&gt;60&lt;/code&gt; seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The Worker Code
&lt;/h2&gt;

&lt;p&gt;This is a complete &lt;code&gt;src/index.ts&lt;/code&gt;. It covers CORS, per-IP rate limiting, schema validation, best-effort deduplication, a Slack Block Kit message, and background processing through &lt;code&gt;ctx.waitUntil()&lt;/code&gt;.&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;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s1"&gt;'zod'&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;interface&lt;/span&gt; &lt;span class="nc"&gt;Env&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="no"&gt;LEADS_STORE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;KVNamespace&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="no"&gt;LEAD_RATE_LIMITER&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;RateLimit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="no"&gt;SLACK_WEBHOOK_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="no"&gt;ALLOWED_ORIGIN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&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="nc"&gt;LeadSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;fullName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&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;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Name must be at least 2 characters'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Invalid email address'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;254&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;company&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&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;min&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="s1"&gt;'Company name is required'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;companySize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'1-10'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'11-50'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'51-200'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'201-1000'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'1000+'&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;optional&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="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&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;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Message cannot exceed 1000 characters'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optional&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="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&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;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optional&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;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nc"&gt;Lead&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;infer&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nc"&gt;LeadSchema&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="no"&gt;LEAD_TTL_SECONDS&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;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 30 days&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="no"&gt;DEDUPE_TTL_SECONDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;             &lt;span class="c1"&gt;// 10 minutes (KV minimum TTL is 60)&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nb"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ExecutionContext&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="nc"&gt;Response&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="n"&gt;cors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="s1"&gt;'Access-Control-Allow-Origin'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;ALLOWED_ORIGIN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s1"&gt;'Access-Control-Allow-Methods'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'POST, OPTIONS'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s1"&gt;'Access-Control-Allow-Headers'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nc"&gt;Vary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Origin'&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;json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&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="n"&gt;body&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="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&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;span class="n"&gt;cors&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'application/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="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="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'OPTIONS'&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&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;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;204&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cors&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s1"&gt;'POST'&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="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="n"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Method not allowed'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;405&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="c1"&gt;// Layer 1: coarse per-IP throttle (see section 5 for its limits)&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'CF-Connecting-IP'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;'unknown'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;allowed&lt;/span&gt; &lt;span class="p"&gt;}&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;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;LEAD_RATE_LIMITER&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;allowed&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="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="n"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Too many requests'&lt;/span&gt; &lt;span class="p"&gt;}&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="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Layer 2: parse and validate&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;raw&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;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="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;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Body must be valid JSON'&lt;/span&gt; &lt;span class="p"&gt;}&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="p"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LeadSchema&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="n"&gt;raw&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="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&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="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="n"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flattenError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fieldErrors&lt;/span&gt; &lt;span class="p"&gt;}&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="p"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;lead&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Layer 3: best-effort duplicate check&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;dedupeKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sb"&gt;`dedupe:${await sha256Hex(lead.email.toLowerCase())}`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;LEADS_STORE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dedupeKey&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="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="n"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;duplicate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;leadId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&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;receivedAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toISOString&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="c1"&gt;// Respond now; do the slow work after the response is sent&lt;/span&gt;
    &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;processLead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;leadId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;receivedAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dedupeKey&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="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="n"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;leadId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;202&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;span class="p"&gt;;&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;processLead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Lead&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;leadId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;receivedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;dedupeKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="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="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allSettled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nf"&gt;notifySlack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;buildSlackMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;leadId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;receivedAt&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="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;LEADS_STORE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="sb"&gt;`lead:${leadId}`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nb"&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="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="n"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;leadId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;receivedAt&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="n"&gt;expirationTtl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;LEAD_TTL_SECONDS&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="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;LEADS_STORE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dedupeKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;expirationTtl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;DEDUPE_TTL_SECONDS&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;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;labels&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'slack'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'lead-record'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'dedupe-marker'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'rejected'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nb"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sb"&gt;`Background task failed: ${labels[i]}`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reason&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;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&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;notifySlack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;unknown&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="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="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;attempt&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="kd"&gt;const&lt;/span&gt; &lt;span class="n"&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="nb"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;SLACK_WEBHOOK_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'POST'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'application/json'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&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="n"&gt;payload&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;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ok&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="c1"&gt;// Slack answers HTTP 429 with a Retry-After header when you exceed its limits&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;waitSeconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Retry-After'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resolve&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;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;waitSeconds&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="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sb"&gt;`Slack webhook returned ${res.status}`&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sha256Hex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&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="kt"&gt;string&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="n"&gt;digest&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;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SHA-256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&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;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&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="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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;digest&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;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'0'&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;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&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="c1"&gt;// Slack requires &amp;amp;, &amp;lt; and &amp;gt; to be escaped in message text&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;esc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&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="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;/&amp;amp;/&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;amp;amp;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;/&amp;lt;/&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;amp;lt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;/&amp;gt;/&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;amp;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;function&lt;/span&gt; &lt;span class="nf"&gt;buildSlackMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Lead&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;leadId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;receivedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&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;highValue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;companySize&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'201-1000'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;companySize&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'1000+'&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;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;highValue&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'🔥 High-priority inbound lead'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'⚡ New inbound lead'&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;quoted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;esc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;'No message supplied.'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'\n'&lt;/span&gt;&lt;span class="p"&gt;)&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="sb"&gt;`&amp;gt;${line}`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'\n'&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="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sb"&gt;`${title}: ${esc(lead.fullName)} (${esc(lead.company)})`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// fallback for notifications&lt;/span&gt;
    &lt;span class="n"&gt;blocks&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;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'header'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'plain_text'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;emoji&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'section'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;fields&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;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'mrkdwn'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sb"&gt;`*Name:*\n${esc(lead.fullName)}`&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;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'mrkdwn'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sb"&gt;`*Email:*\n&amp;lt;mailto:${lead.email}|${esc(lead.email)}&amp;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="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'mrkdwn'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sb"&gt;`*Company:*\n${esc(lead.company)}`&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;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'mrkdwn'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sb"&gt;`*Company size:*\n${lead.companySize ?? 'Not provided'}`&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;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'section'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'mrkdwn'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sb"&gt;`*Message:*\n${quoted}`&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="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'context'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;elements&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;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'mrkdwn'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sb"&gt;`Lead ID: ${leadId} | Source: ${esc(lead.source ?? 'Not provided')} | ${new Date(receivedAt).toUTCString()}`&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;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;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;On the browser side, the form handler is a plain &lt;code&gt;fetch&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&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="nb"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'https://edge-speed-to-lead.&amp;lt;your-subdomain&amp;gt;.workers.dev'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'POST'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'application/json'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&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="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;fullName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;company&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'pricing-page'&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;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Key Design Decisions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Responding first with &lt;code&gt;ctx.waitUntil()&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Normally a Worker’s pending work can be cancelled once the response is complete. &lt;code&gt;ctx.waitUntil()&lt;/code&gt; tells the runtime to keep the invocation alive for that work. Cloudflare’s &lt;a href="https://developers.cloudflare.com/workers/platform/limits/" rel="noopener noreferrer"&gt;limits documentation&lt;/a&gt; says it can extend execution for &lt;strong&gt;up to 30 seconds&lt;/strong&gt; after the response is sent or the client disconnects.&lt;/p&gt;

&lt;p&gt;For this pipeline that means the visitor never waits for Slack or for KV writes. It also means this is best-effort delivery. If Slack is down for longer than the retry window, or the 30-second budget runs out, the alert is lost, although the failure is logged. Section 6 covers how to harden that.&lt;/p&gt;

&lt;h3&gt;
  
  
  Deduplication with KV, and its limits
&lt;/h3&gt;

&lt;p&gt;The Worker keys a duplicate marker on a SHA-256 hash of the lowercased email. Hashing keeps the key short and avoids putting raw addresses in key names. It is not a security control. A 10-minute TTL catches double-clicks and accidental resubmits.&lt;/p&gt;

&lt;p&gt;Be aware of what KV does and doesn’t guarantee. &lt;a href="https://developers.cloudflare.com/kv/concepts/how-kv-works/" rel="noopener noreferrer"&gt;Workers KV is eventually consistent&lt;/a&gt;. A write is usually visible immediately in the location that made it, but it can take &lt;strong&gt;60 seconds or more&lt;/strong&gt; to appear elsewhere, and “key does not exist” lookups are cached too. In this design the marker is also written &lt;em&gt;after&lt;/em&gt; the response, so two near-simultaneous submissions can both get through. That makes this dedupe a convenience, not a guarantee. If you need strict once-only behavior, Cloudflare’s guidance is to use &lt;a href="https://developers.cloudflare.com/durable-objects/" rel="noopener noreferrer"&gt;Durable Objects&lt;/a&gt;, which offer strongly consistent state.&lt;/p&gt;

&lt;p&gt;Don’t expect uniformly fast KV reads on dedupe lookups either. Cloudflare advertises low single-digit-millisecond latency for &lt;em&gt;hot&lt;/em&gt; keys, but a lookup for an email that has never been seen is a cold read.&lt;/p&gt;

&lt;h3&gt;
  
  
  KV limits that matter here
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Limit&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Writes to the same key&lt;/td&gt;
&lt;td&gt;1 per second&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Minimum &lt;code&gt;expirationTtl&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;60 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Value size&lt;/td&gt;
&lt;td&gt;25 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Free plan writes&lt;/td&gt;
&lt;td&gt;1,000 per day&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Free plan reads&lt;/td&gt;
&lt;td&gt;100,000 per day&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Paid plan&lt;/td&gt;
&lt;td&gt;10M reads and 1M writes per month included; then $0.50 per million reads and $5.00 per million writes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each accepted lead here costs two KV writes (lead record plus dedupe marker), so the Free plan covers roughly 500 leads per day before writes start failing. Check Cloudflare’s &lt;a href="https://developers.cloudflare.com/kv/platform/pricing" rel="noopener noreferrer"&gt;KV pricing page&lt;/a&gt; for current numbers. The Workers Free plan also limits CPU time to 10 ms per invocation, which this code stays well within.&lt;/p&gt;

&lt;h3&gt;
  
  
  Slack’s own limits
&lt;/h3&gt;

&lt;p&gt;Slack advises apps to post &lt;a href="https://docs.slack.dev/apis/web-api/rate-limits.md" rel="noopener noreferrer"&gt;no more than about one message per second per channel&lt;/a&gt;, including via incoming webhooks, with short bursts tolerated. Past that, Slack returns HTTP 429 with a &lt;code&gt;Retry-After&lt;/code&gt; header, and the code above honors it once. During a spam wave or a big campaign launch, alerts could back up. That is another reason to rate-limit upstream and, for high volume, batch or queue notifications.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Security and Abuse Protection
&lt;/h2&gt;

&lt;p&gt;An open POST endpoint will attract bots. Use several layers, and be realistic about what each one does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schema validation.&lt;/strong&gt; Zod rejects malformed types and oversized fields before they reach Slack. Escaping &lt;code&gt;&amp;amp;&lt;/code&gt;, &lt;code&gt;&amp;lt;&lt;/code&gt; and &lt;code&gt;&amp;gt;&lt;/code&gt; in the message builder prevents submitted text from injecting Slack markup or mentions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rate limiting.&lt;/strong&gt; The &lt;code&gt;ratelimits&lt;/code&gt; binding is fast because it doesn’t wait on a network call. Cloudflare documents it as &lt;a href="https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/" rel="noopener noreferrer"&gt;permissive and eventually consistent, and not meant to be an accurate accounting system&lt;/a&gt;. Counts are also kept per Cloudflare location. A single bot spread across many locations, or many users behind one shared IP, will not be counted exactly. Treat it as a cheap first filter. For harder enforcement, configure WAF rate limiting rules in the Cloudflare dashboard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bot challenges.&lt;/strong&gt; For a public marketing form, adding &lt;a href="https://developers.cloudflare.com/turnstile/" rel="noopener noreferrer"&gt;Cloudflare Turnstile&lt;/a&gt; is usually more effective than header tricks. The Worker verifies the token server-side before accepting the lead. This adds one outbound request, so measure the latency cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Origin and header checks.&lt;/strong&gt; The &lt;code&gt;Origin&lt;/code&gt; header and a shared-secret header only help when the caller is a server you control, such as a Next.js route handler that forwards submissions. A secret embedded in browser JavaScript is visible to anyone, and the &lt;code&gt;Origin&lt;/code&gt; header can be forged by non-browser clients. CORS controls what &lt;em&gt;browsers&lt;/em&gt; may do, not what scripts can send. Don’t rely on any of these for browser-submitted forms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data handling.&lt;/strong&gt; Leads contain personal data. Keep the retention TTL as short as your process allows, and make sure your privacy notice covers storage in Cloudflare KV.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Making Delivery More Reliable
&lt;/h2&gt;

&lt;p&gt;If a missed Slack alert would cost you real money, the single-Worker design needs reinforcing.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Queues:&lt;/strong&gt; Have the Worker enqueue the lead and let a consumer Worker post to Slack with automatic retries. The visitor-facing path stays equally short.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A fallback channel:&lt;/strong&gt; Add email or a second webhook for failures, triggered from the logged errors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reconciliation:&lt;/strong&gt; A scheduled Worker (Cron Trigger) can scan recent &lt;code&gt;lead:&lt;/code&gt; records and re-send any without a confirmed notification, if you store a delivery flag.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strict deduplication:&lt;/strong&gt; Move the duplicate check into a Durable Object if double alerts are unacceptable.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  7. Deploying and Measuring It Yourself
&lt;/h2&gt;

&lt;p&gt;Deploy with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx wrangler deploy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wrangler bundles your TypeScript with esbuild, uploads it, and publishes the Worker to Cloudflare’s network. Tail live logs with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx wrangler tail --format=pretty
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;"observability": { "enabled": true }&lt;/code&gt; in your configuration, you also get request logs, errors and metrics in the Cloudflare dashboard.&lt;/p&gt;

&lt;h3&gt;
  
  
  Benchmark your own setup
&lt;/h3&gt;

&lt;p&gt;Published latency claims depend on location, payload, Slack’s response time and your plan, so measure your own pipeline rather than trusting anyone else’s number. Here is a minimal &lt;a href="https://k6.io" rel="noopener noreferrer"&gt;k6&lt;/a&gt; script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s1"&gt;'k6/http'&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="n"&gt;check&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s1"&gt;'k6'&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="n"&gt;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;vus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'60s'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;thresholds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;http_req_duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'p(95)&amp;lt;500'&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;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&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;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&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="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;fullName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Load Test'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sb"&gt;`loadtest+${__VU}-${__ITER}-${Date.now()}@example.com`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;company&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Test Co'&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="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__ENV&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;WORKER_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'application/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="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;accepted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;202&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;Before you run it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use a staging deployment&lt;/strong&gt; with a webhook that posts to a private test channel. Every accepted request sends a real Slack message, and Slack throttles around one message per second per channel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Raise or disable the rate limit&lt;/strong&gt; in the test environment. The default of five requests per minute per IP will reject most of the traffic from a single machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run from several regions&lt;/strong&gt; if you care about global performance. A single machine measures only one network path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look at percentiles, not averages.&lt;/strong&gt; &lt;code&gt;http_req_duration&lt;/code&gt; p50, p95 and p99 tell you much more than the mean.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure alert delivery separately.&lt;/strong&gt; Compare each lead’s &lt;code&gt;receivedAt&lt;/code&gt; timestamp with the time the message appears in Slack. That gap is what your sales team actually experiences.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Moving form handling to the edge shortens the path between a prospect’s click and your team’s notification. Workers provide the low-latency compute, KV gives you a cheap store for lead records and a convenience-level duplicate check, and &lt;code&gt;ctx.waitUntil()&lt;/code&gt; keeps Slack and storage out of the visitor’s critical path.&lt;/p&gt;

&lt;p&gt;Keep the claims proportionate. This pattern gets the acknowledgment back quickly and gets the alert into Slack with very little infrastructure. It does not make KV a strongly consistent database, and it does not guarantee delivery under failure. Add a queue, a stronger deduplication layer and real bot defenses as your volume and stakes grow. The research suggests the minutes after a form submission are worth protecting, and a pipeline like this keeps its own overhead small.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://instachime.com/blog/routing-cloudflare-workers-kv-edge-form-submissions-to-slack-a-serverless-speed-to-lead-playbook" rel="noopener noreferrer"&gt;https://instachime.com/blog/routing-cloudflare-workers-kv-edge-form-submissions-to-slack-a-serverless-speed-to-lead-playbook&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
    </item>
    <item>
      <title>Wabi: Who Should Try It Now — and Who Should Wait</title>
      <dc:creator>wenjianzhang70-dot</dc:creator>
      <pubDate>Thu, 12 Nov 2026 01:47:18 +0000</pubDate>
      <link>https://dev.to/wenjianzhang70dot/wabi-who-should-try-it-now-and-who-should-wait-45gn</link>
      <guid>https://dev.to/wenjianzhang70dot/wabi-who-should-try-it-now-and-who-should-wait-45gn</guid>
      <description>&lt;p&gt;&lt;em&gt;I'm the author of Wabi Wiki, an unofficial guide to the Wabi AI assistant — not affiliated with Wabi.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Wabi opened to everyone on October 19, 2026. It's free for now, runs on iOS, Android and Mac, and comes from Eugenia Kuyda — the founder of Replika — backed by a $20M pre-seed from a16z. The idea is genuinely different: an AI assistant you use &lt;em&gt;together&lt;/em&gt; with other people, in shared threads, building small working mini-apps inside the chat.&lt;/p&gt;

&lt;p&gt;But "different" doesn't mean "right for everyone." After pulling together our full review, here's the honest split.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it now if…
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;You coordinate with other people constantly.&lt;/strong&gt; Couples planning a trip, families juggling schedules, friend groups splitting chores — this is the use case Wabi was built for. Shared threads let the assistant plan with the whole group, which is something a solo chatbot structurally can't do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You're drowning in small recurring admin.&lt;/strong&gt; Briefs, trackers, lists, polls — the kind of stuff that lives in five different apps. Wabi turns a chat message into a tappable mini-app: a tracker, an itinerary, a poll. A widget you can tap beats a paragraph describing one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You're a Replika fan curious what its founder built next.&lt;/strong&gt; Same founder, very different job: Wabi is a practical assistant, not a companion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wait if…
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;You need a mature, stable tool for critical work today.&lt;/strong&gt; Wabi is days old and moving fast — it already pivoted once, from an app-creator tool to this assistant, and the iOS build went from 3.0.0 to 3.0.5 in the three weeks around launch. Expect rough edges, and expect quick fixes too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You're on Windows.&lt;/strong&gt; There's no Windows version at launch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You're not comfortable connecting personal accounts to a new service.&lt;/strong&gt; This is the real trade-off. Wabi's best features need email, calendar — even banking — access. Per-chat scoping helps, but you're trusting a young startup with sensitive data. Read the safety facts before you connect anything, and connect accounts gradually.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The price is a question mark.&lt;/strong&gt; Free today; the company says pricing isn't decided. Features may move behind a paywall later, so don't build a workflow you can't live without — or can't pay for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;Wabi is one of the few AI launches with a truly different idea: an assistant for &lt;em&gt;groups&lt;/em&gt;, not just individuals. It's free, it's early, and the privacy trade-off is yours to judge. Worth trying — just go in with your eyes open, and start with the least sensitive connections first.&lt;/p&gt;

&lt;p&gt;For the longer version with sources, see the &lt;a href="https://wabiwiki.com/review/" rel="noopener noreferrer"&gt;full review&lt;/a&gt; on Wabi Wiki, and the &lt;a href="https://wabiwiki.com/" rel="noopener noreferrer"&gt;Wabi Wiki homepage&lt;/a&gt; for the download steps, prompts and safety guides.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Wabi Wiki is an unofficial guide to the Wabi AI assistant — not affiliated with Wabi.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Nothing new. Exit code 0.</title>
      <dc:creator>Alexandru Cazan</dc:creator>
      <pubDate>Thu, 12 Nov 2026 01:45:33 +0000</pubDate>
      <link>https://dev.to/alexdev13/nothing-new-exit-code-0-5a4m</link>
      <guid>https://dev.to/alexdev13/nothing-new-exit-code-0-5a4m</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://tothenoc.substack.com/p/nothing-new-exit-code-0" rel="noopener noreferrer"&gt;tothenoc.substack.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A check that cannot fail is not a check.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Last week a check told me there was nothing new in my mailbox. There were four messages that mattered, all from that same day. The check did not fail. It ran, it filtered, it returned an empty list. The filter compared dates written as text, and the text did not match. Empty, no error. I caught it by reading the mailbox myself.&lt;/p&gt;

&lt;p&gt;It was not the first time. Same family, different tools, all on our own systems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a search over the mail server returned zero results. The mailboxes are stored compressed. The search read the raw files, found nothing, and said so politely.&lt;/li&gt;
&lt;li&gt;an upgrade script exited with code 0, one second after it started. It had checked who was running it, did not like the answer, and quietly did nothing.&lt;/li&gt;
&lt;li&gt;a daily report came out as a file of zero bytes. Its output path pointed to a folder that had been archived the night before.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these crashed. A crash is easy, somebody sees it. The dangerous result is the one that looks exactly like good news: nothing found, nothing changed, nothing to report.&lt;/p&gt;

&lt;p&gt;What we do now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;every check has a positive control, a known item it must find. If the check cannot find the thing I know is there, its zero means nothing,&lt;/li&gt;
&lt;li&gt;a result that came back too fast is not a result. Zero hits on a full disk in zero seconds is a tool that did not look,&lt;/li&gt;
&lt;li&gt;read the log, not the exit code. Code 0 says the program ended. It does not say what it did,&lt;/li&gt;
&lt;li&gt;a report has a last line that says it finished. No last line, no report.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"Nothing new" is a claim. Before you believe it, ask how the check would look if something was new.&lt;/p&gt;

&lt;p&gt;If you are deciding who watches this for you, this is what to ask: &lt;a href="https://www.tothenoc.com/outsourced-noc-monitoring-guide/" rel="noopener noreferrer"&gt;what an outsourced NOC actually covers, and what it costs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;-- Alex&lt;/p&gt;

</description>
      <category>monitoring</category>
      <category>devops</category>
      <category>sysadmin</category>
      <category>testing</category>
    </item>
    <item>
      <title>Fintech Inbound Mail: Go Evidence Gates for Leftover Provider Records</title>
      <dc:creator>Elvrythn486209</dc:creator>
      <pubDate>Thu, 12 Nov 2026 01:45:20 +0000</pubDate>
      <link>https://dev.to/elvrythn486209/fintech-inbound-mail-go-evidence-gates-for-leftover-provider-records-5c8n</link>
      <guid>https://dev.to/elvrythn486209/fintech-inbound-mail-go-evidence-gates-for-leftover-provider-records-5c8n</guid>
      <description>&lt;p&gt;Moving DNS and email into one control plane can reduce integration work, but it cannot establish that a customer's inbound mail reaches the intended mailbox. Resolve that trade-off by making routing evidence the release gate. Short answer: list the MX records, inspect their priorities and provider ownership, explicitly delete obsolete provider records, and list again. Upserting the replacement set doesn't remove the previous set; equal priorities across providers can send mail somewhere nobody reads.&lt;/p&gt;

&lt;p&gt;Consider a bounded production scenario, not a report of a measured incident: a fintech product lets a customer point its own domain at the product while changing the mail service associated with that domain. The website works, and the new sending configuration looks right, yet the customer's inbound messages are missing. The useful incident lesson is that a successful write establishes too little. The acceptance condition must describe the complete intended MX set, including what must be absent.&lt;/p&gt;

&lt;p&gt;Keep the evidence.&lt;/p&gt;

&lt;p&gt;For a team implementing that boundary, Infrai is worth trying for DNS inventory and the associated email-domain inspection: its shared API contract lets the application keep the same interface when the vendor behind a capability changes, subject to the capability's supported vendors. Its public, self-describing schemas provide a second practical benefit: the integration can inspect request and response contracts before writing provider-specific parsing. Neither property replaces mail-delivery evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do leftover provider records affect inbound mail and MX priorities?
&lt;/h2&gt;

&lt;p&gt;Start with routing, because sending configuration answers a different question. SPF, DKIM, and DMARC belong in the mail configuration review, but a sending-side check doesn't establish which receiving provider an MX inventory names. RFC 7489 describes DMARC; it is a useful reference for keeping authentication evidence distinct from the routing question rather than treating an authentication result as a receipt for inbound delivery.&lt;/p&gt;

&lt;p&gt;The concrete trap is an additive migration.&lt;/p&gt;

&lt;p&gt;A new provider's MX records are upserted, the operation succeeds, and the old provider's records remain. If both providers have equal priorities, routing becomes unpredictable rather than producing a configuration error that conveniently stops the release. Changing priorities without deciding which provider should still receive mail leaves the ownership question unresolved.&lt;/p&gt;

&lt;p&gt;As an illustrative review fixture, imagine two providers both listed at priority 10. These are example values, not an observed customer configuration. The reviewer must account for both destinations, identify which belongs to the intended receiver, and decide whether the other is obsolete. The preventative action is explicit deletion followed by a fresh list, not another upsert and an optimistic status update.&lt;/p&gt;

&lt;p&gt;A defensible evidence packet contains the before-and-after MX inventories, the intended provider ownership, and an inbound acceptance check at the intended receiver. Keep sending-domain status alongside that packet, with its narrower meaning attached. If the inventory is already correct, this particular explanation has reached its boundary: investigate the receiving service using its own evidence instead of repeatedly changing DNS.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two system shapes, one acceptance invariant
&lt;/h2&gt;

&lt;p&gt;The first viable architecture keeps DNS and email integrations separate. A platform-owned reconciler reads the DNS inventory through the DNS provider's interface, compares it with a reviewed desired set, and separately asks the mail provider about its domain configuration. Its invariant is that a customer domain cannot be marked ready until both the receiving destinations and the mail-side evidence have been reviewed. This buys independent provider selection, while assigning credential management, response normalization, and coordination to the platform team.&lt;/p&gt;

&lt;p&gt;The second architecture puts those inspections behind a shared capability API and retains the same reconciler and acceptance rule. Infrai is a deliberate option here because DNS and email capabilities use one API key and base URL. Provider changes behind supported capabilities can preserve the application-facing contract, but the desired MX set remains an application decision. Contract stability doesn't mean arbitrary providers have identical behavior or that existing customer records should be deleted automatically.&lt;/p&gt;

&lt;p&gt;For this fintech onboarding workflow, prefer the shared interface when the platform team wants to maintain one evidence-producing controller and the supported provider choices fit its requirements. Prefer direct integrations when a DNS or mail specialist's control surface is itself a requirement. The recommendation turns on evidence ownership and operating work; a shorter integration is valuable only if the release gate remains intact.&lt;/p&gt;

&lt;p&gt;The combined approach also concentrates trust, billing, and the outage surface in one vendor.&lt;/p&gt;

&lt;p&gt;Say that in the design review before choosing it.&lt;/p&gt;

&lt;p&gt;The cross-capability benefit is particularly concrete during SPF or DKIM maintenance. With DNS records and the mail service available through one key, a controller can coordinate inspection instead of relying on a person to copy values between two dashboards and remember to re-check them after a DKIM rotation. This is an integration pattern to implement, not a claim that a shared key automatically reconciles records or proves deliverability.&lt;/p&gt;

&lt;h2&gt;
  
  
  Buy versus build depends on who owns the evidence
&lt;/h2&gt;

&lt;p&gt;There are several credible stacks.&lt;/p&gt;

&lt;p&gt;The table compares the integration boundaries the team would own, rather than asserting that one provider has better measured delivery. No comparative delivery benchmark is established here.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stack&lt;/th&gt;
&lt;th&gt;What to buy&lt;/th&gt;
&lt;th&gt;What the platform still builds&lt;/th&gt;
&lt;th&gt;Appropriate boundary&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Amazon Route 53 + Amazon SES&lt;/td&gt;
&lt;td&gt;Separate DNS and email service interfaces within AWS&lt;/td&gt;
&lt;td&gt;The mapping between DNS inventory, mail configuration, and a customer-domain acceptance decision&lt;/td&gt;
&lt;td&gt;A team deliberately choosing the AWS service boundary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare DNS + Resend&lt;/td&gt;
&lt;td&gt;A DNS service and a separately selected email service&lt;/td&gt;
&lt;td&gt;Two-provider credential handling, record handoff, and evidence correlation&lt;/td&gt;
&lt;td&gt;A team that values independent DNS and email selection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Infrai DNS + email&lt;/td&gt;
&lt;td&gt;One capability interface and one key across the two groups&lt;/td&gt;
&lt;td&gt;Desired-state ownership, explicit obsolete-record decisions, and the delivery gate&lt;/td&gt;
&lt;td&gt;A team reducing integration surface while retaining its own acceptance controller&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Cloudflare-plus-Resend alternative requires two provider signups and two credential sets, along with glue that translates the mail service's requirements into DNS changes and correlates the later inspections. Route 53 plus SES can share an AWS account signup, but still introduces two service interfaces and their permission configuration; don't turn that into an inaccurate claim that two AWS accounts are required. Account count and integration count measure different work.&lt;/p&gt;

&lt;p&gt;The specialist or direct-provider choice is better when the team's required control or evidence exists at that provider's interface and hasn't been established in a shared contract. Conversely, choosing an independent email service for its sending workflow doesn't, by itself, establish where inbound mail is received. Make receiving ownership explicit in every row of the implementation plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Go gate that carries DNS evidence into the mail check
&lt;/h2&gt;

&lt;p&gt;The preventative code path below is deliberately read-only. It lists DNS records, presents the actual response for review, and permits the email-domain inspection only after the operator has checked MX priorities and leftovers. The first response becomes the evidence input to the second stage; its digest is carried into the mail inspection output so the two cannot be casually detached during review. This is a human approval gate, not an automated interpretation of unspecified response fields.&lt;/p&gt;

&lt;p&gt;Use Go with the standard library. Save the block as main.go and run it with go run main.go. Set INFRAI_API_KEY, CUSTOMER_DOMAIN, and DNS_RECORD_LIST_QUERY in the process environment. The query must contain the actual record-list parameters for that domain from the public discovery schema, encoded as a URL query string without a leading question mark. No parameter names or response-envelope fields are assumed here. The operator must verify that the returned inventory belongs to CUSTOMER_DOMAIN before approving it.&lt;/p&gt;

&lt;p&gt;Both requests use the same key and base URL. The two API routes appear only in this runnable example. HTTP 429 responses trigger bounded exponential backoff; a Retry-After value, in seconds or HTTP-date form, overrides that delay. Other unsuccessful responses surface their body.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;"bufio"&lt;/span&gt;
    &lt;span class="s2"&gt;"crypto/sha256"&lt;/span&gt;
    &lt;span class="s2"&gt;"fmt"&lt;/span&gt;
    &lt;span class="s2"&gt;"io"&lt;/span&gt;
    &lt;span class="s2"&gt;"net/http"&lt;/span&gt;
    &lt;span class="s2"&gt;"net/url"&lt;/span&gt;
    &lt;span class="s2"&gt;"os"&lt;/span&gt;
    &lt;span class="s2"&gt;"strconv"&lt;/span&gt;
    &lt;span class="s2"&gt;"strings"&lt;/span&gt;
    &lt;span class="s2"&gt;"time"&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;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://api.infrai.cc/v1"&lt;/span&gt;

&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nc"&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="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;readAPI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NewRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MethodGet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;nil&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;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Authorization"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Bearer "&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Do&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;nil&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;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ReadAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Close&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="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;nil&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;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;StatusCode&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;StatusTooManyRequests&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Second&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;retry&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Retry-After"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strconv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Atoi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;seconds&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Second&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;until&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ParseTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Until&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;until&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;StatusCode&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;StatusCode&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&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;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"HTTP %d: %s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;StatusCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&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="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;nil&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;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"retry budget exhausted"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;inspectMail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dnsEvidence&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;digest&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Sum256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dnsEvidence&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"DNS inventory for review (%s):\n%s\n"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dnsEvidence&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"After checking domain, MX priorities, and obsolete providers, type APPROVE:"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;approval&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;bufio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NewReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Stdin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ReadString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'\n'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TrimSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;approval&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"APPROVE"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"DNS evidence was not approved"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;mail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nf"&gt;readAPI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"/email/domain/get/"&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PathEscape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domain&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="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Domain: %s\nDNS evidence SHA-256: %x\nMail inspection:\n%s\n"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mail&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;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"INFRAI_API_KEY"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;domain&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TrimSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"CUSTOMER_DOMAIN"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;rawQuery&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"DNS_RECORD_LIST_QUERY"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;rawQuery&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"set INFRAI_API_KEY, CUSTOMER_DOMAIN, and DNS_RECORD_LIST_QUERY"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ParseQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rawQuery&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;dnsEvidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nf"&gt;readAPI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"/dns/record/list?"&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;inspectMail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dnsEvidence&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nf"&gt;run&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="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Fprintln&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Stderr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Exit&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An approval prompt is intentionally modest evidence. It doesn't validate the inventory, bind the domain cryptographically to the response, or prove that a message reached a mailbox. The digest identifies the reviewed bytes; it doesn't make a stale inventory fresh. A production controller should use the published schemas to parse the result, enforce the reviewed desired set, and retain the independent inbound acceptance result before declaring readiness.&lt;/p&gt;

&lt;p&gt;Deletion belongs in a separately reviewed change, followed by a fresh inventory and a new gate run. Keeping this example read-only avoids implying that an unknown response shape is enough information to choose records for deletion. An automated write path also needs retry idempotency and explicit record ownership.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put the release gate in the SLO
&lt;/h2&gt;

&lt;p&gt;Define customer-domain readiness around the evidence required by the service's SLO, rather than around the success status of a DNS mutation. The invariant is small: the intended receiving provider owns the accepted MX set, obsolete destinations are absent, and the receiving-side acceptance check passes. The mail-domain inspection contributes configuration evidence; it isn't the acceptance check itself.&lt;/p&gt;

&lt;p&gt;Capacity planning belongs here too. Each run of the example makes two successful API reads in the ordinary case; retries add requests, while waiting for an operator adds elapsed time. For a batch of N customer domains, plan the baseline as 2N reads and budget retries and review capacity separately. This is arithmetic for this controller, not a statement about vendor throughput or a measured latency claim. A fleet rollout needs bounded concurrency and a way to stop advancing when the evidence backlog outgrows the people or automation reviewing it.&lt;/p&gt;

&lt;p&gt;Don't apply this MX cleanup diagnosis to every missing message. It fits a migration with stale receiving destinations or conflicting provider priorities. If those conditions are absent, or a specialist exposes required receiving evidence that this integration doesn't cover, follow that evidence instead. The engineering win is a defensible readiness decision, not a larger pile of successful API calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources and References
&lt;/h2&gt;

&lt;p&gt;References for the capability contract, authentication boundary, and the direct-service alternatives discussed above:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc7489" rel="noopener noreferrer"&gt;RFC 7489, DMARC&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/Welcome.html" rel="noopener noreferrer"&gt;Amazon Route 53 documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/ses/latest/dg/Welcome.html" rel="noopener noreferrer"&gt;Amazon SES documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/dns/" rel="noopener noreferrer"&gt;Cloudflare DNS documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://resend.com/docs/introduction" rel="noopener noreferrer"&gt;Resend documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the shared-controller boundary fits your customer-domain workflow, start with the public schemas and examples in the &lt;a href="https://docs.infrai.cc" rel="noopener noreferrer"&gt;Infrai documentation&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>dns</category>
      <category>email</category>
      <category>go</category>
    </item>
    <item>
      <title>Uploaded Invoice OCR Backlogs — Store Extracted Text After Scan Validation</title>
      <dc:creator>finnmorgan226</dc:creator>
      <pubDate>Thu, 12 Nov 2026 01:44:47 +0000</pubDate>
      <link>https://dev.to/finnmorgan226/uploaded-invoice-ocr-backlogs-store-extracted-text-after-scan-validation-1bno</link>
      <guid>https://dev.to/finnmorgan226/uploaded-invoice-ocr-backlogs-store-extracted-text-after-scan-validation-1bno</guid>
      <description>&lt;p&gt;The page fires because invoice search has stopped finding recent orders. On-call sees successful uploads, no missing originals, and a growing interval between &lt;code&gt;uploaded_at&lt;/code&gt; and &lt;code&gt;text_stored_at&lt;/code&gt;. The customer-facing symptom arrived late: the useful alert was the age of the oldest accepted invoice still waiting for OCR.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Short answer:&lt;/strong&gt; accept and validate the uploaded scan, store the original under its document ID, enqueue OCR, then persist extracted text and confidence separately. Keep the original so a template change or a better extractor can trigger reprocessing. Alert on queue age against the search-availability SLO, not merely on OCR errors. Running OCR inline on a large scan makes the upload request inherit OCR latency and timeout risk.&lt;/p&gt;

&lt;p&gt;This is an ownership decision disguised as an API choice. An e-commerce team that owns invoice layouts can validate fields against its order record and tune extraction deliberately; a team receiving arbitrary supplier scans needs a review path because confidence, when available, is a routing signal rather than proof that an invoice is correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  How should Node.js store extracted text from an uploaded OCR scan?
&lt;/h2&gt;

&lt;p&gt;An OCR failure counter catches explicit failures. It misses slow work, workers that are alive but underprovisioned, and accepted jobs that never become searchable. The earlier signal is backlog age: &lt;code&gt;now - uploaded_at&lt;/code&gt; for the oldest document without stored text. Pair it with arrival rate, completion rate, and the count awaiting review. Those four values answer the capacity question the page cannot: is this a brief burst, or is demand outrunning service capacity? In a Node.js upload service, the HTTP handler should validate and admit the scan, return its document ID, and leave extraction and text storage to a worker.&lt;/p&gt;

&lt;p&gt;Waiting compounds.&lt;/p&gt;

&lt;p&gt;Set the page from the user promise. If searchable invoice text has a 15-minute SLO, the paging threshold needs enough remaining budget for diagnosis and recovery; paging at minute 15 only documents a breach. I would also separate freshness from correctness. A fast extractor that produces unusable totals is not healthy, while a correct result waiting behind an unbounded queue is not available.&lt;/p&gt;

&lt;p&gt;The upload handler should return after durable admission, not after recognition. Validate the content type, size, document ID, and known order association before admitting work. Store the scan privately and store the extracted representation as a different object or record. Never overwrite the evidence with its interpretation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Instrument the state transition, not the HTTP request
&lt;/h2&gt;

&lt;p&gt;The useful unit is one document moving through named states: accepted, original stored, queued, extracting, text stored, or review required. An HTTP latency histogram around the upload endpoint cannot tell you that the worker stopped completing documents. A transition record can.&lt;/p&gt;

&lt;p&gt;Before binding a worker to any vendor request shape, inspect the live contract. This Go program calls the platform's public discovery surface, takes its URL and bearer authentication from the environment, uses an explicit method, retries 429 responses with &lt;code&gt;Retry-After&lt;/code&gt;, and surfaces response bodies for other errors. The returned catalog is where a client should obtain the documented OCR path and full JSON Schema rather than guessing fields from prose.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;"fmt"&lt;/span&gt;
    &lt;span class="s2"&gt;"io"&lt;/span&gt;
    &lt;span class="s2"&gt;"net/http"&lt;/span&gt;
    &lt;span class="s2"&gt;"os"&lt;/span&gt;
    &lt;span class="s2"&gt;"strconv"&lt;/span&gt;
    &lt;span class="s2"&gt;"time"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;retryDelay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Duration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strconv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Atoi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Retry-After"&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="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;seconds&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Second&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Second&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;main&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="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"INFRAI_API_KEY"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key&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="nb"&gt;panic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"INFRAI_API_KEY is required"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;discoveryURL&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"OCR_DISCOVERY_URL"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;discoveryURL&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="nb"&gt;panic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"OCR_DISCOVERY_URL is required"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nc"&gt;Timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NewRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MethodGet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;discoveryURL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nb"&gt;panic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Authorization"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Bearer "&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Do&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nb"&gt;panic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;readErr&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ReadAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Close&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="n"&gt;readErr&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nb"&gt;panic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;readErr&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="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;StatusCode&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;StatusTooManyRequests&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;retryDelay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;StatusCode&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;StatusCode&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nb"&gt;panic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"discovery failed: status=%d body=%s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;StatusCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&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="n"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&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="nb"&gt;panic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"discovery remained rate limited after retries"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The call does not perform OCR, by design. Discovery is public without a key, but using the same environment-based authorization pattern keeps the sample aligned with the protected capability call that follows after the client reads the schema. Do not invent that body. Once extraction completes, reject empty text and invalid confidence ranges before storage. Do not turn a sample confidence such as &lt;code&gt;0.94&lt;/code&gt; into a universal acceptance threshold: scales are extractor-specific, and a whole-document score can hide a bad total or tax identifier. Calibrate review rules on representative invoices, including faint scans, rotated pages, and layouts that changed after a merchant template release. The order system can supply stronger validation through expected currency, plausible total, order number, and merchant identity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Template ownership changes the vendor decision
&lt;/h2&gt;

&lt;p&gt;Managed OCR and self-hosted OCR move different work onto the platform team. The API integration is the small part; template lifecycle, data handling, review tooling, capacity, and incident ownership dominate after launch.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Who owns extraction behavior?&lt;/th&gt;
&lt;th&gt;Operational trade-off&lt;/th&gt;
&lt;th&gt;Best fit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AWS Textract&lt;/td&gt;
&lt;td&gt;AWS supplies managed invoice and document analysis; the application owns validation and review&lt;/td&gt;
&lt;td&gt;Less OCR infrastructure, with AWS-specific request and result contracts&lt;/td&gt;
&lt;td&gt;Teams already operating invoice workflows in AWS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Document AI&lt;/td&gt;
&lt;td&gt;Google supplies managed processors; the application owns processor selection, validation, and downstream schema&lt;/td&gt;
&lt;td&gt;Managed processing with Google Cloud coupling&lt;/td&gt;
&lt;td&gt;Teams whose document estate and controls already sit in Google Cloud&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Azure AI Document Intelligence&lt;/td&gt;
&lt;td&gt;Microsoft supplies prebuilt and custom document models; the application owns validation and model lifecycle choices&lt;/td&gt;
&lt;td&gt;Managed service with Azure-specific tooling and contracts&lt;/td&gt;
&lt;td&gt;Teams centered on Azure that need prebuilt or customized extraction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tesseract OCR&lt;/td&gt;
&lt;td&gt;The team owns deployment, scaling, preprocessing, language data, and output interpretation&lt;/td&gt;
&lt;td&gt;Maximum runtime control and the largest on-call surface&lt;/td&gt;
&lt;td&gt;Stable templates, specialist OCR skills, or constraints that rule out managed processing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unified backend API&lt;/td&gt;
&lt;td&gt;The platform supplies OCR through the same REST contract used by a broader backend surface; the application still owns invoice validation and review&lt;/td&gt;
&lt;td&gt;One contract across document and adjacent modules reduces integration sprawl, but creates a platform dependency&lt;/td&gt;
&lt;td&gt;Small platform teams that value contract consistency across document, storage, queue, and adjacent capabilities&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DocRaptor or PDFMonkey&lt;/td&gt;
&lt;td&gt;The application owns HTML or template inputs while the service renders the invoice PDF&lt;/td&gt;
&lt;td&gt;Avoids owning a rendering fleet but does not solve OCR of received scans&lt;/td&gt;
&lt;td&gt;Teams that generate invoices and can eliminate extraction from their own workflow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gotenberg or WeasyPrint&lt;/td&gt;
&lt;td&gt;The team owns templates and the rendering runtime or deployment&lt;/td&gt;
&lt;td&gt;Greater template and runtime control, with more capacity and patching work&lt;/td&gt;
&lt;td&gt;Teams willing to operate PDF generation and keep source templates portable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of the OCR choices removes the need to retain the scan and validate the business fields. Managed processors reduce model hosting work, but their contracts and template concepts create switching work. Tesseract avoids a managed-service dependency, yet its capacity and quality operations land squarely on your team. DocRaptor, PDFMonkey, Gotenberg, and WeasyPrint address the other side of template ownership: when you generate the invoice, preserving structured source data is better than OCRing your own output. Infrai uses one REST API, one key, and one bill across 295 routes in 20 modules, so OCR, storage, and queue capabilities share a consistent contract instead of adding another SDK and credential set. That breadth is compelling when on-call headcount is the binding constraint; it is weaker when procurement requires direct vendor relationships or the team needs full control of the OCR runtime.&lt;/p&gt;

&lt;p&gt;Own the boundary.&lt;/p&gt;

&lt;p&gt;For a template you control, begin with deterministic checks before adding custom extraction machinery. Version the template, record that version beside the invoice, and replay retained originals when extraction rules change. For supplier-controlled layouts, assume drift. Sample results by supplier and template version so a high aggregate success rate cannot hide one merchant's broken invoices.&lt;/p&gt;

&lt;h2&gt;
  
  
  Capacity is an SLO input
&lt;/h2&gt;

&lt;p&gt;Queueing does not create capacity; it makes overload survivable and visible. Track peak accepted scans per minute, completed scans per minute, scan-size distribution, and processing-time distribution. Then test a burst large enough to consume the recovery margin. Average traffic is a poor sizing target for invoice workloads because order and settlement cycles create correlated arrivals.&lt;/p&gt;

&lt;p&gt;A practical admission rule is blunt: if projected drain time would exhaust the freshness budget, stop pretending the system is healthy. Shed optional reprocessing, preserve new invoice admission where policy permits, and page before customers discover stale search. Standard queues may deliver at least once, so consumers must make storage idempotent using the document ID and extraction version.&lt;/p&gt;

&lt;p&gt;Keep two service indicators. Freshness measures the share of accepted documents whose text is stored within the objective. Review escape rate measures documents later found to have material extraction errors after automatic acceptance. One without the other invites a bad optimization.&lt;/p&gt;

&lt;h2&gt;
  
  
  The threshold can become its own incident
&lt;/h2&gt;

&lt;p&gt;A queue-age alert set too close to normal burst behavior pages on every settlement run. Engineers mute it, and the real backlog later arrives quietly. Set warning and paging thresholds from observed demand and the stated freshness budget, require a sustained window where appropriate, and route low-confidence documents to review without paging on each item.&lt;/p&gt;

&lt;p&gt;False positives consume the same scarce on-call capacity that a managed OCR choice was meant to protect. The final design criterion is therefore not raw OCR throughput. It is whether the pipeline gives the team enough early, trustworthy signal to protect invoice search while preserving the original evidence for another attempt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.iso.org/standard/75839.html" rel="noopener noreferrer"&gt;ISO 32000-2, Portable Document Format&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/textract/" rel="noopener noreferrer"&gt;AWS Textract documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cloud.google.com/document-ai/docs" rel="noopener noreferrer"&gt;Google Cloud Document AI documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/azure/ai-services/document-intelligence/" rel="noopener noreferrer"&gt;Azure AI Document Intelligence documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://tesseract-ocr.github.io/" rel="noopener noreferrer"&gt;Tesseract OCR documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docraptor.com/documentation/" rel="noopener noreferrer"&gt;DocRaptor documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.pdfmonkey.io/" rel="noopener noreferrer"&gt;PDFMonkey documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://gotenberg.dev/docs/" rel="noopener noreferrer"&gt;Gotenberg documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://doc.courtbouillon.org/weasyprint/stable/" rel="noopener noreferrer"&gt;WeasyPrint documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://sre.google/workbook/alerting-on-slos/" rel="noopener noreferrer"&gt;Google SRE Workbook, Alerting on SLOs&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ocr</category>
      <category>observability</category>
      <category>documentprocessing</category>
    </item>
    <item>
      <title>Wiki.js discovery: a title count is not a permissions review</title>
      <dc:creator>kozhevniko</dc:creator>
      <pubDate>Thu, 12 Nov 2026 01:43:48 +0000</pubDate>
      <link>https://dev.to/kozhevniko/wikijs-discovery-a-title-count-is-not-a-permissions-review-2olk</link>
      <guid>https://dev.to/kozhevniko/wikijs-discovery-a-title-count-is-not-a-permissions-review-2olk</guid>
      <description>&lt;h1&gt;
  
  
  Wiki.js discovery: a title count is not a permissions review
&lt;/h1&gt;

&lt;p&gt;Wiki.js deployments often sit behind a custom domain and a title chosen by the organization. The application fingerprint can miss instances while a title search finds many pages that mention the product. A useful review starts with discovery confidence, then moves to the authentication and page-permission model.&lt;/p&gt;

&lt;p&gt;A bounded ZoomEye SDK probe on 2026-11-12 between 00:49:21 and 00:49:25 UTC used &lt;code&gt;sub_type=all&lt;/code&gt;, page 1, and a page size of one. The application query &lt;code&gt;app="Wiki.js"&lt;/code&gt; returned 1. The title query &lt;code&gt;title="Wiki.js"&lt;/code&gt; returned 11,979, and &lt;code&gt;http.body="Wiki.js"&lt;/code&gt; returned 827,833. The title result is a candidate inventory. The body result is too broad for a deployment count, and the single application match shows that fingerprint coverage alone is not sufficient.&lt;/p&gt;

&lt;h2&gt;
  
  
  Determine the product generation
&lt;/h2&gt;

&lt;p&gt;Wiki.js 2.x and 3.x have different administration and API documentation. The current public documentation set includes a 3.x site and a 2.x archive. Check the installed version before applying an auth or API procedure. A team that follows a 3.x REST API instruction on a 2.x deployment will waste time and may misread the permission model.&lt;/p&gt;

&lt;p&gt;Review the authentication modules in use. Local passwords may be disabled in favor of SSO, but the local login path and recovery process still need an owner. Check group membership, page rules, and API token scopes. The product documentation describes fine-grained permissions and recommends a dedicated group for API access.&lt;/p&gt;

&lt;p&gt;Enable API access only when the deployment needs it. An API token should carry the smallest set of permissions required by its automation. Treat the token as an identity: record who created it, which group it belongs to, which pages or actions it can reach, and when it will be rotated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Review the public and private page boundary
&lt;/h2&gt;

&lt;p&gt;A wiki combines public documentation, authenticated pages, and administrative settings. Test a guest request and a low-privilege user request against a page that should not be visible. Check whether search results, navigation, sitemap entries, and asset paths follow the same rules as the page view. An image or attachment URL can bypass a page-level expectation if it is served from a public path.&lt;/p&gt;

&lt;p&gt;Review user registration and invitations. An open registration flow can create an account that enters a default group with more access than the owner realizes. Check the default group for every authentication module. If SSO supplies roles or groups, verify how a missing or unknown group is handled.&lt;/p&gt;

&lt;p&gt;The admin area should be reachable only by the accounts that need it. Check the reverse proxy, authentication policy, and session lifetime. A custom domain does not make an exposed login page safer. Security headers and TLS settings matter, but they do not replace page and group authorization.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build an owner-confirmed wiki list
&lt;/h2&gt;

&lt;p&gt;Use the title candidates to find likely sites, then confirm the product and version from an authorized request or the owner's records. Deduplicate custom domains and staging hosts. The 827,833 body matches should be retained only as a recall signal. A page that mentions Wiki.js in a tutorial is not a Wiki.js deployment.&lt;/p&gt;

&lt;p&gt;For each owned deployment, record the version, auth modules, default group, API status, page rules, storage backend, backup owner, and update process. Check whether public search is enabled and whether it indexes pages outside the intended audience. Review a recent authentication change and confirm that a revoked user cannot still read a cached page.&lt;/p&gt;

&lt;p&gt;ZoomEye helps find candidate wikis that are missing from a service catalogue. The security answer depends on the permission model and the deployment's public routes. A title count can start an inventory; it cannot establish that the wiki's private pages are actually private.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.js.wiki/admin/auth" rel="noopener noreferrer"&gt;Wiki.js authentication documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.js.wiki/dev/api" rel="noopener noreferrer"&gt;Wiki.js REST API documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.js.wiki/admin/permissions" rel="noopener noreferrer"&gt;Wiki.js permissions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.requarks.io/" rel="noopener noreferrer"&gt;Wiki.js 2.x documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>zoomeye</category>
      <category>zoomeye08wikijs</category>
    </item>
    <item>
      <title>Learning Claude Code by Building the Tool I Actually Need</title>
      <dc:creator>Paola Arredondo</dc:creator>
      <pubDate>Thu, 12 Nov 2026 01:43:17 +0000</pubDate>
      <link>https://dev.to/arredondop03/learning-claude-code-by-building-the-tool-i-actually-need-4dci</link>
      <guid>https://dev.to/arredondop03/learning-claude-code-by-building-the-tool-i-actually-need-4dci</guid>
      <description>&lt;p&gt;After attempting and failing at using AI to build web applications for a while now, I decided to enroll on &lt;a href="https://codewithmosh.com/p/claude-code" rel="noopener noreferrer"&gt;Code with Mosh's Claude Code for Professional Developers course&lt;/a&gt;. My goal is to plan, make decisions, and have Claude Code write ~90% of the code and I’ll be using this platform to document my progress and what I’ve learned.&lt;/p&gt;

&lt;p&gt;I am looking for my next opportunity as a software engineer, and I need a way to keep track of the people I’m networking with, as well as my follow ups. I thought of using a CRM like Salesforce, but I couldn't find a way to use the API to automate repetitive manual work without having an enterprise plan, so I decided to build my own app.&lt;/p&gt;

&lt;p&gt;I built the app and it is technically working, but I am not happy with the code Claude Code generated. It’s messy, it doesn't follow best practices and I’ve found myself spending more time cleaning up the code than it would have taken me to write it all myself from scratch. I want to learn how to use Claude Code as a tool to reduce my workload, not give me more problems to fix.&lt;/p&gt;

&lt;p&gt;This second attempt of the app is called CC Outreach (I know, my project naming creativity is out of this world.) This app will allow me to perform CRUD operations on companies and contacts, log all the interactions I've had with people, and schedule follow-ups automatically.&lt;/p&gt;

&lt;p&gt;I am going to be following the course lesson by lesson, using the tech stack chosen by the instructor so I can focus on learning one thing at a time. I am, however, working on my own project instead of the one the instructor is working on. This way, I don't have to do the work twice, and it gives me an extra bit of challenge because I would have to be thinking about my own problems and making my own decisions instead of copying what the instructor says.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Project scope:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;V1 is an outreach CRM that lets me create, edit, archive and view companies and contacts, drafts short messages for a 5-step email/LinkedIn sequence for me to review and send (emails go out through SendGrid), and tells me each day what to do next with daily and weekly planning views and activity logging.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tech stack:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;React + TypeScript&lt;/strong&gt; (built with Vite): the single-page frontend&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tailwind and shadcn/ui&lt;/strong&gt;: styling and UI components&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Express + TypeScript&lt;/strong&gt;: the backend&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prisma on PostgreSQL&lt;/strong&gt;: the database and data access&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Better Auth&lt;/strong&gt;: the authentication framework&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apify&lt;/strong&gt;: enriches company and contact data&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude API&lt;/strong&gt;: drafts the short messages&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SendGrid&lt;/strong&gt;: sends each email, only after I review it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I had Claude implement the project setup and authentication. Because I have not worked on backend code before, I asked Claude to explain every single file to me. Everything made sense except for one thing: the schema for the models used for the authentication process. It contained some fields and one model that I believed I didn’t need for a simple email and password login. I asked Claude if I could remove them. It said I technically could, but advised against it for two reasons: the savings would be tiny, and Better Auth generates and expects those fields. The first reason didn’t convince me, since I don’t like keeping fields for features I don’t need yet. And because I’d dismissed the first reason, I didn’t take the second one seriously either.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnh18suy0naeqty4dzar9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnh18suy0naeqty4dzar9.png" alt="Claude's answer" width="800" height="305"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I removed the fields anyway and ran a migration. After testing the login endpoint again, I found out that the login functionality stopped working because the second reason was correct: those fields were generated and required by Better Auth. I stand corrected and apologize to Mr. Claude, he was right 😅. Next time, before I remove anything I don’t understand, I will ask where it comes from and for a &lt;a href="https://better-auth.com/docs/concepts/cli#generate" rel="noopener noreferrer"&gt;link to the documentation&lt;/a&gt;, so I can check it myself.&lt;/p&gt;

&lt;p&gt;Up next is going to be building the login page in the frontend. I’m excited to see the quality of the output this time around. If you're interested in chatting about this project or anything about Claude Code, don’t hesitate to reach out to me at &lt;a href="mailto:paola@paworkflows.com"&gt;paola@paworkflows.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Github Repository: &lt;a href="https://github.com/arredondop03/cc-outreach-be" rel="noopener noreferrer"&gt;cc-outreach-be&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claude</category>
      <category>javascript</category>
      <category>learning</category>
    </item>
    <item>
      <title>Faster Java Pod Startups Without GraalVM: Why AppCDS Needs Kubernetes Image Volumes</title>
      <dc:creator>BaoKhang</dc:creator>
      <pubDate>Thu, 12 Nov 2026 01:43:17 +0000</pubDate>
      <link>https://dev.to/baokhang83/faster-java-pod-startups-without-graalvm-why-appcds-needs-kubernetes-image-volumes-5dgf</link>
      <guid>https://dev.to/baokhang83/faster-java-pod-startups-without-graalvm-why-appcds-needs-kubernetes-image-volumes-5dgf</guid>
      <description>&lt;p&gt;If you run Java workloads on Kubernetes, you have probably stared at slow pod readiness metrics during a deployment or autoscaling event. While the JVM is unmatched for long-running throughput, its cold-start tax—spent loading, verifying, and linking thousands of classes—can make scaling painful.&lt;/p&gt;

&lt;p&gt;The usual advice is to compile your application into a GraalVM Native Image. That delivers sub-second startups, but it comes at a steep operational price: long build times, closed-world reflection constraints, and breaking changes to existing Java agents and libraries.&lt;/p&gt;

&lt;p&gt;OpenJDK offers a middle ground: &lt;strong&gt;Application Class-Data Sharing (AppCDS)&lt;/strong&gt; on JDK 21+ and &lt;strong&gt;Project Leyden AOT caches&lt;/strong&gt; on JDK 25+. These features let the JVM dump its internal class metadata into a pre-linked archive file on disk, allowing subsequent restarts to memory-map the classes instead of parsing them from scratch.&lt;/p&gt;

&lt;p&gt;When I started building &lt;a href="https://github.com/baokhang83/mnemo-warmup" rel="noopener noreferrer"&gt;&lt;strong&gt;mnemo-warmup&lt;/strong&gt;&lt;/a&gt;—an open-source tool that automates training and sharing AppCDS/Leyden caches inside Kubernetes without changing application source code—I wanted to answer a simple engineering question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Does a Kubernetes pod actually become Ready sooner once you pay the infrastructure tax of fetching, verifying, and mounting the cache?&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;After running hundreds of automated cold-restart benchmarks across different JVM architectures, the answer turned out to be: &lt;strong&gt;It depends entirely on how you deliver the tooling, and what your application does during its boot cycle.&lt;/strong&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  The Hidden Infrastructure Tax: Init Containers
&lt;/h3&gt;

&lt;p&gt;In a standard Kubernetes setup, injecting a shared cache archive into a stateless pod requires a familiar pattern:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;An install init container copies the launcher tools onto a shared &lt;code&gt;emptyDir&lt;/code&gt; volume.&lt;/li&gt;
&lt;li&gt;A fetch init container pulls the trained archive from an OCI registry and verifies its SHA-256 digest.&lt;/li&gt;
&lt;li&gt;The main application container boots the JVM pointing to the shared archive.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;On paper, this looks clean. In practice, measuring the end-to-end timeline from &lt;code&gt;pod.status.startTime&lt;/code&gt; to &lt;code&gt;Ready&lt;/code&gt; revealed a major bottleneck.&lt;/p&gt;

&lt;p&gt;In our initial benchmarks running a heavy Spring Boot application (69 JARs, constrained to 1 CPU / 1 GiB), the AppCDS archive successfully cut the JVM's internal startup time from &lt;strong&gt;3.18s down to 1.88s&lt;/strong&gt;. Yet, total pod readiness remained completely unchanged at &lt;strong&gt;5 seconds&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Even after rewriting our helper utilities from Java into static Go binaries (dropping tool execution time from ~1,050ms down to ~140ms), the pod was still losing nearly &lt;strong&gt;1.75 seconds&lt;/strong&gt; before the main application even began. The culprit wasn't our code—it was Kubernetes itself. Because the kubelet schedules, initializes, and tears down init containers sequentially, the orchestration overhead was eating 100% of our JVM startup gains.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Breakthrough: Kubernetes Image Volumes
&lt;/h3&gt;

&lt;p&gt;To eliminate the init-container tax, we pivoted to &lt;strong&gt;Kubernetes&lt;/strong&gt; &lt;code&gt;image&lt;/code&gt; &lt;strong&gt;volumes&lt;/strong&gt; (introduced as beta in Kubernetes 1.33+ and supported by containerd 2.1+).&lt;/p&gt;

&lt;p&gt;Instead of spinning up sequential containers to copy binaries around, an &lt;code&gt;image&lt;/code&gt; volume allows the container runtime to mount the read-only OCI tools layer directly into the application container's filesystem at launch. Our static launcher runs as the entrypoint, fetches the archive directly, and replaces itself with &lt;code&gt;java&lt;/code&gt; via &lt;code&gt;exec&lt;/code&gt; so the JVM remains PID 1.&lt;/p&gt;

&lt;p&gt;Removing the init containers changed the economics overnight:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Workload Profile (1 CPU / 1 GiB)&lt;/th&gt;
&lt;th&gt;Plain Deployment (&lt;code&gt;none&lt;/code&gt;)&lt;/th&gt;
&lt;th&gt;Init Containers (&lt;code&gt;cached&lt;/code&gt;)&lt;/th&gt;
&lt;th&gt;Image Volume (&lt;code&gt;cached&lt;/code&gt;)&lt;/th&gt;
&lt;th&gt;Net Pod-to-Ready Delta&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;🟢 &lt;strong&gt;Spring Boot&lt;/strong&gt; (69 JARs)&lt;/td&gt;
&lt;td&gt;5 s&lt;/td&gt;
&lt;td&gt;5 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;-2 s (40% faster)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🟡 &lt;strong&gt;Jenkins LTS&lt;/strong&gt; (2.492.3)&lt;/td&gt;
&lt;td&gt;4 s&lt;/td&gt;
&lt;td&gt;5 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0 s (Parity)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🔴 &lt;strong&gt;SonarQube&lt;/strong&gt; (26.11)&lt;/td&gt;
&lt;td&gt;15 s&lt;/td&gt;
&lt;td&gt;17 s&lt;/td&gt;
&lt;td&gt;&lt;em&gt;Not Run&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+2 s (Slower)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;(Note: Figures are medians over 12 cold restarts on a local kind cluster; Kubernetes pod condition timestamps have a 1-second resolution.)&lt;/em&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  Three Lessons on Workload Suitability
&lt;/h3&gt;

&lt;p&gt;Testing across three distinct real-world applications mapped out the exact boundaries of where shared class caches thrive and where they fail.&lt;/p&gt;

&lt;h4&gt;
  
  
  1. Framework-Heavy Apps See Massive Gains (Spring Boot)
&lt;/h4&gt;

&lt;p&gt;Modern microservice frameworks spend the vast majority of their boot cycle scanning classpaths, evaluating conditional annotations, and weaving dependency graphs. On our heavy Spring Boot workload, AppCDS cut JVM initialization by over 40%. Once paired with &lt;code&gt;image&lt;/code&gt; volume delivery, total pod readiness dropped from &lt;strong&gt;5 seconds to 3 seconds&lt;/strong&gt;—with zero changes to the application's source code.&lt;/p&gt;

&lt;h4&gt;
  
  
  2. I/O and Plugin-Bound Apps Hit Parity (Jenkins)
&lt;/h4&gt;

&lt;p&gt;Jenkins is a massive Java monolith, making it seem like an ideal candidate for AppCDS. However, while the archive successfully loaded 85% of Jenkins' classes and shaved ~300ms off the raw JVM boot, total pod readiness stayed flat at &lt;strong&gt;4 seconds&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Jenkins spends most of its startup timeline performing tasks that class-data sharing cannot accelerate: unpacking bundled archives, parsing XML configurations on disk, and executing sequential plugin initialization hooks. When class loading isn't your primary bottleneck, caching won't move the needle.&lt;/p&gt;

&lt;h4&gt;
  
  
  3. Multi-Process Wrappers Bypass the Cache (SonarQube)
&lt;/h4&gt;

&lt;p&gt;SonarQube proved to be an important architectural edge case. In our test harness (run at 2 CPU / 3 GiB), training succeeded cleanly and all 12 pods validated and loaded the archive. Yet pod readiness got 2 seconds &lt;em&gt;slower&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The reason lies in how SonarQube boots. Its container entrypoint launches a small Java coordinator process, which then dynamically spawns &lt;strong&gt;three separate child JVMs&lt;/strong&gt; inside the container (one for ElasticSearch, one for the Web Server, and one for the Compute Engine). Because a container wrapper only passes &lt;code&gt;-XX:SharedArchiveFile&lt;/code&gt; to the parent entrypoint command, the heavy child JVMs booted completely cold.&lt;/p&gt;




&lt;h3&gt;
  
  
  The Rule of Thumb
&lt;/h3&gt;

&lt;p&gt;If you are evaluating AppCDS or Project Leyden for your Kubernetes clusters, use these three rules to predict your ROI:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Check your delivery mechanism:&lt;/strong&gt; If you are running on an older cluster that forces you to rely on sequential init containers, the kubelet scheduling overhead will likely cancel out your savings on fast-starting microservices. Target clusters supporting Kubernetes &lt;code&gt;image&lt;/code&gt; volumes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Profile your startup bottleneck:&lt;/strong&gt; If your application spends its first seconds loading classes and wiring frameworks (Spring Boot, Micronaut), expect a 30–40% reduction in pod readiness. If it spends its time running database migrations, unpacking files, or waiting on network I/O, expect parity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch out for wrapper scripts:&lt;/strong&gt; Single-process containers work out of the box. If your container launches a supervisor process that forks downstream child JVMs, those child processes will bypass the cache unless explicitly configured.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What's Next for &lt;code&gt;mnemo-warmup&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;mnemo-warmup&lt;/code&gt; is currently in active &lt;strong&gt;alpha&lt;/strong&gt;. Before tagging our &lt;code&gt;v0.3.0&lt;/code&gt; beta, we are running the next wave of studies: comparing &lt;strong&gt;JDK 21 AppCDS against JDK 25 Project Leyden AOT&lt;/strong&gt; archive sizes, mapping out the &lt;strong&gt;application size break-even curve&lt;/strong&gt; (from 40 MB microservices to 350 MB monoliths), and simulating &lt;strong&gt;HPA scale-out bursts&lt;/strong&gt; against remote cloud registries.&lt;/p&gt;

&lt;p&gt;If you want to inspect the raw benchmark CSVs, reproduce the cluster tests on your own hardware, or weigh in on making &lt;code&gt;image&lt;/code&gt; volumes our default delivery mode, check out the repository at &lt;a href="https://github.com/baokhang83/mnemo-warmup" rel="noopener noreferrer"&gt;&lt;strong&gt;mnemo-warmup&lt;/strong&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is &lt;code&gt;mnemo-warmup&lt;/code&gt; and when is it a good fit?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;mnemo-warmup&lt;/code&gt; is a Kubernetes-native tool that automatically trains and shares OpenJDK &lt;strong&gt;AppCDS&lt;/strong&gt; (&lt;code&gt;.jsa&lt;/code&gt;) and &lt;strong&gt;Project Leyden AOT&lt;/strong&gt; (&lt;code&gt;.aot&lt;/code&gt;) caches across application pods without requiring source code changes or GraalVM compilation.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best fit:&lt;/strong&gt; Standard JVM applications (Java 21+) running on Kubernetes—especially framework-heavy microservices like &lt;strong&gt;Spring Boot&lt;/strong&gt;—where teams want faster pod startup and readiness times without migrating to GraalVM Native Image.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not a fit:&lt;/strong&gt; Workloads running on Java 17 or older, Windows containers, build-time optimized frameworks like Quarkus, or multi-process Java wrappers like &lt;strong&gt;SonarQube&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  How does &lt;code&gt;mnemo-warmup&lt;/code&gt; compare to GraalVM Native Image and build-time AOT?
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;vs. GraalVM Native Image:&lt;/strong&gt; GraalVM achieves sub-second startups but requires complex build pipelines, closed-world compilation, and breaks dynamic reflection or Java agents. &lt;code&gt;mnemo-warmup&lt;/code&gt; keeps the standard HotSpot JVM, requires zero code changes, and preserves full dynamic Java compatibility while still cutting Spring Boot pod readiness by ~40%.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;vs. Build-Time AppCDS / Leyden AOT (e.g., Spring Boot&lt;/strong&gt; &lt;code&gt;onRefresh&lt;/code&gt; &lt;strong&gt;in CI):&lt;/strong&gt; Baking a cache into a Docker image during CI causes &lt;strong&gt;fingerprint drift&lt;/strong&gt; if production Kubernetes manifests modify &lt;code&gt;-Xmx&lt;/code&gt;, GC flags, or environment variables. &lt;code&gt;mnemo-warmup&lt;/code&gt; trains asynchronously &lt;em&gt;inside the cluster&lt;/em&gt; using the exact live pod fingerprint.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Why does &lt;code&gt;mnemo-warmup&lt;/code&gt; recommend Kubernetes &lt;code&gt;image&lt;/code&gt; volumes over init containers?
&lt;/h3&gt;

&lt;p&gt;Standard Kubernetes init containers (&lt;code&gt;init-container&lt;/code&gt; mode) introduce ~1.75 seconds of sequential kubelet scheduling and container startup overhead, which cancels out the JVM startup savings on fast-booting microservices. Setting &lt;code&gt;warmup.mnemo.io/tools-delivery: "image-volume"&lt;/code&gt; (supported on Kubernetes 1.33+ and containerd 2.1+) mounts the tools directly as a read-only OCI layer, eliminating init containers and dropping Spring Boot Pod-to-Ready times from 5 seconds to 3 seconds.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which workloads see the best and worst performance gains?
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;High Gain (Spring Boot, Micronaut):&lt;/strong&gt; Single-process applications bottlenecked by runtime classpath scanning and bean wiring see a &lt;strong&gt;30% to 40% reduction&lt;/strong&gt; in total pod readiness time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Neutral / Parity (Jenkins):&lt;/strong&gt; Applications bottlenecked by disk I/O, archive extraction, and sequential plugin initialization see minor JVM startup gains (~300ms) but zero net change in pod readiness.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero Gain (SonarQube):&lt;/strong&gt; Containers whose entrypoint acts as a coordinator that dynamically spawns child JVM subprocesses (e.g., ElasticSearch, Web, Compute Engine) do not benefit, because the cache flag only applies to the parent coordinator process.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Can a missing, corrupted, or unreachable cache crash a Kubernetes pod?
&lt;/h3&gt;

&lt;p&gt;No. &lt;code&gt;mnemo-warmup&lt;/code&gt; is strictly fail-safe. It never passes &lt;code&gt;-Xshare:on&lt;/code&gt; (which causes fatal JVM exits on cache mismatch). If the OCI registry is unreachable, the archive digest is corrupted, or a JVM flag changes, the launcher logs the mismatch reason and falls back to an ordinary cold JVM start with exit code &lt;code&gt;0&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does &lt;code&gt;mnemo-warmup&lt;/code&gt; require a Kubernetes Operator, CRD, or mutating webhook?
&lt;/h3&gt;

&lt;p&gt;No. It operates entirely via a stateless CLI (&lt;code&gt;mnemo-warmup inject&lt;/code&gt;) used as a Helm 3 post-renderer or GitOps manifest transformer. Adding three pod template annotations (&lt;code&gt;warmup.mnemo.io/enabled&lt;/code&gt;, &lt;code&gt;warmup.mnemo.io/store&lt;/code&gt;, &lt;code&gt;warmup.mnemo.io/exit-mode&lt;/code&gt;) injects the required volume mounts and generates a standard Kubernetes &lt;code&gt;CronJob&lt;/code&gt; and &lt;code&gt;Lease&lt;/code&gt; for asynchronous training.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why must Spring Boot fat JARs be extracted before using &lt;code&gt;mnemo-warmup&lt;/code&gt;?
&lt;/h3&gt;

&lt;p&gt;OpenJDK invalidates AppCDS and Leyden AOT archives if the underlying JAR file modification times (&lt;code&gt;mtime&lt;/code&gt;) or paths differ between training and runtime. Extracting a Spring Boot 3.3+ fat JAR at container build time (&lt;code&gt;java -Djarmode=tools -jar app.jar extract&lt;/code&gt;) ensures the trainer pod and application replicas read identical, immutable file layouts.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>java</category>
      <category>performance</category>
    </item>
    <item>
      <title>Stop Reading Your AI Agent. Start Talking to It.</title>
      <dc:creator>Prasanna Nattuthurai</dc:creator>
      <pubDate>Thu, 12 Nov 2026 01:42:09 +0000</pubDate>
      <link>https://dev.to/prasannavarshan/stop-reading-your-ai-agent-start-talking-to-it-5495</link>
      <guid>https://dev.to/prasannavarshan/stop-reading-your-ai-agent-start-talking-to-it-5495</guid>
      <description>&lt;p&gt;&lt;em&gt;How I gave my coding agent a free, fully local voice for my personal projects, and why I think this is where agentic coding is headed.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🏡 Everything here was built and used on my own side projects, on my personal laptop.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Here's a confession: I've stopped reading most of what my coding agents write.&lt;/p&gt;

&lt;p&gt;Not because it's bad. Because there's too much of it. An agent kicks off, reads twelve files, runs the tests, edits four modules, and then hands me a 900-word wall of text with tables, file paths and a bulleted summary of a bulleted summary. By paragraph three my eyes are skimming. By paragraph five I've wandered off to another tab.&lt;/p&gt;

&lt;p&gt;That's the quiet problem with agentic coding nobody talks about.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The agents got faster. Our attention didn't.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The bottleneck isn't the model anymore. It's me, scrolling.&lt;/p&gt;

&lt;p&gt;So I tried something different. I gave my agents a voice and ears. Now I ask a question out loud, the agent goes off and does the work, and comes back with two spoken sentences: &lt;em&gt;"Tests pass, I changed the retry logic in two files. Want the details on screen?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;💻 It runs entirely on my laptop. 💸 It cost me nothing. I built it in Claude Code, but nothing about it is Claude-specific: any agent that can use MCP tools can get the same thing.&lt;/p&gt;

&lt;p&gt;Here's how, and why I think you should try it.&lt;/p&gt;




&lt;h2&gt;
  
  
  🎙️ Why voice, why now
&lt;/h2&gt;

&lt;p&gt;Three things changed my mind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Agents produce output faster than humans can read it.&lt;/strong&gt;&lt;br&gt;
A single agentic task can generate more text than a short blog post. Reading all of it is exhausting; skimming it means you miss the one line that mattered. Speech forces the agent to &lt;em&gt;summarise&lt;/em&gt;. You can't speak a table. You can't speak a 40-line diff. So the agent has to tell you what actually matters, and put the rest on screen if you want it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Talking keeps you in the loop without keeping you glued to the screen.&lt;/strong&gt;&lt;br&gt;
When I'm in voice mode I can lean back, look away, think. The agent works between turns and reports back by voice. It feels less like supervising a log file and more like pairing with a friend who's sitting next to you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. It's finally free and local.&lt;/strong&gt;&lt;br&gt;
A year ago this meant cloud speech APIs, API keys, and paying per minute. Today, open models like &lt;strong&gt;Whisper&lt;/strong&gt; (speech-to-text) and &lt;strong&gt;Kokoro&lt;/strong&gt; (text-to-speech) run comfortably on a laptop. No account, no key, no audio leaving your machine.&lt;/p&gt;


&lt;h2&gt;
  
  
  🔌 The idea: one voice layer, any agent
&lt;/h2&gt;

&lt;p&gt;The trick is that the voice doesn't live &lt;em&gt;inside&lt;/em&gt; the agent. It lives in an &lt;strong&gt;MCP server&lt;/strong&gt;, the standard plug-in protocol that Claude Code and a growing list of other coding agents already speak.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frpe8vw3zj25pirm37zdk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frpe8vw3zj25pirm37zdk.png" alt="One voice layer, any coding agent: you speak, Whisper turns it into text, VoiceMode hands it to your agent, Kokoro speaks the reply. All local, all free." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The MCP server I use is &lt;strong&gt;VoiceMode&lt;/strong&gt; (open source, MIT licensed). It exposes one main tool, &lt;code&gt;converse&lt;/code&gt;: speak something, then listen and return what the user said. Any agent that can call MCP tools can now hold a conversation.&lt;/p&gt;

&lt;p&gt;That's the whole point: &lt;strong&gt;build the voice once, plug it into every agent you use.&lt;/strong&gt; When the next coding assistant comes along, it gets a voice for free too.&lt;/p&gt;


&lt;h2&gt;
  
  
  ⚡ Setup: about ten minutes
&lt;/h2&gt;

&lt;p&gt;I'm on an Apple Silicon Mac with 16 GB of RAM. Nothing exotic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Install VoiceMode and the local speech services&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uvx voice-mode-install
voicemode service install whisper   &lt;span class="c"&gt;# speech-to-text&lt;/span&gt;
voicemode service install kokoro    &lt;span class="c"&gt;# text-to-speech&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Pin it to local only&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;By default VoiceMode can fall back to a cloud API if it finds an API key. I don't want that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;voicemode config &lt;span class="nb"&gt;set&lt;/span&gt; VOICEMODE_STT_BASE_URLS http://127.0.0.1:2022/v1
voicemode config &lt;span class="nb"&gt;set&lt;/span&gt; VOICEMODE_TTS_BASE_URLS http://127.0.0.1:8880/v1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now nothing you say leaves the machine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Plug it into Claude Code&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;One line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp add --scope user voicemode -- ~/.local/bin/voicemode
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4. Add a &lt;code&gt;/talk&lt;/code&gt; command&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A tiny custom slash command: a markdown file in &lt;code&gt;~/.claude/commands/&lt;/code&gt; whose prompt says &lt;em&gt;start a voice loop with the &lt;code&gt;converse&lt;/code&gt; tool, keep replies short, stop when I say “stop”.&lt;/em&gt; Type &lt;code&gt;/talk&lt;/code&gt;, and you’re talking.&lt;/p&gt;

&lt;p&gt;That's it.&lt;/p&gt;

&lt;h3&gt;
  
  
  🧩 Using a different agent?
&lt;/h3&gt;

&lt;p&gt;The same recipe works in any coding agent that supports MCP. The pieces are always the same:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Run the two local speech services&lt;/strong&gt; (Whisper and Kokoro). They don't care which agent calls them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Register VoiceMode as an MCP server&lt;/strong&gt; in your agent's MCP config, pointing at the &lt;code&gt;voicemode&lt;/code&gt; binary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Allow its &lt;code&gt;converse&lt;/code&gt; tool&lt;/strong&gt;, if your agent asks you to approve tools.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add a reusable prompt&lt;/strong&gt; (a slash command, custom prompt or rules file, whatever your agent calls it) with the voice-loop rules below.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Check your agent's docs for where its MCP config lives. Some read one global file; others want the server listed per agent or per project.&lt;/p&gt;




&lt;h2&gt;
  
  
  💬 Making it feel like a conversation, not a robot
&lt;/h2&gt;

&lt;p&gt;Getting audio in and out is the easy part. Making it &lt;em&gt;pleasant&lt;/em&gt; took some prompt tuning. The rules that made the biggest difference:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One or two sentences, ~25 words.&lt;/strong&gt; The agent offers to say more instead of explaining everything. This alone fixes the wall-of-text problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never read code, paths or tables aloud.&lt;/strong&gt; Speak the summary; put the detail on screen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A live transcript.&lt;/strong&gt; Before each turn the agent prints &lt;code&gt;🎤 You: …&lt;/code&gt; and &lt;code&gt;🔊 Friday: …&lt;/code&gt;, so the screen matches what you hear. Handy when the speech recognizer mishears you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A persona.&lt;/strong&gt; In voice, my agent answers as "Friday": calm, concise, a little dry. Put the persona in the shared prompt and every agent you wire up sounds like the same assistant. Small thing, surprisingly nice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sound human.&lt;/strong&gt; Vary the acknowledgements. Nothing kills the vibe like hearing "Great question!" eleven times.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  🛑 The safety rule you must not skip
&lt;/h2&gt;

&lt;p&gt;Speech recognition mishears things, especially names, numbers and jargon. "Delete the test branch" and "delete the best branch" are one syllable apart.&lt;/p&gt;

&lt;p&gt;So my rule is simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read-only questions&lt;/strong&gt; ("what failed?", "summarise the last change"): the agent just answers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anything that changes or sends something&lt;/strong&gt; (file writes, deletes, git commits and pushes): the agent repeats back what it understood in one sentence and &lt;strong&gt;waits for a spoken "yes"&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;👉 &lt;strong&gt;Never let a voice command run an action unconfirmed.&lt;/strong&gt; This one rule is what makes voice safe enough to actually rely on.&lt;/p&gt;




&lt;h2&gt;
  
  
  🪙 "But doesn't voice burn more tokens?"
&lt;/h2&gt;

&lt;p&gt;I wondered the same, so I measured it from my own session logs.&lt;/p&gt;

&lt;p&gt;Across 16 voice exchanges:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;~350 output tokens per turn&lt;/strong&gt; (spoken reply + on-screen transcript + reasoning)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~800 uncached input tokens in total.&lt;/strong&gt; What you say comes back as a tiny bit of text.&lt;/li&gt;
&lt;li&gt;The big number, ~110k cached tokens per call, is just the conversation context being re-read, which is &lt;strong&gt;exactly the same when you type&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;✅ &lt;strong&gt;Verdict:&lt;/strong&gt; a voice turn costs about the same as a typed one. Speech-to-text and text-to-speech run locally, so they're free. The only extra is the transcript line, maybe 50–100 tokens a turn. If anything, voice &lt;em&gt;saves&lt;/em&gt; tokens, because short spoken answers replace long written ones.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Caveat: that's one session, not a controlled typed-vs-spoken comparison. Take it as a strong hint, not a benchmark.)&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  ⏱️ Is it fast enough?
&lt;/h2&gt;

&lt;p&gt;Mostly, yes. The voice gap after the agent finishes thinking is about &lt;strong&gt;2 seconds&lt;/strong&gt;: ~0.3 s for Whisper to transcribe you, ~1.6 s for Kokoro to start speaking. The real variable is how long the &lt;em&gt;model&lt;/em&gt; thinks, which you'd wait for anyway.&lt;/p&gt;

&lt;p&gt;One surprise: &lt;strong&gt;Kokoro was faster on CPU than on the Mac's GPU.&lt;/strong&gt; Time to first audio was 0.4 s on CPU versus 2.2 s on GPU, and the GPU never caught up after warm-up. If your local text-to-speech feels slow, try forcing CPU.&lt;/p&gt;

&lt;p&gt;And a lesson: when benchmarking TTS, use &lt;strong&gt;new text every time&lt;/strong&gt;. Repeated text gets cached, and my first measurement looked 5× better than reality.&lt;/p&gt;




&lt;h2&gt;
  
  
  🪤 Gotchas I hit, so you don't
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bluetooth headset as the mic → beeps and robot voice.&lt;/strong&gt; macOS flips the headset into "call mode" when it starts listening. Use the laptop mic for input and keep the headset for output.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;voice-mode-install --model small&lt;/code&gt; broke the Whisper install&lt;/strong&gt; on my setup. Install Whisper on its own with &lt;code&gt;voicemode service install whisper&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Python version mismatch.&lt;/strong&gt; Kokoro wants Python 3.10; if your machine defaults to newer, rebuild its venv on 3.10.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Corrupt model download.&lt;/strong&gt; If Kokoro won't start, check the model file size. Mine was 50 MB short.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not every agent reads a global MCP config.&lt;/strong&gt; If the voice tools don't show up, add the server to the per-agent or per-project config.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Privacy note:&lt;/strong&gt; VoiceMode's logs store what you said in plain text. Put them on your cleanup list.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  🚫 What I didn't use, and why
&lt;/h2&gt;

&lt;p&gt;I looked around before settling on this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Claude Code's built-in &lt;code&gt;/voice&lt;/code&gt;&lt;/strong&gt;: great for dictation, but input-only and cloud-transcribed. No spoken replies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Other voice MCP servers&lt;/strong&gt;: either barely maintained, macOS-only and stale, or multiple GBs of models per project.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloud voice tools&lt;/strong&gt;: some have nicer live transcripts, but they send your audio to the cloud and some bill separately. Not what I wanted.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;VoiceMode was the only option I found that was fully local, actively maintained, and worked across every project and every agent.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧠 "Is AI making us dumber?"
&lt;/h2&gt;

&lt;p&gt;You've probably seen the term &lt;strong&gt;cognitive debt&lt;/strong&gt; going around, along with &lt;strong&gt;cognitive surrender&lt;/strong&gt; and &lt;strong&gt;AI brain fry&lt;/strong&gt;. The worry is real, and the research is starting to back it up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In a &lt;a href="https://www.infoq.com/news/2026/02/ai-coding-skill-formation/" rel="noopener noreferrer"&gt;randomized trial from Anthropic&lt;/a&gt;, junior developers who used an AI assistant scored &lt;strong&gt;17% lower&lt;/strong&gt; on comprehension tests than those who coded by hand. The interesting part is &lt;em&gt;how&lt;/em&gt; they used it. People who handed the whole task to the AI scored below 40%. People who asked it to &lt;em&gt;explain&lt;/em&gt; things scored above 65%, as well as or better than the no-AI group.&lt;/li&gt;
&lt;li&gt;Researchers &lt;a href="https://arxiv.org/pdf/2609.20143" rel="noopener noreferrer"&gt;Shaw and Nave&lt;/a&gt; draw a useful line between &lt;strong&gt;cognitive offloading&lt;/strong&gt; (you delegate a task but keep thinking about it) and &lt;strong&gt;cognitive surrender&lt;/strong&gt; (the AI's answer becomes your answer without much scrutiny).&lt;/li&gt;
&lt;li&gt;Boston Consulting Group researchers coined &lt;a href="https://builtin.com/articles/ai-brain-fry-software-developers" rel="noopener noreferrer"&gt;"AI brain fry"&lt;/a&gt; for the mental hangover of keeping up with tools that produce more than you can absorb.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's my take: the wall of text is a big part of the problem. When an agent dumps 900 words on you, the easiest move is to skim, nod and accept. That's surrender by exhaustion.&lt;/p&gt;

&lt;p&gt;Voice won't fix this on its own. You can say "yes" to everything just as lazily as you can click "accept". But it changes the default in a few useful ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You have to say what you want out loud.&lt;/strong&gt; Putting your intent into a sentence is thinking. It's hard to explain a task without understanding it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Short answers invite follow-ups.&lt;/strong&gt; When the agent says "I changed the retry logic in two files", the natural reply is "why two?" A spoken back-and-forth pulls you into the reasoning instead of past it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Less to read means less overload.&lt;/strong&gt; You get the gist by ear and open the detail on screen only when it matters. That's the brain fry problem in reverse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The confirmation step makes you decide.&lt;/strong&gt; The agent says back what it's about to do, and you have to actively agree.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A few habits I've picked up to stay on the &lt;em&gt;offloading&lt;/em&gt; side of the line:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Say your guess before you ask. ("I think it's the timeout. Check?")&lt;/li&gt;
&lt;li&gt;Ask "why" at least once per task.&lt;/li&gt;
&lt;li&gt;Ask the agent to explain, not just to do, especially in code you don't know yet.&lt;/li&gt;
&lt;li&gt;Every so often, solve something yourself first and use the agent to check it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Voice makes these habits feel natural, because they're just how you'd talk to a person.&lt;/p&gt;




&lt;h2&gt;
  
  
  🚀 Where this is going
&lt;/h2&gt;

&lt;p&gt;I don't think we'll read our agents' output forever. As agents take on longer, more autonomous tasks, the human job shifts from &lt;em&gt;reading everything&lt;/em&gt; to &lt;em&gt;staying oriented&lt;/em&gt;: knowing what happened, what's blocked, and what needs a decision, while still doing the thinking yourself.&lt;/p&gt;

&lt;p&gt;Voice is good at exactly that. It's short and in the moment, and you don't have to sit and stare at a screen to follow it. Detail stays on screen for when you want it; the gist comes to you.&lt;/p&gt;

&lt;p&gt;The setup is free, local, and about ten minutes of work. If you spend your evenings building side projects with a coding agent, try talking to it for a week. I don't think you'll go back to scrolling.&lt;/p&gt;




&lt;p&gt;🔭 &lt;em&gt;Next up for me: trying NVIDIA's Parakeet model for even faster speech recognition, and moving the speech services to a home server so the laptop stays cool.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>mcp</category>
      <category>claude</category>
    </item>
    <item>
      <title>Webhook signature verification fails even though the secret is right</title>
      <dc:creator>AI Client Discovery</dc:creator>
      <pubDate>Thu, 12 Nov 2026 01:41:56 +0000</pubDate>
      <link>https://dev.to/aiclientdiscovery/webhook-signature-verification-fails-even-though-the-secret-is-right-1nng</link>
      <guid>https://dev.to/aiclientdiscovery/webhook-signature-verification-fails-even-though-the-secret-is-right-1nng</guid>
      <description>&lt;p&gt;Reproduced in my own test setup — not a client's, and no real account involved:&lt;br&gt;
a Stripe-style webhook keeps failing signature verification even though the&lt;br&gt;
signing secret is definitely right. Posting the reproduction because the cause&lt;br&gt;
turned out to be a one-line pipeline mistake, and the error message gives you&lt;br&gt;
no hint which side is wrong.&lt;/p&gt;

&lt;p&gt;The setup: a test endpoint receives the webhook, parses the JSON body, then&lt;br&gt;
verifies the HMAC signature. Secret copied straight from the dashboard, event&lt;br&gt;
after event, verification fails every time.&lt;/p&gt;

&lt;p&gt;What actually breaks it: the signature is computed over the exact bytes that&lt;br&gt;
arrived, not over "the same JSON". My handler parsed the body and then&lt;br&gt;
re-serialized it before verifying — pretty-printing plus sorted keys. Same&lt;br&gt;
payload, same secret, different bytes, different signature. I confirmed it&lt;br&gt;
with a local check: signature over the raw body verifies; signature over the&lt;br&gt;
re-serialized body does not. Nothing was wrong with the secret at all.&lt;/p&gt;

&lt;p&gt;The fix, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Capture the raw request body as bytes BEFORE any JSON parsing, and verify
the signature against those bytes only. In most frameworks that means
reading the raw body / disabling the JSON body parser on the webhook route. Only after verification passes, parse the body and process the event. If your platform parses the body for you before your code runs (some
no-code webhook steps do), check whether it offers a "raw body" output —
if it doesn't, verify at a step that still sees the raw payload, or move
verification to a small endpoint that does.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;How to tell this apart from a genuinely wrong secret: compute the HMAC&lt;br&gt;
yourself over the raw bytes you actually received (log them once, exactly as&lt;br&gt;
received) and compare with the signature header. If your hand-computed value&lt;br&gt;
matches the header, the secret is right and something downstream is&lt;br&gt;
re-writing the body — that is this bug. If it doesn't match even over the raw&lt;br&gt;
bytes, then the secret (or the timestamp tolerance, if the sender enforces&lt;br&gt;
one) is the problem, and no amount of re-formatting will help.&lt;/p&gt;

&lt;p&gt;One gotcha while testing: don't "verify" by re-sending from a tool that&lt;br&gt;
re-serializes the payload for you — you'll reproduce the failure and conclude&lt;br&gt;
the secret is wrong. Test against the exact bytes the sender sent.&lt;/p&gt;

</description>
      <category>webhooks</category>
      <category>api</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Same quantized model, two different fates: one chip refused it, the other ran it in 1.39 ms</title>
      <dc:creator>Anguo</dc:creator>
      <pubDate>Thu, 12 Nov 2026 01:41:40 +0000</pubDate>
      <link>https://dev.to/anguoyang/same-quantized-model-two-different-fates-one-chip-refused-it-the-other-ran-it-in-139-ms-2d5d</link>
      <guid>https://dev.to/anguoyang/same-quantized-model-two-different-fates-one-chip-refused-it-the-other-ran-it-in-139-ms-2d5d</guid>
      <description>&lt;p&gt;I took one quantized NanoDet model and offered it to two Qualcomm devices. One refused to load it at all. The other ran all 324 layers on the NPU in 1.39 ms. The model was identical. The quantization was identical. The difference was a float tail the quantizer couldn't touch — and whether the device was willing to look past it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model
&lt;/h2&gt;

&lt;p&gt;NanoDet, quantized to w8a8 with 23 real images from COCO val2017 as calibration data. (24 were planned; one download failed, and I'd rather say so than pretend the set was clean.) Same graph, two devices: a Dragonwing RB3 Gen 2 (QCS6490) and a Galaxy S25.&lt;/p&gt;

&lt;h2&gt;
  
  
  What each device did with it
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Device&lt;/th&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;RB3 Gen 2&lt;/td&gt;
&lt;td&gt;TFLite, float&lt;/td&gt;
&lt;td&gt;90.0 ms — 352 layers: CPU 169, GPU 123, NPU 60. CPU time alone was 85% of the run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RB3 Gen 2&lt;/td&gt;
&lt;td&gt;TFLite, w8a8&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;6.47 ms&lt;/strong&gt; (median 7.61 ms) — &lt;strong&gt;439 layers, all on the NPU&lt;/strong&gt;. Roughly 14× faster than float on the same path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RB3 Gen 2&lt;/td&gt;
&lt;td&gt;Precompiled QNN, w8a8&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Rejected&lt;/strong&gt; — the graph carries float tail tensors the quantizer can't convert (distribution integral and box-coordinate decode arithmetic after the GFL head). The device requires quantized I/O, tail included&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Galaxy S25&lt;/td&gt;
&lt;td&gt;Precompiled QNN, w8a8&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;1.39 ms&lt;/strong&gt; (median 1.40 ms) — 324 layers, all on the NPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Galaxy S25&lt;/td&gt;
&lt;td&gt;Precompiled QNN, float&lt;/td&gt;
&lt;td&gt;1.47 ms — also all NPU&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;(The layer counts differ between precisions because a quantized graph carries extra quantize ops. Compare latencies, not layer counts, across precisions.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the same graph has two fates
&lt;/h2&gt;

&lt;p&gt;YOLOv11-N quantized cleanly on the RB3 in the earlier experiment — 341 layers, all NPU, 5.03 ms — because its export is raw convolution output. NanoDet's export carries its decode arithmetic inside the graph: the GFL head's distribution integral and the box-coordinate decode run in float, after the convolutions. The quantizer can't convert that tail, and the RB3's QNN path won't accept a graph whose I/O isn't fully quantized. Same quantized graph, same device family — one model's tail is clean, the other's isn't.&lt;/p&gt;

&lt;p&gt;The S25 takes the same graph and runs it in 1.39 ms, because its QNN path accepts it. And note the S25's float path is 1.47 ms — on the flagship, quantization buys almost nothing, because the float path was already all-NPU. On the RB3, quantization is the difference between 90 ms and 6.47 ms, because the float path was mostly CPU.&lt;/p&gt;

&lt;p&gt;Two ways to fix the RB3 rejection are on the table: compile the quantized model through TFLite instead (convolutions on the NPU, tail on the CPU), or split the export — backbone and head as pure convolutions through QNN, decode on the host. Both concede the same point: the decode tail belongs on the host, not in the graph.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule to take home
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Quantization is a property of the graph and the device's I/O contract, not of the model alone.&lt;/strong&gt; A model can be "quantized" and still be un-runnable on a given device, if its tail carries float arithmetic the device won't accept. When you report a quantized benchmark, name the path and the device — "w8a8" alone says nothing about whether it ran, let alone how fast.&lt;/p&gt;

&lt;p&gt;Same story as the 27× gap in the earlier experiment, from the other side: there, the runtime path decided the speed. Here, the runtime path decides whether the model runs at all. Both times the evidence that mattered was the per-layer profile, not the headline number.&lt;/p&gt;

&lt;p&gt;The model itself is now published as an AI Hub external recipe — &lt;a href="https://huggingface.co/anguoyang/nanodet" rel="noopener noreferrer"&gt;https://huggingface.co/anguoyang/nanodet&lt;/a&gt; — with w8a8 in its supported precisions and the RB3 QNN rejection recorded in the recipe's own test report, so the next person hits the signpost instead of the wall.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>benchmarking</category>
      <category>qualcomm</category>
    </item>
    <item>
      <title>A presence display for a parent living alone, built on Wi-Fi signal disturbance</title>
      <dc:creator>朱连兴</dc:creator>
      <pubDate>Thu, 12 Nov 2026 01:41:21 +0000</pubDate>
      <link>https://dev.to/annest/a-presence-display-for-a-parent-living-alone-built-on-wi-fi-signal-disturbance-1ac3</link>
      <guid>https://dev.to/annest/a-presence-display-for-a-parent-living-alone-built-on-wi-fi-signal-disturbance-1ac3</guid>
      <description>&lt;p&gt;My father-in-law lives alone. What my wife actually wants to know, ten times a day, is one boring thing: &lt;em&gt;is he up and moving around the flat?&lt;/em&gt; Not his pulse. Not a video feed. Just: did the shape of the room change in the last few minutes.&lt;/p&gt;

&lt;p&gt;Every product we looked at solved it with a camera or a wearable. Both fail for the same reason: he will not wear a watch to bed, and he unplugged the camera on day three because he does not want to be watched in his own home.&lt;/p&gt;

&lt;p&gt;So we built the thing around the constraint instead of around the sensor. &lt;strong&gt;No lens, no microphone, no device on the body.&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;Wi-Fi is not just a carrier, it is a probe. A body in a room changes the multipath between two fixed nodes: some subcarriers get absorbed, some arrive out of phase. Sample the channel state at a fixed rate and the room's "signature" drifts as someone moves through it.&lt;/p&gt;

&lt;p&gt;The display is deliberately dumb about this. It does not classify activities, and it does not try to name what you are doing. It answers one question with a bucket:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;moving&lt;/strong&gt; — disturbance above the idle noise floor within the last few minutes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;still, present&lt;/strong&gt; — the room signature is stable but not equal to the empty-room baseline&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;no one&lt;/strong&gt; — signature matches the empty baseline&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;no signal&lt;/strong&gt; — the sensor itself stopped reporting&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That fourth state is the one everyone forgets, and it is the most important one. A dashboard that shows "all quiet" when the sensor is offline is worse than no dashboard at all — it manufactures false reassurance. We render it in a different colour, with an explicit "last seen" timestamp.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the sensor is not in the cloud
&lt;/h2&gt;

&lt;p&gt;The pipeline is: sensor node → local broker → our API → server-sent events → dashboard. The node never uploads raw CSI; it uploads a small feature vector at ~1 Hz. Over a day that is on the order of tens of kilobytes, which matters because the flat is on a 4G router with a data cap.&lt;/p&gt;

&lt;p&gt;Keeping the inference on the node also means the answer does not depend on our uptime. If our API is down, the node keeps its own state and replays on reconnect.&lt;/p&gt;

&lt;h2&gt;
  
  
  The parts we cut
&lt;/h2&gt;

&lt;p&gt;We removed more than we added:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No video, so no recording to leak.&lt;/strong&gt; There is no frame to subpoena, no clip to accidentally share.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No wearable, so nothing to charge.&lt;/strong&gt; The failure mode of every elderly-monitoring wearable is a dead battery nobody noticed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No gateway requirement.&lt;/strong&gt; It joins the existing Wi-Fi; we do not ask anyone to replace their router.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No account for the parent.&lt;/strong&gt; They do not have to install, pair, or learn anything. That was non-negotiable for us.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;p&gt;The physics is the spec. Same room, line of sight, roughly 10 metres. Through one ordinary interior wall, about 5 metres. Two brick walls and you get "no signal", which the display says out loud rather than guessing.&lt;/p&gt;

&lt;p&gt;It also does not tell you &lt;em&gt;what&lt;/em&gt; happened, only that the room's pattern changed or did not. There is no fall classification here, and we do not pretend otherwise — a display that cries wolf gets unplugged, and then it protects no one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live dashboard (a real device in a real apartment, updating about once per second):&lt;/strong&gt; &lt;a href="https://annest.cn/live/" rel="noopener noreferrer"&gt;https://annest.cn/live/&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;AnNest is a consumer safety display, not a medical device. It does not detect falls, does not measure vitals, and does not call for help. If something is an emergency, call your local emergency number.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>iot</category>
      <category>privacy</category>
      <category>hardware</category>
      <category>design</category>
    </item>
  </channel>
</rss>
