<?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: Mohamed Zaghloul</title>
    <description>The latest articles on DEV Community by Mohamed Zaghloul (@mowa).</description>
    <link>https://dev.to/mowa</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%2F3245691%2Fc4014f38-8f9c-4c4a-8b86-89ccb22c56e5.jpg</url>
      <title>DEV Community: Mohamed Zaghloul</title>
      <link>https://dev.to/mowa</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mowa"/>
    <language>en</language>
    <item>
      <title>Turn any website into clean Markdown for RAG (incremental sync, llms.txt)</title>
      <dc:creator>Mohamed Zaghloul</dc:creator>
      <pubDate>Sun, 18 Oct 2026 16:18:25 +0000</pubDate>
      <link>https://dev.to/mowa/turn-any-website-into-clean-markdown-for-rag-incremental-sync-llmstxt-2m7n</link>
      <guid>https://dev.to/mowa/turn-any-website-into-clean-markdown-for-rag-incremental-sync-llmstxt-2m7n</guid>
      <description>&lt;p&gt;If you've ever fed a website into a RAG pipeline, you know the boring part isn't the embeddings. It's getting clean text. Nav menus, cookie banners, "related posts" widgets and footers all end up in your chunks, and then every time the site changes you either re-scrape everything or guess what changed.&lt;/p&gt;

&lt;p&gt;I built a small Apify Actor for exactly this, called &lt;a href="https://apify.com/chimey_forearm/sitemap-to-markdown" rel="noopener noreferrer"&gt;Sitemap to Markdown&lt;/a&gt;. This post walks through a real run on the htmx essays: what goes in, what comes out, how re-runs only pick up changes, and what it costs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea: use the sitemap, not a crawler
&lt;/h2&gt;

&lt;p&gt;Most sites already publish a list of every page and when it last changed: &lt;code&gt;sitemap.xml&lt;/code&gt;. So instead of crawling links, the Actor:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Finds the sitemap on its own (robots.txt, &lt;code&gt;/sitemap.xml&lt;/code&gt;, sitemap indexes, &lt;code&gt;.xml.gz&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Keeps only the URLs under the path you gave it&lt;/li&gt;
&lt;li&gt;Downloads each page with plain HTTP (no headless browser)&lt;/li&gt;
&lt;li&gt;Strips the boilerplate and converts the main content to Markdown&lt;/li&gt;
&lt;li&gt;Optionally splits it into RAG-sized chunks and writes &lt;code&gt;llms.txt&lt;/code&gt; / &lt;code&gt;llms-full.txt&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If a site has no sitemap, it falls back to a shallow same-site link crawl.&lt;/p&gt;

&lt;h2&gt;
  
  
  Input
&lt;/h2&gt;

&lt;p&gt;This is the exact input I ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"startUrls"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"https://htmx.org/essays"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxPages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"incremental"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"incrementalStateKey"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"devto-demo-htmx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"chunkSize"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"createLlmsTxt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;maxPages: 0&lt;/code&gt; means no limit: every essay URL in the sitemap.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;incremental: true&lt;/code&gt; makes the Actor remember what it has seen, under the name in &lt;code&gt;incrementalStateKey&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;chunkSize: 400&lt;/code&gt; splits each page into chunks of about 400 tokens, by heading and paragraph.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Output
&lt;/h2&gt;

&lt;p&gt;You get one dataset record per page. Each page's &lt;code&gt;markdown&lt;/code&gt; starts with YAML front matter, so it can go straight into a file-based pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;/&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;htmx&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;~&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;A&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Real&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;World&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;wasm&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;htmx&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Port"&lt;/span&gt;
&lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://htmx.org/essays/a-real-world-wasm-to-htmx-port/"&lt;/span&gt;
&lt;span class="na"&gt;canonical_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://htmx.org/essays/a-real-world-wasm-to-htmx-port/"&lt;/span&gt;
&lt;span class="na"&gt;lastmod&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2025-01-10"&lt;/span&gt;
&lt;span class="na"&gt;language&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;en"&lt;/span&gt;
&lt;span class="na"&gt;word_count&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;617&lt;/span&gt;
&lt;span class="na"&gt;thin_content&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="gh"&gt;# A Real World wasm to htmx Port&lt;/span&gt;

Joe Fioti

January 10, 2025

When I was in college, I wrote some customer service software that tied together ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rest of the record (trimmed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"page"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://htmx.org/essays/a-real-world-wasm-to-htmx-port/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"canonicalUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://htmx.org/essays/a-real-world-wasm-to-htmx-port/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"lastmod"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2025-01-10"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"changeStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"new"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"contentHash"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"958fdb17b6246b5bea12b422b88cc12d12204a467949cfee7522513928b8917b"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"wordCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;617&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tokenEstimate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;897&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"chunkCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"chunks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"index"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"heading"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"A Real World wasm to htmx Port"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"# A Real World wasm to htmx Port&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;Joe Fioti ..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tokenEstimate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;324&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few fields that matter for RAG:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;chunks&lt;/code&gt;&lt;/strong&gt;: each chunk keeps its heading, so you can store it as metadata and cite sources.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;contentHash&lt;/code&gt;&lt;/strong&gt;: a SHA-256 of the Markdown, handy for "only re-embed what changed".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;canonicalUrl&lt;/code&gt;&lt;/strong&gt;: lets you skip duplicate paths. The Actor already skips exact duplicates by default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;thin_content&lt;/code&gt;&lt;/strong&gt;: pages with almost no main content (under 50 words by default) are flagged as &lt;code&gt;thin_page&lt;/code&gt; instead of being written like a normal page. That includes JS-only pages, since there's no browser rendering. They're also left out of &lt;code&gt;llms.txt&lt;/code&gt;, so they don't end up embedded as noise.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The run also saves &lt;code&gt;llms.txt&lt;/code&gt; (a title + link + description index) and &lt;code&gt;llms-full.txt&lt;/code&gt; (every page's Markdown in one file) to the key-value store. The second one is handy for uploading a whole site to ChatGPT, Claude Projects or NotebookLM. Here's the start of the generated &lt;code&gt;llms.txt&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# htmx.org&lt;/span&gt;
&lt;span class="gt"&gt;
&amp;gt; Pages from https://htmx.org/essays, converted to Markdown by the Sitemap to Markdown Actor on 2026-10-18.&lt;/span&gt;

&lt;span class="gu"&gt;## Pages&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;&amp;lt;/&amp;gt; htmx ~ Architectural Sympathy&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://htmx.org/essays/architectural-sympathy/&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;: In this essay, Carson Gross explores architectural sympathy as a software design principle ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Incremental sync: the part I actually care about
&lt;/h2&gt;

&lt;p&gt;Re-scraping a whole docs site every night to catch two edited pages is wasteful. With &lt;code&gt;incremental: true&lt;/code&gt;, the Actor keeps a small state record in a named key-value store in your own Apify account. On each run it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;skips pages whose sitemap &lt;code&gt;lastmod&lt;/code&gt; hasn't changed (they aren't even downloaded)&lt;/li&gt;
&lt;li&gt;downloads the rest, compares content hashes, and only outputs pages that are &lt;strong&gt;new&lt;/strong&gt; or &lt;strong&gt;changed&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;writes a &lt;code&gt;CHANGES&lt;/code&gt; record listing new, changed, unchanged and removed URLs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's what happened on the last two runs of this input. Earlier runs, with a page cap, had already synced 41 of the 56 essay pages:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;Pages output&lt;/th&gt;
&lt;th&gt;Runtime&lt;/th&gt;
&lt;th&gt;CHANGES&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Catch-up run&lt;/td&gt;
&lt;td&gt;the 15 remaining pages, all &lt;code&gt;new&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;7.1 s&lt;/td&gt;
&lt;td&gt;&lt;code&gt;new: 15, unchanged: 41&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Re-run right after&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0 pages&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.6 s&lt;/td&gt;
&lt;td&gt;&lt;code&gt;new: 0, changed: 0, unchanged: 56, removed: 0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;CHANGES&lt;/code&gt; record is what you wire into your vector store: upsert &lt;code&gt;new&lt;/code&gt; and &lt;code&gt;changed&lt;/code&gt;, delete &lt;code&gt;removed&lt;/code&gt;. Then a nightly schedule only does real work on days the site actually changed.&lt;/p&gt;

&lt;p&gt;Two honest caveats:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;removed&lt;/code&gt; is only reliable when the run reads the whole sitemap: &lt;code&gt;maxPages: 0&lt;/code&gt; and no &lt;code&gt;lastmodAfter&lt;/code&gt; date filter.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;maxPages&lt;/code&gt; limits new and changed pages per run, so with a cap, a big site fills in over several runs.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;It's pay-per-result on Apify:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;$0.50 per 1,000 pages&lt;/strong&gt; saved (that's $0.0005 a page), plus a $0.00005 start fee per run&lt;/li&gt;
&lt;li&gt;Chunks live inside the page record, so chunking costs nothing extra; &lt;code&gt;llms.txt&lt;/code&gt; is free too&lt;/li&gt;
&lt;li&gt;A re-run where nothing changed outputs nothing, so you pay only the start fee&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full htmx essays section (56 pages) costs under 3 cents the first time and about nothing on a quiet night. You can set a max cost per run in Apify, and the Actor stops cleanly at that limit.&lt;/p&gt;

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

&lt;p&gt;It's plain HTTP, so it won't render JavaScript-only SPAs, log in, or get past aggressive bot protection. Pages from those sites come back flagged as thin, or as a &lt;code&gt;BLOCKED&lt;/code&gt; error instead of silently empty. For those, a browser-based crawler like Apify's Website Content Crawler is the better (more expensive) tool. This one is aimed at docs, blogs, help centers and marketing sites, which is most of what people actually want in a RAG index.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Store page: &lt;a href="https://apify.com/chimey_forearm/sitemap-to-markdown" rel="noopener noreferrer"&gt;Sitemap to Markdown&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;It runs from the Apify console, the API, or as a tool for AI agents through the Apify MCP server.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's new, so I'd genuinely like feedback. If the Markdown isn't clean enough for your pipeline, or a field is missing, tell me in the comments or open an issue on the Actor page.&lt;/p&gt;

</description>
      <category>rag</category>
      <category>llm</category>
      <category>webscraping</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Validate an IBAN and VAT number before paying an invoice</title>
      <dc:creator>Mohamed Zaghloul</dc:creator>
      <pubDate>Sun, 18 Oct 2026 16:17:04 +0000</pubDate>
      <link>https://dev.to/mowa/validate-an-iban-and-vat-number-before-paying-an-invoice-1cpn</link>
      <guid>https://dev.to/mowa/validate-an-iban-and-vat-number-before-paying-an-invoice-1cpn</guid>
      <description>&lt;p&gt;A mistyped IBAN or a made-up VAT number on a supplier invoice is cheap to catch before payment and expensive to fix after. Here's a small pre-payment check you can add to any accounts-payable flow with two HTTP calls.&lt;/p&gt;

&lt;p&gt;I'll use the Invoice &amp;amp; Payment ID Validator API on RapidAPI (I built it; there's a free plan with 100 requests a month): &lt;a href="https://rapidapi.com/MohamedBahaa/api/invoice-payment-id-validator" rel="noopener noreferrer"&gt;https://rapidapi.com/MohamedBahaa/api/invoice-payment-id-validator&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Check the IBAN
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; GET &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s1"&gt;'https://invoice-payment-id-validator.p.rapidapi.com/v1/iban?value=DE89370400440532013000'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'x-rapidapi-host: invoice-payment-id-validator.p.rapidapi.com'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'x-rapidapi-key: YOUR_RAPIDAPI_KEY'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Real response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DE89370400440532013000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iban"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DE89370400440532013000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"country"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"check_digits"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"89"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"bban"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"370400440532013000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"valid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"country_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Germany"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"bank_code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"37040044"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"branch_code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"account_number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0532013000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"formatted"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DE89 3704 0044 0532 0130 00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sepa"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"errors"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What it checks: the country's IBAN length, the ISO 13616 mod-97 checksum, and SEPA membership. It also splits out the bank code, branch code, and account number for 20 common countries. Change one digit and you get &lt;code&gt;"valid": false&lt;/code&gt; with &lt;code&gt;"errors": ["bad_checksum"]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What it doesn't check: whether the account is open or who owns it. A valid checksum means the IBAN is well-formed, not that it belongs to your supplier. For that you still need the supplier's confirmation, or your bank's payee-verification service where one exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Check the EU VAT number, live against VIES
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; GET &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s1"&gt;'https://invoice-payment-id-validator.p.rapidapi.com/v1/vat/eu?value=IE6388047V&amp;amp;live=true'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'x-rapidapi-host: invoice-payment-id-validator.p.rapidapi.com'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'x-rapidapi-key: YOUR_RAPIDAPI_KEY'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Real response (request_date trimmed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"IE6388047V"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"valid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"country"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"IE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"6388047V"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"vat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"IE6388047V"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"format_valid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"checksum"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"not_checked"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"errors"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"vies"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"checked"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"registered"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"GOOGLE IRELAND LIMITED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"address"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"3RD FLOOR, GORDON HOUSE, BARROW STREET, DUBLIN 4"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;live=true&lt;/code&gt;, the API asks the EU's VIES service whether the number is registered. When the member state shares it, you also get the registered name and address, which you can compare with the invoice. Without &lt;code&gt;live=true&lt;/code&gt;, it only checks the format (fast, offline). VIES is sometimes down for individual countries. When it is, &lt;code&gt;vies.error&lt;/code&gt; tells you, so treat that as "retry later", not "invalid".&lt;/p&gt;

&lt;p&gt;UK VAT (&lt;code&gt;/v1/vat/gb&lt;/code&gt;), US EIN (&lt;code&gt;/v1/ein&lt;/code&gt;) and BIC/SWIFT (&lt;code&gt;/v1/bic&lt;/code&gt;) work the same way with &lt;code&gt;?value=&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Check a whole invoice in one call
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s1"&gt;'https://invoice-payment-id-validator.p.rapidapi.com/v1/batch'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'content-type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'x-rapidapi-host: invoice-payment-id-validator.p.rapidapi.com'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'x-rapidapi-key: YOUR_RAPIDAPI_KEY'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{"items":[{"type":"iban","value":"DE89370400440532013000"},{"type":"eu_vat","value":"IE6388047V","live":true}]}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Up to 50 IDs per call (up to 10 of them live VIES checks).&lt;/p&gt;

&lt;h2&gt;
  
  
  4. A simple pay / hold rule
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Hold if &lt;code&gt;iban.valid&lt;/code&gt; is false&lt;/li&gt;
&lt;li&gt;Hold if &lt;code&gt;vies.registered&lt;/code&gt; is false&lt;/li&gt;
&lt;li&gt;Flag for review if the VIES name doesn't roughly match the supplier name on the invoice&lt;/li&gt;
&lt;li&gt;Retry later if &lt;code&gt;vies.error&lt;/code&gt; is set&lt;/li&gt;
&lt;li&gt;Otherwise, pass it to approval&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. Do it in n8n
&lt;/h2&gt;

&lt;p&gt;If your invoices arrive by email, my free n8n template turns vendor invoice PDFs in Gmail into clean rows in Google Sheets: &lt;a href="https://mohabaha0.gumroad.com/l/ap-invoice-triage-free" rel="noopener noreferrer"&gt;https://mohabaha0.gumroad.com/l/ap-invoice-triage-free&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To add the checks above, put an HTTP Request node after the extraction step: use POST &lt;code&gt;/v1/batch&lt;/code&gt; with the JSON body from step 3 and your two &lt;code&gt;x-rapidapi-*&lt;/code&gt; headers. Then add an IF node with the pay / hold rule.&lt;/p&gt;

&lt;p&gt;If you use AI agents, the API is also an MCP server at &lt;code&gt;/mcp&lt;/code&gt;, so Claude or Cursor can call the validators as tools.&lt;/p&gt;

&lt;p&gt;Questions or edge cases (odd country formats, VIES quirks)? Drop them in the comments.&lt;/p&gt;

</description>
      <category>api</category>
      <category>fintech</category>
      <category>automation</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Extracting invoices from Gmail with n8n and Gemini, then checking the IBAN before anyone pays it</title>
      <dc:creator>Mohamed Zaghloul</dc:creator>
      <pubDate>Sun, 18 Oct 2026 08:41:33 +0000</pubDate>
      <link>https://dev.to/mowa/extracting-invoices-from-gmail-with-n8n-and-gemini-then-checking-the-iban-before-anyone-pays-it-27fe</link>
      <guid>https://dev.to/mowa/extracting-invoices-from-gmail-with-n8n-and-gemini-then-checking-the-iban-before-anyone-pays-it-27fe</guid>
      <description>&lt;p&gt;Most invoice automations stop at extraction: an LLM pulls the vendor, total and bank details out of a PDF, and the numbers go straight into a spreadsheet or ledger. The risky part is that an LLM can read a perfectly formatted IBAN with one wrong digit, and nothing downstream notices.&lt;/p&gt;

&lt;p&gt;This post walks through a free n8n template that handles the extraction, and a small API I built that handles the checksum part. They're separate pieces: the template doesn't call the API out of the box. I'll show how to add it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 1: the free n8n template
&lt;/h2&gt;

&lt;p&gt;Nodes, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Gmail Trigger: polls for unread mail labelled &lt;code&gt;invoices&lt;/code&gt; with a PDF attached.&lt;/li&gt;
&lt;li&gt;Split PDF Attachments (Code node): one item per PDF, so an email with three invoices produces three rows.&lt;/li&gt;
&lt;li&gt;Extract From File: reads the PDF's text layer. Scanned image-only PDFs need OCR first.&lt;/li&gt;
&lt;li&gt;Gemini + Structured Output Parser: returns vendor, invoice number, PO number, dates, currency, subtotal, tax, total, line items and a confidence score as strict JSON. Temperature 0, retries on rate limits.&lt;/li&gt;
&lt;li&gt;Normalize Invoice (Code node): cleans numbers and dates, checks that subtotal + tax = total, and marks low-confidence rows &lt;code&gt;needs_review&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Google Sheets: appends the row. Then Gmail marks the email read so it's never processed twice.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Setup takes about 10 minutes: a Gmail label and filter, a sheet with an &lt;code&gt;Invoices&lt;/code&gt; tab, and three credentials (Gmail, Sheets, a free Gemini key from Google AI Studio).&lt;/p&gt;

&lt;p&gt;Two things that made it more reliable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Strict schema over free text. The Structured Output Parser rejects anything that isn't the expected JSON, so a malformed response fails loudly instead of writing garbage.&lt;/li&gt;
&lt;li&gt;Check the math in code, not in the prompt. Asking Gemini "does this add up?" is slower and less reliable than one line of arithmetic in the Normalize node.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Part 2: validating payment IDs with an API
&lt;/h2&gt;

&lt;p&gt;Extraction confidence doesn't tell you whether an IBAN is real. The ISO 13616 mod-97 checksum does. I put that, plus a few related checks, in a Cloudflare Worker:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;IBAN: country, length, mod-97 checksum, SEPA membership&lt;/li&gt;
&lt;li&gt;BIC: format and country&lt;/li&gt;
&lt;li&gt;EU VAT: format, plus an optional live lookup against the EU's VIES service&lt;/li&gt;
&lt;li&gt;UK VAT: format and checksum&lt;/li&gt;
&lt;li&gt;US EIN: format and IRS prefix (EINs have no check digit, so that's as far as honest validation goes)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Up to 50 identifiers per batch request. Here's a real response from the keyless demo endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://invoice-validator.mohamed-bahaa53000.workers.dev/demo/v1/iban?value=DE89370400440532013000"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iban"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DE89370400440532013000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"country"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"check_digits"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"89"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"bban"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"370400440532013000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"valid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"formatted"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DE89 3704 0044 0532 0130 00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sepa"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"errors"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The demo allows 10 calls per IP per day without a key.&lt;/p&gt;

&lt;p&gt;Why a Worker rather than an n8n Code node? Mostly reuse: the same check is useful from Make, Zapier or a backend, and it's stateless, so it runs at the edge in milliseconds. It also isolates the slow part. VIES throttles by member state, so when it's unavailable the API returns &lt;code&gt;checked: false&lt;/code&gt; with a reason instead of a false "invalid".&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 3: wiring them together yourself
&lt;/h2&gt;

&lt;p&gt;After Normalize Invoice, add an HTTP Request node:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Method: POST, to the &lt;code&gt;/v1/batch&lt;/code&gt; endpoint from the RapidAPI listing&lt;/li&gt;
&lt;li&gt;Headers: &lt;code&gt;X-RapidAPI-Key&lt;/code&gt; and &lt;code&gt;X-RapidAPI-Host&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Body:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"INV-1042-iban"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"iban"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DE89370400440532013000"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"INV-1042-vat"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"eu_vat"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DE123456789"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"live"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In n8n, map each value with an expression from the previous node, for example $json.invoice_number, $json.vendor_iban and $json.vendor_vat.&lt;/p&gt;

&lt;p&gt;You'll need to add &lt;code&gt;vendor_iban&lt;/code&gt; and &lt;code&gt;vendor_vat&lt;/code&gt; to the Gemini schema first. Then treat &lt;code&gt;valid: false&lt;/code&gt; as "needs review", and &lt;code&gt;checked: false&lt;/code&gt; from VIES as "verify manually", not as a rejection. A person sees exactly why a row was flagged.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Fail open to a human, never closed to the ledger. Anything that can time out should route to review.&lt;/li&gt;
&lt;li&gt;Batch external checks. One call per identifier multiplies latency and quota.&lt;/li&gt;
&lt;li&gt;Keep each piece honest about what it does. The template extracts; the API validates; you decide how strict to be.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Free n8n template: &lt;a href="https://mohabaha0.gumroad.com/l/ap-invoice-triage-free" rel="noopener noreferrer"&gt;https://mohabaha0.gumroad.com/l/ap-invoice-triage-free&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Invoice &amp;amp; Payment ID Validator on RapidAPI: &lt;a href="https://rapidapi.com/MohamedBahaa/api/invoice-payment-id-validator" rel="noopener noreferrer"&gt;https://rapidapi.com/MohamedBahaa/api/invoice-payment-id-validator&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Feedback on either is welcome, especially edge cases where the extraction or the validation gets it wrong.&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>automation</category>
      <category>ai</category>
      <category>api</category>
    </item>
  </channel>
</rss>
