<?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: Kiragu Maina</title>
    <description>The latest articles on DEV Community by Kiragu Maina (@kiragu_maina_2f2e757b46a8).</description>
    <link>https://dev.to/kiragu_maina_2f2e757b46a8</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2208810%2Facaa55de-50c7-4632-8472-d6977267f9a3.png</url>
      <title>DEV Community: Kiragu Maina</title>
      <link>https://dev.to/kiragu_maina_2f2e757b46a8</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kiragu_maina_2f2e757b46a8"/>
    <language>en</language>
    <item>
      <title>Your programmatic SEO pipeline needs a judge, not a template</title>
      <dc:creator>Kiragu Maina</dc:creator>
      <pubDate>Sat, 29 Aug 2026 13:39:06 +0000</pubDate>
      <link>https://dev.to/kiragu_maina_2f2e757b46a8/your-programmatic-seo-pipeline-needs-a-judge-not-a-template-1pb0</link>
      <guid>https://dev.to/kiragu_maina_2f2e757b46a8/your-programmatic-seo-pipeline-needs-a-judge-not-a-template-1pb0</guid>
      <description>&lt;p&gt;Programmatic SEO has one failure mode that matters: two thousand pages with the same skeleton and a keyword swapped in. Google calls them doorway pages, and it is right to.&lt;/p&gt;

&lt;p&gt;When I took over MyPhotoAI as its only engineer, the manifest had 2,158 keywords worth 198,460 searches a month. I built the loop that turns a keyword into a page. Then I built the thing that refuses pages, and that turned out to be most of the work. This post is about the refusing part: a two-part quality gate that runs before any money is spent, and the retry loop that feeds its verdict back into the next attempt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Order matters more than the model
&lt;/h2&gt;

&lt;p&gt;A page on MyPhotoAI carries a grid of generated photos. Each render costs real money. The copy costs a fraction of a cent. So the worker never renders an image for a page whose copy has not already passed:&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;processPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;KeywordEntry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Manifest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 1. Copy, inside a validation loop (up to 3 attempts)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;generateHelpfulContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;updateStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                       &lt;span class="c1"&gt;// no images were rendered for this page&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// 2. Only now do we spend on images&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;images&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;generateImages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// 3. Assemble, validate, write&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;images&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;validation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;validatePage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;validation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;writePage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;html&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;A page that fails three times costs three text generations and zero renders.&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%2Fh8l7u60728f43ikliodz.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%2Fh8l7u60728f43ikliodz.png" alt="The spend gate" width="800" height="410"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 1: heuristics, no API call
&lt;/h2&gt;

&lt;p&gt;The first pass is deterministic and free. It reads every string in the generated content (hero, benefits, FAQs, CTA and the layout-specific sections) and deducts from 100:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Filler phrases.&lt;/strong&gt; A list of 42 strings AI copy reaches for: "in today's digital age", "look no further", "a game changer", "unlock the potential", "elevate your". Each hit deducts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Robotic starters.&lt;/strong&gt; "This ensures", "Additionally,", "Furthermore,", "Ultimately,". Three or more on one page: minus 15.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keyword density.&lt;/strong&gt; Over 2.0 percent, or more than 8 occurrences, is stuffing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sentence length variety.&lt;/strong&gt; Low standard deviation reads like a metronome: minus 10.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Benefit specificity.&lt;/strong&gt; "Helps you get results" with no number, timeframe or outcome: minus 8 each.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FAQ quality.&lt;/strong&gt; An answer that restates the question, or runs under 15 words: minus 5 each.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;score&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;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;deductions&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;pass&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="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;issues&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;   &lt;span class="c1"&gt;// judge never called&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That short-circuit keeps the judge's cost proportional to pages that have a chance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 2: an LLM as a Search Quality Rater
&lt;/h2&gt;

&lt;p&gt;Survivors go to a second model with a rubric that mirrors Google's Helpful Content language. The prompt opens with:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You are a Google Search Quality Rater evaluating a landing page for the keyword "{keyword}". Be harsh; Google is actively demoting AI slop.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;and scores five things: keyword naturalness (20), E-E-A-T signals (25), conversational tone (20), genuine helpfulness (20), originality (15). It returns JSON: a score, a verdict of &lt;code&gt;pass&lt;/code&gt;, &lt;code&gt;rewrite&lt;/code&gt; or &lt;code&gt;fail&lt;/code&gt;, and lists of issues and suggestions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Combining them
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;combined&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;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;heuristics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.4&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;llmResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pass&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;combined&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;75&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;llmResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pass&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both have to agree. And if the judge's JSON does not parse, the result is &lt;code&gt;rewrite&lt;/code&gt;, never &lt;code&gt;pass&lt;/code&gt;. A parsing error must not let a page through.&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%2Fhqqjb4zkfz2yuwopgx37.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%2Fhqqjb4zkfz2yuwopgx37.png" alt="Two judges, one verdict" width="800" height="410"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The retry is not a retry
&lt;/h2&gt;

&lt;p&gt;When the gate fails, the next attempt is not the same prompt again. The judge's issues and suggestions are appended to the brief:&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="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;contentBrief&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="s2"&gt;`
Previous attempt scored &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;helpfulness&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;. Fix these:
&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;helpfulness&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`- &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;
Suggestions:
&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;helpfulness&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;suggestions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`- &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three attempts, then the manifest marks the keyword &lt;code&gt;failed&lt;/code&gt; with the top issue recorded, and moves on. Some keywords never publish. That is the gate working.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell you to copy
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Put the gate before the expensive step, not after publishing.&lt;/li&gt;
&lt;li&gt;Make the cheap check free and deterministic, and let it short-circuit.&lt;/li&gt;
&lt;li&gt;Give the judge a rubric with points, not "is this good?".&lt;/li&gt;
&lt;li&gt;Require both to agree, and treat any parsing failure as a rejection.&lt;/li&gt;
&lt;li&gt;Feed the verdict back into the next attempt, and cap the attempts.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The rest of the engine (deterministic per-slug layout variation so no two pages share a skeleton, static HTML on Cloudflare Pages with a build guard for its SPA-mode trap, and an IndexNow queue that will not submit a URL until it has seen the page live) is in the full write-up: &lt;a href="https://medium.com/@kennkyragu/how-i-built-a-1-898-page-programmatic-seo-engine-with-ai-quality-gates-static-html-and-indexnow-a2f5d80ec812" rel="noopener noreferrer"&gt;How I built a 1,898-page programmatic SEO engine with AI quality gates, static HTML and IndexNow&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I build programmatic SEO pipelines and audit existing ones. &lt;a href="mailto:kiragu@alkenacode.dev"&gt;kiragu@alkenacode.dev&lt;/a&gt; or &lt;a href="https://contra.com/kiragu_maina_txoyol9a" rel="noopener noreferrer"&gt;Contra&lt;/a&gt;. Case studies and more of this work at &lt;a href="https://kiragu.alkenacode.dev" rel="noopener noreferrer"&gt;kiragu.alkenacode.dev&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>ai</category>
      <category>webdev</category>
      <category>typescript</category>
    </item>
    <item>
      <title>The Idempotency-Key tutorial has a hole, and M-Pesa found it for me</title>
      <dc:creator>Kiragu Maina</dc:creator>
      <pubDate>Sat, 29 Aug 2026 08:44:09 +0000</pubDate>
      <link>https://dev.to/kiragu_maina_2f2e757b46a8/the-idempotency-key-tutorial-has-a-hole-and-m-pesa-found-it-for-me-5cpj</link>
      <guid>https://dev.to/kiragu_maina_2f2e757b46a8/the-idempotency-key-tutorial-has-a-hole-and-m-pesa-found-it-for-me-5cpj</guid>
      <description>&lt;p&gt;Every idempotency tutorial I have read ends the same way.&lt;/p&gt;

&lt;p&gt;Take the &lt;code&gt;Idempotency-Key&lt;/code&gt; header. Look it up in Redis. If it is there, replay the cached response. If not, run the handler and cache the result. Done.&lt;/p&gt;

&lt;p&gt;I shipped exactly that in the first version of FyberPay, a billing platform for internet service providers in Kenya. It worked for a year. Then it nearly handed one ISP's payment response to another ISP.&lt;/p&gt;

&lt;p&gt;This post is about that hole, the fix, and the second hole the fix does not cover.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup: eight payment rails, many tenants
&lt;/h2&gt;

&lt;p&gt;FyberPay sits between M-Pesa (plus Paystack, KopoKopo, Tuma, Bill Manager and bank paybills) and the routers of dozens of small ISPs. Every ISP is a tenant. Every tenant's subscribers pay through the same webhook endpoints.&lt;/p&gt;

&lt;p&gt;Daraja, Safaricom's M-Pesa API, retries callbacks aggressively. If your endpoint does not answer 200 within a few seconds, the same payment arrives again. Twice is normal. Three times happens weekly.&lt;/p&gt;

&lt;p&gt;So idempotency is not optional. The question is what the cache key is made of.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the textbook key actually is
&lt;/h2&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%2Fvkhiudbrcvorbo9dpv26.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%2Fvkhiudbrcvorbo9dpv26.png" alt="Textbook idempotency key vs a server-namespaced key" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the textbook version the caller chooses the entire key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Idempotency-Key: 7f3c1a2e-...-91ab
redis.get('idem:7f3c1a2e-...-91ab')
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now imagine two tenants. Tenant A's integration and Tenant B's integration were both written from the same sample code. Both generate keys from the same seed, or both reuse a key after a crash, or one key simply leaks through a log line.&lt;/p&gt;

&lt;p&gt;Tenant B sends key &lt;code&gt;7f3c...&lt;/code&gt;. Redis already has it. Your interceptor does what it was told: it replays Tenant A's cached response body to Tenant B.&lt;/p&gt;

&lt;p&gt;The system is idempotent. It is also a data leak.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: namespace before you trust
&lt;/h2&gt;

&lt;p&gt;The interceptor now builds the key from the server's view of the request first. The client's header goes last, as a suffix, never as the address:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// idempotency.interceptor.ts (NestJS)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;orgId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;org&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;root&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;principal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s2"&gt;`anon:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;noip&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;originalUrl&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bodyHash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&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="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;redisKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;KEY_PREFIX&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;principal&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;orgId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;bodyHash&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Atomic claim. One winner runs the handler; everyone else replays.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;claimed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;redisKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;processing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;EX&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;IDEMPOTENCY_TTL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NX&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the key left to right:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;idem : u_8812 : org_kisumu-fiber : POST : /payments/stk : 9e1c...f2a0 : 7f3c-...-91ab
       who      which tenant       verb   route           body hash       client's key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things fall out of this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Leakage becomes structurally impossible.&lt;/strong&gt; Tenant B's principal and org are baked into the address before the lookup. There is no key Tenant B can send that resolves to Tenant A's slot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Body changes never replay.&lt;/strong&gt; A client that changes one byte gets a fresh execution. That is stricter than the spec, and in a payments path it is what you want. "Same key, different amount" should never silently return the old receipt.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;NX&lt;/code&gt; matters as much as the namespace. &lt;code&gt;SET ... NX&lt;/code&gt; is the claim. Two concurrent retries race for one slot, exactly one wins, and the loser waits for the stored response instead of running the handler a second time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second hole: the request that never sees your interceptor
&lt;/h2&gt;

&lt;p&gt;Here is a real Tuesday from the logs, one M-Pesa receipt, four arrivals:&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%2Fpli6y0cwwlbvy9th8mse.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%2Fpli6y0cwwlbvy9th8mse.png" alt="Timeline of one duplicated M-Pesa webhook: four arrivals, one ledger row" width="800" height="350"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;10:41:02.114  webhook #&lt;/span&gt;1  RJK4T7  redis MISS  -&amp;gt;  claim  -&amp;gt;  INSERT ok  -&amp;gt;  200
&lt;span class="gp"&gt;10:41:02.980  webhook #&lt;/span&gt;2  RJK4T7  redis HIT   -&amp;gt;  replay 200
&lt;span class="gp"&gt;10:41:31.400  webhook #&lt;/span&gt;3  RJK4T7  redis HIT   -&amp;gt;  replay 200
&lt;span class="gp"&gt;11:15:07.000  hourly Daraja pull   RJK4T7      -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;INSERT fails UNIQUE  -&amp;gt;  rollback
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Arrivals two and three are the interceptor doing its job. Arrival four is the one the tutorial never mentions.&lt;/p&gt;

&lt;p&gt;FyberPay also &lt;em&gt;pulls&lt;/em&gt; transactions from Daraja on a schedule, as a safety net for webhooks that never arrive at all. That pull runs in a worker. It is not an HTTP request. It never touches the interceptor. Redis cannot help it.&lt;/p&gt;

&lt;p&gt;What stops the double credit is one line in a hand-written migration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;payments&lt;/span&gt;
  &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;payments_receipt_unique&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gateway&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;receipt_number&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The M-Pesa receipt number is unique on Safaricom's side, so it is unique on ours. The insert fails, the transaction rolls back, and the ledger never moves twice. The interceptor is the fast path. The constraint is the truth.&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%2F0ee3f882cgpau95zix77.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%2F0ee3f882cgpau95zix77.png" alt="The three layers stacked: Redis interceptor, Postgres unique receipt, transactional outbox" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What to take from this
&lt;/h2&gt;

&lt;p&gt;If you are building anything where the same payload can arrive from more than one direction (webhooks plus polling, webhooks plus manual replay, two regions), two rules:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The client's idempotency key is a suffix, not an address.&lt;/strong&gt; Prefix it with everything the server knows: principal, tenant, method, route, body hash. Claim with &lt;code&gt;SET NX&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put a &lt;code&gt;UNIQUE&lt;/code&gt; constraint on the external reference.&lt;/strong&gt; It costs one migration and it is the only layer that works for every code path, including the ones you have not written yet.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There is a third layer (a transactional outbox with &lt;code&gt;SKIP LOCKED&lt;/code&gt;, for the side effects that run after the ledger commits) and a circuit-breaker story about why a wrong PIN must not count as an outage. Both are in the full teardown here: &lt;a href="https://medium.com/@kennkyragu/building-3-layer-idempotency-and-webhook-resilience-across-8-payment-gateways-m-pesa-paystack-and-f8eb85bea1e4" rel="noopener noreferrer"&gt;Building 3-layer idempotency and webhook resilience across 8 payment gateways&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I build fixed-scope payment integrations and audit existing ones. &lt;a href="mailto:kiragu@alkenacode.dev"&gt;kiragu@alkenacode.dev&lt;/a&gt; or &lt;a href="https://contra.com/kiragu_maina_txoyol9a" rel="noopener noreferrer"&gt;Contra&lt;/a&gt;. Case studies and more of this work at &lt;a href="https://kiragu.alkenacode.dev" rel="noopener noreferrer"&gt;kiragu.alkenacode.dev&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>architecture</category>
      <category>fintech</category>
      <category>typescript</category>
    </item>
  </channel>
</rss>
