<?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: 301ST</title>
    <description>The latest articles on DEV Community by 301ST (@301st).</description>
    <link>https://dev.to/301st</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%2F4043581%2Ffe5435fc-2111-44a0-8981-964a6a57b876.png</url>
      <title>DEV Community: 301ST</title>
      <link>https://dev.to/301st</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/301st"/>
    <language>en</language>
    <item>
      <title>Counting visits on a Cloudflare site with no third party script and no cookies</title>
      <dc:creator>301ST</dc:creator>
      <pubDate>Mon, 12 Oct 2026 14:00:41 +0000</pubDate>
      <link>https://dev.to/301st/counting-visits-on-a-cloudflare-site-with-no-third-party-script-and-no-cookies-4o09</link>
      <guid>https://dev.to/301st/counting-visits-on-a-cloudflare-site-with-no-third-party-script-and-no-cookies-4o09</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;My site is static, on Cloudflare. I want to know how many people read which page and how many scanned the QR code on the flyer, without a cookie banner and without sending my visitors to someone else's server.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Cloudflare already ships a free answer to the first half of that, and for many sites it is enough. This guide starts there, shows where it stops counting, and then builds the alternative: a cookieless counter, a Worker on a path of your own site that counts page views, short link clicks and QR scans into a D1 database in your own account. Every Cloudflare limit below was checked against Cloudflare's documentation on 11 October 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Cloudflare gives you for free
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://developers.cloudflare.com/web-analytics/about/" rel="noopener noreferrer"&gt;Web Analytics&lt;/a&gt; is free, and Cloudflare describes it as "privacy-first analytics" that "does not collect or use your visitors' personal data". On a proxied hostname you turn it on in the dashboard and Cloudflare injects its script into your HTML for you. Its &lt;a href="https://developers.cloudflare.com/web-analytics/faq/" rel="noopener noreferrer"&gt;FAQ&lt;/a&gt; sets the terms: data for the previous six months, and "We retain unsampled beacon data for the past 7 days, after this point data is aggregated down to around 10%."&lt;/p&gt;

&lt;p&gt;If your pages load it, you can live with a script from another host, and you only care about pages, stop here. It costs nothing and you configure nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Web Analytics stops counting
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A strict Content Security Policy refuses it.&lt;/strong&gt; The script lives at &lt;code&gt;static.cloudflareinsights.com/beacon.min.js&lt;/code&gt;. Automatic setup sends its reports to your own domain's &lt;code&gt;/cdn-cgi/rum&lt;/code&gt;, so &lt;code&gt;connect-src 'self'&lt;/code&gt; is fine, but the script itself is a third party host, and the FAQ asks you to add it to &lt;code&gt;script-src&lt;/code&gt;. This blog is the measured case. Its policy is &lt;code&gt;script-src 'self' 'unsafe-inline'&lt;/code&gt;. On 11 October 2026 a page of 301.sh carried the injected tag, with an &lt;code&gt;integrity&lt;/code&gt; attribute and the site's token, and Chrome refused it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;blockedURI:        https://static.cloudflareinsights.com/beacon.min.js
violatedDirective: script-src-elem
disposition:       enforce
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The resource entry has a transfer size of 0 and the beacon object never appears. Cloudflare injects the tag without looking at the policy, so the dashboard stays empty and nothing tells you why except a line in the browser console.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Blocker lists refuse it by name.&lt;/strong&gt; Cloudflare's FAQ has a section headed "The analytics beacon is blocked by ad-blockers" and answers it with "Cloudflare is aware". The &lt;a href="https://github.com/easylist/easylist/tree/master/easyprivacy" rel="noopener noreferrer"&gt;EasyPrivacy&lt;/a&gt; list, checked on 11 October 2026, blocks the host, &lt;code&gt;||cloudflareinsights.com^$third-party&lt;/code&gt;, and also carries generic path rules that match any site: &lt;code&gt;/beacon.min.js&lt;/code&gt;, &lt;code&gt;/cdn-cgi/rum?&lt;/code&gt; and &lt;code&gt;/cdn-cgi/rum|&lt;/code&gt;. Reporting to your own domain does not help when the path is the same on every Cloudflare site.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It counts pages, not links.&lt;/strong&gt; A QR code on a flyer, a link in a newsletter or in a bio leads to a page only if you make it one. The click itself, and which code was scanned, is not a page view.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea: a collector on a path of your own site
&lt;/h2&gt;

&lt;p&gt;A Worker can sit on a route that covers one path of your hostname, say &lt;code&gt;example.com/assets-k3/*&lt;/code&gt;. Everything outside that path goes to your origin as before, and so does anything under it that the Worker does not handle, because the Worker passes it on with &lt;code&gt;fetch(request)&lt;/code&gt;. The page sends one small request to that path on load. To the browser, the CSP and the blocker lists it is a same origin &lt;code&gt;POST&lt;/code&gt; to a path that looks like any other.&lt;/p&gt;

&lt;p&gt;This works on a static site too. Cloudflare's &lt;a href="https://developers.cloudflare.com/pages/how-to/add-custom-http-headers/" rel="noopener noreferrer"&gt;Pages documentation&lt;/a&gt; describes deploying a Worker "on the same custom domain as your Pages project", and the &lt;a href="https://301.sh/tds-on-cloudflare-workers/" rel="noopener noreferrer"&gt;TDS guide&lt;/a&gt; on this blog relies on the same arrangement. One order matters, from Pages' &lt;a href="https://developers.cloudflare.com/pages/platform/known-issues/" rel="noopener noreferrer"&gt;known issues&lt;/a&gt;: "It is currently not possible to add a custom domain with … a Worker already routed on that domain." Attach the Pages custom domain first, then add the route.&lt;/p&gt;

&lt;p&gt;The smallest version is one table, one Worker and one line in the page:&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;views&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;day&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&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="nf"&gt;fetch&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;env&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&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;url&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;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/assets-k3/v&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;day&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&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="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;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&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;200&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;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&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="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INSERT INTO views (day, page, n) VALUES (?, ?, 1) &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
          &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ON CONFLICT (day, page) DO UPDATE SET n = n + 1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
      &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;run&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="na"&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="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cache-control&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;no-store&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fetch&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="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendBeacon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/assets-k3/v&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The route is &lt;code&gt;example.com/assets-k3/*&lt;/code&gt; with a D1 binding named &lt;code&gt;DB&lt;/code&gt;. The page sends its own path in the body. The &lt;code&gt;Referer&lt;/code&gt; of the beacon would carry it too, but only while the site's &lt;code&gt;Referrer-Policy&lt;/code&gt; allows it: &lt;code&gt;no-referrer&lt;/code&gt; drops the header and &lt;code&gt;origin&lt;/code&gt; cuts it to the host, and every view would land on &lt;code&gt;/&lt;/code&gt;. A body that is not a path is counted as &lt;code&gt;unknown&lt;/code&gt;. When you want sources, send &lt;code&gt;document.referrer&lt;/code&gt; alongside it. The &lt;code&gt;slice&lt;/code&gt; only caps what reaches D1: &lt;code&gt;request.text()&lt;/code&gt; has already read the whole body by then, so a counter facing the open internet reads the stream itself and cancels it past a limit, as clx does at 2 KB.&lt;/p&gt;

&lt;p&gt;Pick the path the way you would name a folder of images. A name like &lt;code&gt;stats&lt;/code&gt;, &lt;code&gt;track&lt;/code&gt; or &lt;code&gt;beacon&lt;/code&gt; is what the generic rules above are written for.&lt;/p&gt;

&lt;h3&gt;
  
  
  Counting people without a cookie
&lt;/h3&gt;

&lt;p&gt;A view counter is a sum. A visitor counter needs to know whether it has seen someone before, and the usual way to know is a cookie. The way without one is a hash of things the request already carries, salted with a random value that lives for one day: &lt;code&gt;sha256(day salt | site | IP | user agent)&lt;/code&gt;. Store the hash for the open day, count the distinct hashes, and when the day closes keep the count and delete both the hashes and the salt. The salt has to come from &lt;code&gt;crypto.getRandomValues&lt;/code&gt; and never reach a log. After that nothing in the database can be matched to a person, including by you.&lt;/p&gt;

&lt;p&gt;What this counts is distinct combinations of address and browser per day, which is close to people but not the same. A phone that leaves the home network for mobile data counts twice; two colleagues behind one office address with the same browser count once. Over a week the number is the sum of daily uniques, so one person reading on Monday and on Thursday counts twice. The report should say so in its label.&lt;/p&gt;

&lt;h2&gt;
  
  
  Short links and QR codes on the same Worker
&lt;/h2&gt;

&lt;p&gt;A short link is a redirect, and a redirect runs no JavaScript, so no page script will ever see the click. &lt;a href="https://301.sh/count-clicks-on-a-redirect/" rel="noopener noreferrer"&gt;Counting a click on a redirect&lt;/a&gt; explains why only the thing that answers the request can count it. If a Worker already counts views into D1, it can answer the links too and write the clicks into the same tables.&lt;/p&gt;

&lt;p&gt;The links want a hostname of their own, such as &lt;code&gt;k7q.example.com&lt;/code&gt;, with no origin behind it. &lt;a href="https://developers.cloudflare.com/workers/configuration/routing/custom-domains/" rel="noopener noreferrer"&gt;Workers Custom Domains&lt;/a&gt; fit: "Cloudflare will create DNS records and issue necessary certificates on your behalf." The documentation only names an existing CNAME as a blocker. Measured on a live account on 11 October 2026, any existing record stops it: the API answers &lt;code&gt;409&lt;/code&gt; with code &lt;code&gt;100117&lt;/code&gt;, "already has externally managed DNS records", so a record someone made by hand is never taken over.&lt;/p&gt;

&lt;p&gt;Three details make the counts trustworthy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;answer with &lt;code&gt;302&lt;/code&gt; and &lt;code&gt;cache-control: no-store&lt;/code&gt;, so every click reaches the Worker instead of a cached redirect in the browser;&lt;/li&gt;
&lt;li&gt;put a marker in the address printed in the QR code, &lt;code&gt;k7q.example.com/menu?q&lt;/code&gt;, record it as the source &lt;code&gt;qr&lt;/code&gt; and drop it from the redirect;&lt;/li&gt;
&lt;li&gt;count a &lt;code&gt;GET&lt;/code&gt;, redirect a &lt;code&gt;HEAD&lt;/code&gt; without counting it, because link checkers and preview fetchers send those.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rules by country and device fit in the same lookup. &lt;code&gt;request.cf.country&lt;/code&gt; gives the country, the user agent gives mobile or desktop, the first rule that matches wins and the link's main target catches the rest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One more thing the small answers buy.&lt;/strong&gt; Since June 2025 Cloudflare has &lt;a href="https://blog.cloudflare.com/russian-internet-users-are-unable-to-access-the-open-internet/" rel="noopener noreferrer"&gt;reported&lt;/a&gt; that Russian ISPs let visitors load "only the first 16 KB of any web asset" from sites behind it. A counter like this sends an inline script under 600 bytes and gets an empty &lt;code&gt;204&lt;/code&gt;; a link answers with headers only, under 1 KB with Cloudflare's own. Both fit far below the cut. This was measured from outside Russia, by size, not from inside it. It does not rescue a page that is itself larger than the cut: a page that never loads sends no view to count. A short link fares better, because its redirect arrives whole and the visitor goes on to wherever the target is hosted.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs on the Free plan
&lt;/h2&gt;

&lt;p&gt;Workers Free gives an account &lt;a href="https://developers.cloudflare.com/workers/platform/limits/" rel="noopener noreferrer"&gt;100,000 requests a day&lt;/a&gt; and 10 ms of CPU per request. &lt;a href="https://developers.cloudflare.com/d1/platform/pricing/" rel="noopener noreferrer"&gt;D1 on Free&lt;/a&gt; gives 5 million rows read and 100,000 rows written a day, 500 MB per database and 5 GB per account. The quotas reset at 00:00 UTC and are shared by every Worker in the account.&lt;/p&gt;

&lt;p&gt;Writes run out first. Each counted event touches at least one row, more once you keep visitor hashes and per hour detail, and D1 counts every index entry as a row written. clx, the counter described below, budgets nine writes for the worst event and puts the guaranteed ceiling at about 11,000 counted events a day on Free, with a typical site several times that.&lt;/p&gt;

&lt;p&gt;When a quota runs out the failures differ. Over the request limit Cloudflare &lt;a href="https://developers.cloudflare.com/workers/platform/limits/" rel="noopener noreferrer"&gt;returns Error 1027&lt;/a&gt; or passes the request to the origin, depending on how the route is set to fail. Either way nothing is counted, and the short links stop answering, because their host has no origin to fall back to. Your pages are not on the route and keep loading. Over the write limit D1 "will return errors", so counting stops. Whether links keep redirecting depends on the code: the sample above awaits its write and would fail with it. clx writes a click with &lt;code&gt;ctx.waitUntil()&lt;/code&gt; after the redirect is sent and drops a failed write, so its links keep working. Workers Paid lifts both for &lt;a href="https://developers.cloudflare.com/workers/platform/pricing/" rel="noopener noreferrer"&gt;a minimum of $5 a month&lt;/a&gt; per account, with 10 million requests and 50 million D1 writes included.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it breaks
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It is not fraud resistant.&lt;/strong&gt; The path is in the page source, and anyone can &lt;code&gt;POST&lt;/code&gt; to it with any &lt;code&gt;Referer&lt;/code&gt; and user agent. Checks against the &lt;code&gt;Origin&lt;/code&gt; and the host keep out browser noise, not a determined script.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The route must be free.&lt;/strong&gt; If another Worker already holds a route on that pattern, Cloudflare refuses a second one. Pick another path rather than overwrite it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your inline script needs your CSP's permission.&lt;/strong&gt; With &lt;code&gt;script-src 'self'&lt;/code&gt; and no &lt;code&gt;'unsafe-inline'&lt;/code&gt;, serve the same code as a file from the Worker's path, &lt;code&gt;&amp;lt;script defer src="/assets-k3/a.js"&amp;gt;&lt;/code&gt;, which &lt;code&gt;'self'&lt;/code&gt; allows. The beacon itself falls under &lt;code&gt;connect-src&lt;/code&gt;, so a policy that starts from &lt;code&gt;default-src 'none'&lt;/code&gt; needs &lt;code&gt;connect-src 'self'&lt;/code&gt; as well.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Browser Integrity Check is yours.&lt;/strong&gt; Cloudflare's zone setting can refuse some scripted clients on the link host before the Worker runs. Browsers pass. Your own monitoring script may not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pages' own files after the Worker are unchecked here.&lt;/strong&gt; Whether a Pages project's &lt;code&gt;_redirects&lt;/code&gt; and &lt;code&gt;_headers&lt;/code&gt; still apply to requests the Worker passes on was not tested for this guide.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Doing it without writing it
&lt;/h2&gt;

&lt;p&gt;The code above is a working start. Turning it into a counter with sources, countries, devices, bots, visitor hashes, day rollups, link rules, QR codes and a report is a project of its own, and that project is &lt;a href="https://clx.cx" rel="noopener noreferrer"&gt;clx&lt;/a&gt;. It installs a Worker named &lt;code&gt;clx-edge&lt;/code&gt; and a D1 database into your Cloudflare account, using a short lived bootstrap token you create, and keeps only totals on its own side, hourly for two days and daily for 400; the breakdowns stay in your account. Each site gets its own path and its own generated snippet, so two sites on clx share nothing in their page source. Links get a host per site, with a QR code for each link and up to 10 rules. The free plan covers one Cloudflare account, 10 sites and 200 links. The source is open under AGPL-3.0.&lt;/p&gt;

&lt;p&gt;Site generators can use it without a browser. The management API connects a customer's Cloudflare account once, adds each site and returns the snippet to embed at build time; the snippet stays the same across rebuilds. &lt;a href="https://generator.ink" rel="noopener noreferrer"&gt;Site Generator&lt;/a&gt;, which publishes affiliate sites to Pages, turns it on for a customer who asks its support.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you need 301.st instead
&lt;/h2&gt;

&lt;p&gt;For one site and a handful of links, the Worker above or clx is the whole answer, and both are free. &lt;a href="https://301.st" rel="noopener noreferrer"&gt;301.st&lt;/a&gt; is for a different job: many domains with redirects by country and device, traffic split between offers or landing pages, bots filtered before they reach them, and &lt;a href="https://301.sh/postbacks-for-a-tds/" rel="noopener noreferrer"&gt;postbacks&lt;/a&gt; that tell you which click converted. It runs in your Cloudflare account the same way, and a clx short link can point at a 301.st flow when you need both.&lt;/p&gt;

</description>
      <category>tracking</category>
      <category>cloudflare</category>
      <category>pages</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Postbacks for a TDS: how a sale on the offer's site finds its click</title>
      <dc:creator>301ST</dc:creator>
      <pubDate>Wed, 07 Oct 2026 14:01:09 +0000</pubDate>
      <link>https://dev.to/301st/postbacks-for-a-tds-how-a-sale-on-the-offers-site-finds-its-click-29h0</link>
      <guid>https://dev.to/301st/postbacks-for-a-tds-how-a-sale-on-the-offers-site-finds-its-click-29h0</guid>
      <description>&lt;p&gt;&lt;a href="https://301.sh/tds-on-cloudflare-workers/" rel="noopener noreferrer"&gt;The guide to the TDS on Cloudflare Workers&lt;/a&gt; stops at the redirect. The Worker answers the browser with a 302, the browser opens the offer, and from that moment the Worker sees nothing. The registration or the deposit happens on the network's site, on a domain you do not control, where you cannot put a pixel.&lt;/p&gt;

&lt;p&gt;A postback is how that event comes back. It is a server to server (S2S) request: when the visitor converts, the network calls a URL you gave it and puts the conversion details into the query string. For the call to mean anything, it has to say which click it belongs to. Getting the click into the network's hands is the work of the redirect.&lt;/p&gt;

&lt;p&gt;This article shows how the TDS in &lt;a href="https://301.st" rel="noopener noreferrer"&gt;301.st&lt;/a&gt; does both halves: what it adds to the offer link, what the network has to send back, what a conversion changes inside the platform, and how it gets back to the ad network that sold you the click. The TDS is the tracker here: the click, the rule and the conversion meet in one place, with nothing in between. Everything here was read from the platform's source code on 6 October 2026. The general version of this loop, built by hand on a Worker, is in &lt;a href="https://301.sh/attribution-when-the-conversion-page-is-not-yours/" rel="noopener noreferrer"&gt;the article on attribution when the conversion page is not yours&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The loop
&lt;/h2&gt;

&lt;p&gt;Two things have to hold. The redirect has to put an identifier into a parameter the network keeps, and the network has to return that parameter unchanged in its postback. The rest is the settings of the postback source, described below.&lt;/p&gt;

&lt;h2&gt;
  
  
  What travels with the click
&lt;/h2&gt;

&lt;p&gt;For every request the Worker makes a click id: 32 hexadecimal characters, the first part of which is the time of the click. On a redirect, it also builds a click token around that id. The token carries the rule that matched, the visitor's country and device, and, for a split, the page variant that was picked. It is signed with HMAC, and the signature is checked when the token comes back. A token that someone edited or made up fails that check.&lt;/p&gt;

&lt;p&gt;The platform does not keep a table of clicks. The token is the record of the click, and that is why it is signed: everything the platform learns about a conversion, it reads out of the token the network returned. A token is 44 characters long, or 58 for a split rule, and the platform stops accepting it 90 days after the click.&lt;/p&gt;

&lt;p&gt;Whether a rule adds the token is the setting &lt;strong&gt;Click key in the URL&lt;/strong&gt; on the rule:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;th&gt;What the offer link gets&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Don't add&lt;/td&gt;
&lt;td&gt;nothing; the link goes out as you wrote it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;301 key (_cid)&lt;/td&gt;
&lt;td&gt;the token in a parameter named &lt;code&gt;_cid&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Source parameter&lt;/td&gt;
&lt;td&gt;the token in the parameter your postback source names, such as &lt;code&gt;sub1&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;"Don't add" is right for a white page, a bot filter, or any link that does not lead to an affiliate network. The other two are for conversions that come back to 301.st as postbacks. The third one needs a postback source picked on the rule, because the parameter name comes from it.&lt;/p&gt;

&lt;p&gt;The Worker writes the token over any parameter of the same name already in the link. If your offer URL contains &lt;code&gt;sub1=something&lt;/code&gt; and the source's parameter is &lt;code&gt;sub1&lt;/code&gt;, the network gets the token. The rule editor warns about this when you save.&lt;/p&gt;

&lt;p&gt;The token is added to an HTTP redirect and to a split. A rule that sends visitors with a meta refresh, JavaScript or an iframe gets no token, and its conversions cannot be attributed.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;{clickid}&lt;/code&gt; is not the key
&lt;/h2&gt;

&lt;p&gt;Target URLs take macros, and two of them matter for tracking.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;{clickid}&lt;/code&gt; puts the raw 32 character click id into the link. It is the same id that sits inside the token, but it is not signed and it carries nothing else. A network that returns it in a postback returns a value the platform cannot match: the conversion is recorded as unmatched. Use &lt;code&gt;{clickid}&lt;/code&gt; when a network wants your id in a field of its own, and let the token go into the parameter the postback reads.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;{q.name}&lt;/code&gt; puts a parameter of the incoming request into the link. A visitor who arrives as &lt;code&gt;go.example.com/promo?cid=AD123&lt;/code&gt; can be sent on as &lt;code&gt;https://network.example/offer?sub2={q.cid}&lt;/code&gt;, and the network receives &lt;code&gt;sub2=AD123&lt;/code&gt;. The value is URL encoded and cut at 256 characters; a parameter that is not there becomes an empty string. This is how the ad network's own click id, or the &lt;code&gt;gclid&lt;/code&gt; from Google Ads, rides along to the affiliate network and comes back in the postback. &lt;code&gt;{q.name}&lt;/code&gt; works in any target URL today but is not yet in the macro list of the editor, so type it by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  The postback source
&lt;/h2&gt;

&lt;p&gt;A postback source is one network, or one campaign of a network, as 301.st sees it. You find the list under Postbacks, Postback sources. When you add one, the platform can fill most of it for you in two ways. You can paste a postback URL the network gave you, with its macros, and the platform tries to recognize the network. Or you can save the source as a draft, ask the network to fire one test postback at the address shown, and let the platform learn the field names from that request.&lt;/p&gt;

&lt;p&gt;The settings that decide whether conversions match:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Click parameter.&lt;/strong&gt; The name of the parameter that carries the token, &lt;code&gt;_cid&lt;/code&gt; if you leave it empty. Set it when the network dictates its own name, as most do: &lt;code&gt;sub1&lt;/code&gt;, &lt;code&gt;subid&lt;/code&gt;, &lt;code&gt;data1&lt;/code&gt;, &lt;code&gt;btag&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Macro syntax.&lt;/strong&gt; How the network writes its macros in postback URLs: &lt;code&gt;{sub1}&lt;/code&gt;, &lt;code&gt;[SUB1]&lt;/code&gt;, &lt;code&gt;${sub1}&lt;/code&gt; and so on. The platform uses it to build the URL you paste back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Field mapping.&lt;/strong&gt; For each field the platform understands, the name of the parameter the network sends: Click ID, Event type, Transaction ID, Gross amount and currency, Payout amount and currency, External user ID. A source without a Click ID mapping records every conversion as unmatched, and the editor shows a banner until you set it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Event mapping.&lt;/strong&gt; Networks name their events differently. This table translates the network's names into the ones 301.st reports on, such as &lt;code&gt;registration&lt;/code&gt; and &lt;code&gt;ftd&lt;/code&gt;. An event the table does not know is recorded as unknown and does not reach your numbers. A network that cannot send the event name at all gets one postback URL per event instead, each with the event written into it as &lt;code&gt;event_type=ftd&lt;/code&gt;, as in the example below.&lt;/p&gt;

&lt;p&gt;Then there is how the network proves it is the network. The default is a secret token in every postback URL, generated for the source. The alternatives are a list of the network's IP addresses, an HMAC signature in a header, or nothing at all. An extra IP check can sit on top of the token.&lt;/p&gt;

&lt;p&gt;Three more settings shape what is counted. The &lt;strong&gt;attribution window&lt;/strong&gt; in days, 90 by default: a conversion that arrives later than that after the click is recorded as expired. The &lt;strong&gt;payout currency&lt;/strong&gt;: a postback that reports a different currency is set aside rather than added to your totals. And the &lt;strong&gt;program key&lt;/strong&gt;: give several sources the same key when they are campaigns of one affiliate program, so the same transaction arriving through two of them is counted once.&lt;/p&gt;

&lt;p&gt;When the source is saved, the platform builds the postback URLs, one per event:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://api.301.st/tds/postback/&amp;lt;source_id&amp;gt;?token=&amp;lt;secret&amp;gt;&amp;amp;sub1={sub1}&amp;amp;event_id={event_id}&amp;amp;user_id={user_id}&amp;amp;amount={amount}&amp;amp;event_type=ftd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Paste each into the matching field of the network's postback form. The parts in braces are the network's own macros, which it fills in when it calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when a postback arrives
&lt;/h2&gt;

&lt;p&gt;The platform answers the network at once and stores the request. Processing starts immediately; anything left over is picked up by an hourly job. Then, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The fields are read through the mapping. A macro the network failed to fill in, such as a literal &lt;code&gt;{sub1}&lt;/code&gt;, counts as an empty field rather than as a value.&lt;/li&gt;
&lt;li&gt;The token is checked. A valid one gives the rule, the country, the device and the split variant of the original click.&lt;/li&gt;
&lt;li&gt;The conversion is deduplicated. With a transaction id, the same transaction and event are counted once, however many times the network retries. Without one, the platform falls back to click, event, amount and minute.&lt;/li&gt;
&lt;li&gt;The conversion is recorded as matched, unmatched, or expired.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A matched conversion goes into hourly totals per rule, variant, country, device, event and currency. A refund or a chargeback comes in as a negative amount and is attached to the conversion it cancels.&lt;/p&gt;

&lt;p&gt;Not every event teaches a split. The split counts acquisition events, such as a registration or a first deposit, against the clicks each page got. Later money events, &lt;code&gt;deposit&lt;/code&gt;, &lt;code&gt;revshare&lt;/code&gt; and &lt;code&gt;ngr&lt;/code&gt;, add revenue to the rule but do not move the split, because they come from a player the page has already brought in. The Worker picks up the new split numbers with its next sync. Click counters travel the other way: your Worker sends them to the platform once an hour, for each hour that has ended, so a rule's click numbers run an hour or two behind its conversions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Revenue share outlives the token.&lt;/strong&gt; A network that pays revshare sends payments for months, long after the 90 days. If the network sends a stable id for the player, mapped as External user ID, the first matched conversion links that player to the click. For the next 365 days, payments for the same player are attributed to the same rule even without a valid token. These payments count toward the rule's revenue and, like other money events, leave the split alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Passing the conversion on
&lt;/h2&gt;

&lt;p&gt;301.st can send each conversion on to three places, set per postback source.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A GET to the traffic source,&lt;/strong&gt; the ad network that sold you the click. It optimizes your campaign on the conversions it is told about, against its own click id. You give a URL template with the macros &lt;code&gt;{source_click_id}&lt;/code&gt;, &lt;code&gt;{event}&lt;/code&gt;, &lt;code&gt;{payout}&lt;/code&gt;, &lt;code&gt;{currency}&lt;/code&gt; and &lt;code&gt;{txid}&lt;/code&gt;. It is sent only for the events you list, never for negative ones, and only when the conversion carries the network's click id.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A signed POST to your own endpoint.&lt;/strong&gt; The field Outbound target URL on the source. Each conversion is sent as JSON with an &lt;code&gt;X-301-Signature&lt;/code&gt; header, so the receiver can check it came from the platform. This is the way into your own reports or CRM.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A GET to any other URL that takes a postback.&lt;/strong&gt; You give a template, and the platform fills these macros: &lt;code&gt;{click_id}&lt;/code&gt;, &lt;code&gt;{source_click_id}&lt;/code&gt;, &lt;code&gt;{event}&lt;/code&gt;, &lt;code&gt;{payout}&lt;/code&gt;, &lt;code&gt;{gross}&lt;/code&gt;, &lt;code&gt;{currency}&lt;/code&gt;, &lt;code&gt;{txid}&lt;/code&gt;, &lt;code&gt;{conversion_id}&lt;/code&gt;, &lt;code&gt;{rule_id}&lt;/code&gt;, &lt;code&gt;{country}&lt;/code&gt;, &lt;code&gt;{device}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The two GETs have no screen in the 301.st interface yet; they are set through the API, on &lt;code&gt;PATCH https://api.301.st/tds/postback-sources/&amp;lt;id&amp;gt;&lt;/code&gt;, as &lt;code&gt;source_postback_url&lt;/code&gt;, &lt;code&gt;source_postback_events&lt;/code&gt; and &lt;code&gt;outbound_get_url&lt;/code&gt;. The same goes for the source click id mapping, &lt;code&gt;source_click_id&lt;/code&gt; in &lt;code&gt;field_map&lt;/code&gt;, which the next example needs. A screen for them is being built.&lt;/p&gt;

&lt;p&gt;Templates must use https and a host name, not an IP address. Values are URL encoded, and a value the conversion does not have becomes empty. A failed delivery is retried on a server error, a timeout or a rate limit answer: six attempts in all, after waits of 30 seconds, 2 minutes, 10 minutes, an hour and 6 hours. Any other 4xx answer stops the retries, because sending the same request again will not change it.&lt;/p&gt;

&lt;h2&gt;
  
  
  One click, end to end
&lt;/h2&gt;

&lt;p&gt;Here is the whole loop as one setup: an ad network sends the click, an affiliate network pays for the deposit, and the ad network learns which of its clicks paid off.&lt;/p&gt;

&lt;p&gt;The ad network puts its own click id into the ad's link, as &lt;code&gt;cid&lt;/code&gt;. The postback source for the affiliate network has the click parameter &lt;code&gt;sub1&lt;/code&gt;. Through the API, the source maps &lt;code&gt;source_click_id&lt;/code&gt; to &lt;code&gt;sub2&lt;/code&gt;, lists &lt;code&gt;ftd&lt;/code&gt; in &lt;code&gt;source_postback_events&lt;/code&gt;, and has this template for the ad network:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://ads.example/conversion?click_id={source_click_id}&amp;amp;event={event}&amp;amp;payout={payout}&amp;amp;currency={currency}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule redirects with the click key in the source parameter, to this target:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://network.example/offer?sub2={q.cid}&amp;amp;geo={country}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;The ad is clicked, and the visitor lands on &lt;code&gt;https://go.example.com/promo?cid=AD123&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The TDS rule matches and the Worker answers &lt;code&gt;302&lt;/code&gt; with &lt;code&gt;Location: https://network.example/offer?sub2=AD123&amp;amp;geo=DE&amp;amp;sub1=&amp;lt;token&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The visitor registers and deposits. The affiliate network calls the postback URL for &lt;code&gt;ftd&lt;/code&gt;, with &lt;code&gt;sub1=&amp;lt;token&amp;gt;&lt;/code&gt; and &lt;code&gt;sub2=AD123&lt;/code&gt; filled in. It has to return both.&lt;/li&gt;
&lt;li&gt;301.st checks the token, records an &lt;code&gt;ftd&lt;/code&gt; for the rule in Germany, and fills the template: &lt;code&gt;https://ads.example/conversion?click_id=AD123&amp;amp;event=ftd&amp;amp;payout=...&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The ad network now sees a deposit on its own click AD123 and can bid on it. One thing is left to you: the event arrives under 301.st's name, &lt;code&gt;ftd&lt;/code&gt; here. If the ad network expects a name of its own, write that name into the template in place of &lt;code&gt;{event}&lt;/code&gt;; with only &lt;code&gt;ftd&lt;/code&gt; in the list, the template is used for nothing else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it does not reach
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A conversion is attributed only through the token. A network that cannot return a parameter verbatim cannot be attributed, whatever else it sends.&lt;/li&gt;
&lt;li&gt;Rules that redirect with meta refresh, JavaScript or an iframe send no token.&lt;/li&gt;
&lt;li&gt;A token older than 90 days is not read, and the source's attribution window can make that shorter. Revenue share is covered by the player link, if the network sends a player id.&lt;/li&gt;
&lt;li&gt;Postbacks are stored and processed in the platform's own database, so they do not count against your Cloudflare D1 allowance. The click counters do: each click on a rule writes to your D1, which &lt;a href="https://301.sh/tds-on-cloudflare-workers/" rel="noopener noreferrer"&gt;the TDS guide&lt;/a&gt; counts in its section on the Free plan.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If conversions do not match, check the redirect first. Open the rule's link in a browser and look at the address the offer opens with: the token has to be there, in the parameter the network keeps. Then check that the network's postback URL returns that parameter.&lt;/p&gt;

</description>
      <category>tracking</category>
      <category>redirects</category>
      <category>cloudflare</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How a TDS on Cloudflare Workers sorts traffic before it reaches your server</title>
      <dc:creator>301ST</dc:creator>
      <pubDate>Wed, 07 Oct 2026 14:00:49 +0000</pubDate>
      <link>https://dev.to/301st/how-a-tds-on-cloudflare-workers-sorts-traffic-before-it-reaches-your-server-2o79</link>
      <guid>https://dev.to/301st/how-a-tds-on-cloudflare-workers-sorts-traffic-before-it-reaches-your-server-2o79</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;My site is on a VPS in Frankfurt. How can a Cloudflare Worker send my visitors somewhere before they reach it?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The question carries an assumption. It treats the Worker as a place where the site has to live, the way a hosting account is. A Worker on a route is something else. It runs on Cloudflare's edge, on the path between the visitor and wherever your DNS points, and it sees every request to that hostname before your server does.&lt;/p&gt;

&lt;p&gt;That position is what a traffic distribution system needs. A TDS looks at each visitor, checks a list of rules and decides where the visitor goes: to a country page, to the mobile offer, to one of three landing pages under test, or on to your server as if nothing had happened. The server can be anything: a VPS, shared hosting, a page builder, or static files on Cloudflare Pages, which is where &lt;a href="https://generator.ink" rel="noopener noreferrer"&gt;Site Generator&lt;/a&gt; publishes the affiliate sites it builds. The TDS does not care, because it only ever forwards to it. Whether the TDS should sit on the site's own domain or on a subdomain for ad clicks is covered below.&lt;/p&gt;

&lt;p&gt;This guide explains how that works on Cloudflare, using the TDS in &lt;a href="https://301.st" rel="noopener noreferrer"&gt;301.st&lt;/a&gt; as the worked example, read from its source code. Every Cloudflare limit below was checked against Cloudflare's documentation on 6 October 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the Worker sits
&lt;/h2&gt;

&lt;p&gt;When a DNS record in a Cloudflare zone is proxied (the orange cloud), the visitor's browser does not connect to your server. It connects to Cloudflare, which terminates TLS, applies the zone's rules and then opens its own connection to the address in the record. A Worker route plugs into that middle step. Cloudflare's documentation on &lt;a href="https://developers.cloudflare.com/workers/configuration/routing/routes/" rel="noopener noreferrer"&gt;routes&lt;/a&gt; describes what happens when the Worker passes the request on:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Calling &lt;code&gt;fetch()&lt;/code&gt; on the incoming &lt;code&gt;Request&lt;/code&gt; object will trigger a subrequest to your application server, as defined in the DNS settings of your Cloudflare zone.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So the Worker does not replace your server. It stands in front of it, and the server stays where the DNS record says it is.&lt;/p&gt;

&lt;p&gt;Four conditions make this true, and each of them is a place where a setup quietly fails.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The zone is on Cloudflare.&lt;/strong&gt; The domain's nameservers point to Cloudflare. A route can only be created inside a zone you have.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The record is proxied.&lt;/strong&gt; Routes need "a DNS record set up for the domain or subdomain proxied by Cloudflare". A grey cloud record sends visitors straight to your server's IP, and the Worker never runs for them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The route pattern covers the hostname.&lt;/strong&gt; &lt;code&gt;example.com/*&lt;/code&gt; matches &lt;code&gt;example.com&lt;/code&gt; and every path under it, but not &lt;code&gt;www.example.com&lt;/code&gt;. That is a separate hostname and needs its own route or a pattern like &lt;code&gt;*example.com/*&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No more specific route claims the request.&lt;/strong&gt; "When more than one route pattern could match a request URL, the most specific route pattern wins", and only that one Worker runs. A Worker you already have on &lt;code&gt;example.com/api/*&lt;/code&gt; keeps &lt;code&gt;/api/&lt;/code&gt; for itself.&lt;/p&gt;

&lt;p&gt;Your server needs nothing from this. It answers Cloudflare the way it answered before. The usual origin settings still apply: the SSL mode of the zone has to match what the server can do, or Cloudflare answers with a 525 or 526 (&lt;a href="https://301.sh/redirect-silently-fails/" rel="noopener noreferrer"&gt;the article on those errors&lt;/a&gt; covers the fixes).&lt;/p&gt;

&lt;h2&gt;
  
  
  What 301.st puts in your account
&lt;/h2&gt;

&lt;p&gt;The TDS does not run on our infrastructure. It runs in your Cloudflare account, and that is a deliberate choice: the requests count against your Workers quota, the rules live in your database, and the Worker keeps working if our API is unreachable.&lt;/p&gt;

&lt;p&gt;When you connect an account, you give 301.st an API token. The token covers the whole platform, DNS and redirects included; the TDS part of it is Workers Scripts, Workers Routes, D1 and Pages read. With those, the platform creates in your account:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;What it is for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Worker&lt;/td&gt;
&lt;td&gt;&lt;code&gt;301-tds&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the TDS itself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Worker&lt;/td&gt;
&lt;td&gt;&lt;code&gt;301-health&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;domain health checks, separate from the TDS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;D1 database&lt;/td&gt;
&lt;td&gt;&lt;code&gt;301-client&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;your rules, domain settings and hourly counters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Route&lt;/td&gt;
&lt;td&gt;&lt;code&gt;yourdomain.com/*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;one per domain that has at least one active rule&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A domain without rules gets no route, so its traffic never touches the Worker. The platform will not take over a route that already belongs to another Worker.&lt;/p&gt;

&lt;p&gt;Note the pattern. The route is created for the hostname you added, exactly as written. If visitors arrive on both &lt;code&gt;example.com&lt;/code&gt; and &lt;code&gt;www.example.com&lt;/code&gt;, add both as domains.&lt;/p&gt;

&lt;h3&gt;
  
  
  The main domain or a go subdomain
&lt;/h3&gt;

&lt;p&gt;A route on &lt;code&gt;example.com/*&lt;/code&gt; puts the Worker in front of all of the site's traffic: search visitors, direct visits, search engine crawlers, and every image, stylesheet and font they load. The ad clicks you actually want to sort are a small part of that. The rest spends the Workers quota on visitors no rule is meant for, which is how a TDS pushes a site onto the paid plan, and a mistake in a rule reaches the whole site, search engines included.&lt;/p&gt;

&lt;p&gt;So the setup we recommend gives the ad clicks a hostname of their own:&lt;/p&gt;

&lt;p&gt;Add &lt;code&gt;go.example.com&lt;/code&gt; as a domain in 301.st. The platform creates a proxied placeholder record for it, since nothing is served there. Point your ads at &lt;code&gt;go.example.com&lt;/code&gt;, give it your rules, and add one more with no conditions and the lowest priority that redirects to &lt;code&gt;https://example.com/&lt;/code&gt;, so every visitor no other rule took lands on the site. The site and its own traffic never touch the Worker.&lt;/p&gt;

&lt;p&gt;One thing works differently on &lt;code&gt;go&lt;/code&gt;: when the Workers quota runs out, fail open does not help, because with the Worker skipped the request reaches the placeholder and gets an error. Only ad clicks count against the quota there, though, so it runs out far later.&lt;/p&gt;

&lt;p&gt;A TDS on the main domain is for when you do want every visitor sorted. For a site on a server it works today. For a site on Cloudflare Pages, a custom domain of a Pages project as Site Generator sites are, 301.st does not put a route on the main domain yet: applying the rules fails with an error that suggests a subdomain. Support for it is in development; until then, use &lt;code&gt;go&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  One request, step by step
&lt;/h2&gt;

&lt;p&gt;Here is what the &lt;code&gt;301-tds&lt;/code&gt; Worker does with a request, in the order the code does it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two paths are taken.&lt;/strong&gt; The Worker answers &lt;code&gt;/health&lt;/code&gt; and &lt;code&gt;/_health&lt;/code&gt; on your hostname itself, for the platform's checks. If your site has its own page at one of those paths, visitors will get the Worker's answer instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Static files go straight through.&lt;/strong&gt; Requests for stylesheets, scripts, images, fonts and similar files skip the rules entirely. They still invoke the Worker, because the route covers every path, but they never touch the database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rules come from your D1, cached for a minute.&lt;/strong&gt; The Worker reads all active rules in one query and keeps them in memory for 60 seconds. The cache lives in one Worker instance, and Cloudflare runs many, so after a change different visitors may see the old and new rules for up to a minute.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Changes reach the edge when you press Apply.&lt;/strong&gt; Editing a rule in 301.st saves a draft. Apply writes the rules into your D1 through Cloudflare's API. Once an hour the Worker also pulls the full rule set from the platform, as a backstop if a push failed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The first matching rule wins.&lt;/strong&gt; Rules are tried in a fixed order: rules that target bots first, then the other traffic shield rules, then SmartLink rules. Inside each group the priority you set decides, highest first. The first rule whose conditions all hold runs its action, and no other rule is looked at.&lt;/p&gt;

&lt;p&gt;The groups come before the priority, and that matters. A rule counts as a bot rule only if it requires "bot". Suppose you run Bot Shield, which blocks every bot, and a datacenter rule with a higher priority number that sends datacenter traffic to another page. An uptime monitor from a cloud server is a bot, so Bot Shield sees it first and blocks it. The datacenter rule never gets the chance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nothing matched means your server.&lt;/strong&gt; The request goes on with &lt;code&gt;fetch(request)&lt;/code&gt;, unchanged, with its method, headers, cookies and body. The visitor sees your site as if the TDS were not there. The Worker only adds one to the hourly pass counter.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a rule can check
&lt;/h2&gt;

&lt;p&gt;A rule is a set of conditions. All of them have to hold for the rule to match. A condition left empty is not checked at all, so a rule with only a country list matches every device, every browser and every hour in those countries.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Condition&lt;/th&gt;
&lt;th&gt;What the Worker reads&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Country, or countries to exclude&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;request.cf.country&lt;/code&gt;, set by Cloudflare from the visitor's IP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Device: mobile or desktop&lt;/td&gt;
&lt;td&gt;the &lt;code&gt;Sec-CH-UA-Mobile&lt;/code&gt; client hint, otherwise the user agent; an iPad counts as desktop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operating system, browser&lt;/td&gt;
&lt;td&gt;the user agent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bot, bot category, verified, network class&lt;/td&gt;
&lt;td&gt;the bot classifier, described in the next section&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hour and day of the week&lt;/td&gt;
&lt;td&gt;the clock in UTC, not the visitor's local time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;utm_source&lt;/code&gt;, other query parameters&lt;/td&gt;
&lt;td&gt;the query string; a source list and a parameter list on the same rule match if either does&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;utm_campaign&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the query string, as a separate condition&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Path&lt;/td&gt;
&lt;td&gt;a regular expression on the path, without the query string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Referrer&lt;/td&gt;
&lt;td&gt;a regular expression on the &lt;code&gt;Referer&lt;/code&gt; header&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Hours are UTC on purpose. The counters are stored by UTC hour, and a rule in the visitor's local time would spread over every hour of the report. If you want "nights in Germany", convert to UTC yourself and remember the clock change in March and October.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;utm_source&lt;/code&gt; or parameter pair is the one place where a rule says "either". The Facebook preset matches &lt;code&gt;utm_source=facebook&lt;/code&gt; or the presence of &lt;code&gt;fbclid&lt;/code&gt;, because paid social clicks often carry &lt;code&gt;fbclid&lt;/code&gt; and no UTM tags at all.&lt;/p&gt;

&lt;p&gt;The TDS does not match on the visitor's IP address, a specific ASN number, the browser language or cookies. If you need one of those today, a rule in 301.st will not do it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a rule can do
&lt;/h2&gt;

&lt;p&gt;A redirect is an answer to the browser. The Worker tells it where to go and is done; the browser then connects to the target on its own, wherever that target is hosted. The Worker does not see what happens there, which is why counting a conversion on the target needs the postback described further down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Redirect.&lt;/strong&gt; With the status code you choose: 301, 302, 307 or 308. A permanent code tells browsers to remember the answer, which is wrong for a TDS decision that depends on the visitor, so the presets use 302. Every redirect the TDS sends also carries &lt;code&gt;Cache-Control: private, no-cache&lt;/code&gt;, so no shared cache stores one visitor's answer for the next.&lt;/p&gt;

&lt;p&gt;The redirect can be sent in five ways: a plain HTTP redirect, a meta refresh page with an optional delay, a meta refresh that hides the referrer, a JavaScript &lt;code&gt;location.replace()&lt;/code&gt;, or an iframe that keeps your address in the bar. A separate switch adds &lt;code&gt;Referrer-Policy: no-referrer&lt;/code&gt; to any of them. Hiding the referrer is off by default, because some affiliate networks attribute by it and would stop counting without telling anyone.&lt;/p&gt;

&lt;p&gt;The target URL can contain macros that the Worker fills from the request: &lt;code&gt;{country}&lt;/code&gt;, &lt;code&gt;{device}&lt;/code&gt;, &lt;code&gt;{os}&lt;/code&gt;, &lt;code&gt;{browser}&lt;/code&gt;, &lt;code&gt;{path}&lt;/code&gt; and &lt;code&gt;{host}&lt;/code&gt;. A rule that sends every country to &lt;code&gt;https://example.net/{country}/&lt;/code&gt; is one rule, not two hundred.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Block.&lt;/strong&gt; The Worker answers &lt;code&gt;403 Blocked&lt;/code&gt; and your server never sees the request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Split.&lt;/strong&gt; A split rule has several target URLs and picks one for each visitor. With a fixed split you set weights, and &lt;code&gt;90&lt;/code&gt; and &lt;code&gt;10&lt;/code&gt; send nine visitors in ten to the first page. A weight of zero pauses a page without losing its numbers. The other three modes learn from conversions as they arrive and shift traffic toward the page that converts better: Thompson sampling, which is the default, UCB and epsilon greedy. A split can also keep separate pools of pages per group of countries, so the German traffic is tested on German pages.&lt;/p&gt;

&lt;p&gt;A split that learns needs to know which visitor converted. That is the job of the signed click token the Worker adds to the target URL, and of the postbacks that bring the conversion back. Setting those up with an affiliate network and an ad network is covered in &lt;a href="https://301.sh/postbacks-for-a-tds/" rel="noopener noreferrer"&gt;the article on postbacks for a TDS&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the TDS tells bots apart
&lt;/h2&gt;

&lt;p&gt;Every request goes through a classifier before any rule looks at it. The classifier puts a bot into one of five groups by its user agent: search engines, AI training crawlers, uptime monitors, ad reviewers and link preview fetchers. A user agent shorter than ten characters counts as a bot. A user agent with &lt;code&gt;bot&lt;/code&gt;, &lt;code&gt;crawl&lt;/code&gt; or &lt;code&gt;spider&lt;/code&gt; in it that no table recognizes is an unverified bot of no group.&lt;/p&gt;

&lt;p&gt;A user agent is a string anyone can send. That is why the classifier keeps a second, separate answer: verified or not. It comes only from Cloudflare. When Cloudflare has confirmed that a request really comes from the bot it claims to be, it sets &lt;code&gt;request.cf.verifiedBotCategory&lt;/code&gt; (&lt;a href="https://developers.cloudflare.com/ruleset-engine/rules-language/fields/reference/cf.verified_bot_category/" rel="noopener noreferrer"&gt;the field&lt;/a&gt;). The group comes from the user agent table, the confirmation from Cloudflare. A scraper that sends Googlebot's user agent lands in the search group and stays unverified.&lt;/p&gt;

&lt;p&gt;The third answer is the network. If the autonomous system the request comes from belongs to a hosting or cloud provider (AWS, Google, Azure, Hetzner, OVH, DigitalOcean and similar), the request is marked as coming from a datacenter. People browse from home and mobile networks. Checkers, scrapers and review systems often run in datacenters, whatever browser they claim to be.&lt;/p&gt;

&lt;p&gt;A rule can combine these: bot or not, which groups, verified or not, datacenter or not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The presets
&lt;/h2&gt;

&lt;p&gt;You do not have to write rules from scratch. 301.st ships these templates, each one a single rule with its priority already set.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Preset&lt;/th&gt;
&lt;th&gt;Matches&lt;/th&gt;
&lt;th&gt;Does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AI Guard&lt;/td&gt;
&lt;td&gt;AI training crawlers&lt;/td&gt;
&lt;td&gt;blocks, or redirects to a page you choose&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloaking Standard&lt;/td&gt;
&lt;td&gt;ad reviewers, search engines, link preview fetchers&lt;/td&gt;
&lt;td&gt;redirects them to a page you choose (a "white page")&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Datacenter Cloak&lt;/td&gt;
&lt;td&gt;any request from a datacenter network, bot or not&lt;/td&gt;
&lt;td&gt;redirects it to a page you choose&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bot Shield&lt;/td&gt;
&lt;td&gt;any bot&lt;/td&gt;
&lt;td&gt;blocks, or redirects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Geo + Mobile&lt;/td&gt;
&lt;td&gt;mobile visitors from the countries you list&lt;/td&gt;
&lt;td&gt;redirects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mobile Redirect&lt;/td&gt;
&lt;td&gt;mobile visitors&lt;/td&gt;
&lt;td&gt;redirects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Desktop Redirect&lt;/td&gt;
&lt;td&gt;desktop visitors&lt;/td&gt;
&lt;td&gt;redirects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Geo Filter&lt;/td&gt;
&lt;td&gt;visitors from the countries you list&lt;/td&gt;
&lt;td&gt;redirects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Facebook Traffic&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;utm_source&lt;/code&gt; of facebook, fb, fb_ads or meta, or &lt;code&gt;fbclid&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;redirects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Traffic&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;utm_source&lt;/code&gt; of google or google_ads, or &lt;code&gt;gclid&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;redirects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UTM Split&lt;/td&gt;
&lt;td&gt;the &lt;code&gt;utm_source&lt;/code&gt; values you list&lt;/td&gt;
&lt;td&gt;redirects&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;AI Guard leaves search engines alone on purpose. They have their own group, and blocking them to keep scrapers out would take the site out of search results.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the two cloaking presets do, and what they cost you
&lt;/h3&gt;

&lt;p&gt;Cloaking Standard and Datacenter Cloak show one page to the systems that review a site and another page to the people who visit it. That is what the word means, and we name the presets after it so nobody switches one on by accident.&lt;/p&gt;

&lt;p&gt;Know what you are running into. Google's &lt;a href="https://developers.google.com/search/docs/essentials/spam-policies" rel="noopener noreferrer"&gt;spam policies&lt;/a&gt; define cloaking as "presenting different content to users and search engines with the intent to manipulate search rankings and mislead users". Google Ads names it in its &lt;a href="https://support.google.com/adspolicy/answer/15938075" rel="noopener noreferrer"&gt;Circumventing systems&lt;/a&gt; policy: "showing different content on your website to different people or to Google to try to hide things that might break Google Ads' rules". The penalty is stated in the same place: accounts "will be suspended upon detection and without prior warning". Other ad platforms have rules of the same kind.&lt;/p&gt;

&lt;p&gt;The same Google Ads page draws the line for the rest of this article. It says "It's okay to show slightly different content to different people, like showing a landing page or ad destination in different languages, having different special offers, or adjusting for geographical location". Its forbidden example is a shop that shows Google a page selling clothing while the site sells guns. A geo rule that sends each country to its own landing page for the same offer fits the first description. Which side a particular rule falls on is your decision and your risk.&lt;/p&gt;

&lt;p&gt;Two technical facts matter if you use them. First, Cloaking Standard matches on the user agent unless you also require "verified". Without that switch, a reviewer that sends a plain browser user agent goes through to the real page, and anyone who types &lt;code&gt;Googlebot&lt;/code&gt; into their user agent gets the white page. Second, Datacenter Cloak recognizes networks by the name of the provider that owns them. That catches the large clouds and misses a provider whose name is not on the list.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs on the Free plan
&lt;/h2&gt;

&lt;p&gt;A route runs the Worker on every request to the hostname: the page, and each image, stylesheet and font it loads. The static bypass described above saves database work, not invocations. The Workers Free plan allows 100,000 requests a day, reset at midnight UTC (&lt;a href="https://developers.cloudflare.com/workers/platform/limits/" rel="noopener noreferrer"&gt;limits&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The database has its own daily allowance. D1 on the Free plan includes 5 million rows read and 100,000 rows written a day (&lt;a href="https://developers.cloudflare.com/d1/platform/pricing/" rel="noopener noreferrer"&gt;D1 pricing&lt;/a&gt;). On a domain with rules, every page request reads the domain's settings and writes at least one counter row. So on page requests the two allowances run out at about the same traffic.&lt;/p&gt;

&lt;p&gt;What happens then is different for the two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When D1 runs out,&lt;/strong&gt; Cloudflare says queries stop until the reset. The TDS treats a failed rules query as "no rules" and passes every request to your server. Your site keeps working. The routing stops, and so does the counting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When Workers run out,&lt;/strong&gt; the route decides. A route either fails open, which "Bypasses the Worker. Requests behave as if no Worker is configured", or fails closed, which "Returns a Cloudflare &lt;code&gt;1027&lt;/code&gt; error page". 301.st does not set this mode when it creates the route today. Open Workers &amp;amp; Pages in your Cloudflare dashboard, find the routes of &lt;code&gt;301-tds&lt;/code&gt;, and check the mode on each. On a TDS in front of a working site, fail open is almost always what you want: when the quota ends, visitors reach your server unsorted instead of reaching an error page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What lifting both costs.&lt;/strong&gt; The Workers Paid plan is $5 a month per account with 10 million requests included, then $0.30 per additional million (&lt;a href="https://developers.cloudflare.com/workers/platform/pricing/" rel="noopener noreferrer"&gt;pricing&lt;/a&gt;). It has no daily request limit, and it removes the D1 daily limits as well. For a TDS on more than a few small domains, that is the plan to be on.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://301.sh/bot-analytics-worker-on-every-request/" rel="noopener noreferrer"&gt;The article on bot analytics Workers&lt;/a&gt; works through the same route arithmetic with real numbers from this site.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting it up
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Move the zone to Cloudflare&lt;/strong&gt; if it is not there, and check that the record for the hostname is proxied. Leave it pointing at your current server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the SSL mode&lt;/strong&gt; against your server: Full (Strict) if the server has a valid certificate, Full if it has any certificate, Flexible only if it has none.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Create an API token&lt;/strong&gt; with the permissions the 301.st connect screen lists, and connect the account. The platform creates the two Workers and the database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add the domain the ads point at&lt;/strong&gt;: &lt;code&gt;go.example.com&lt;/code&gt; with its last catch-all rule, as described above, or the main domain if you want every visitor sorted, plus &lt;code&gt;www&lt;/code&gt; as a separate one if visitors use it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Create a rule&lt;/strong&gt;, from a preset or by hand, and press Apply. The route appears with the first active rule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set the route to fail open&lt;/strong&gt; in the Cloudflare dashboard, unless you are on Workers Paid.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test from outside.&lt;/strong&gt; A user agent is the easiest condition to try:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://example.com/ | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'^(HTTP|location)'&lt;/span&gt;
curl &lt;span class="nt"&gt;-sI&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s2"&gt;"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) Mobile"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://example.com/ | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'^(HTTP|location)'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first request should come back with your server's normal answer, the second with the redirect of a mobile rule. Rules by country need a request from that country, through a VPN or a remote browser. Allow a minute after Apply for the rule cache.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you need a TDS at all
&lt;/h2&gt;

&lt;p&gt;If all you need is one fixed condition, try Cloudflare without a Worker first. A Single Redirect rule is an expression in Cloudflare's rules language, which has fields for the visitor's country (&lt;code&gt;ip.src.country&lt;/code&gt;) and user agent (&lt;code&gt;http.user_agent&lt;/code&gt;), and it does not count against any request quota. The Free plan gives a zone ten of them; &lt;a href="https://301.sh/every-way-to-redirect-on-cloudflare/" rel="noopener noreferrer"&gt;every way to redirect on Cloudflare&lt;/a&gt; lays out what each option can match and what it costs. The Worker earns its place when the decision needs something a static rule does not have: telling verified bots from claimed ones, splitting traffic and learning which page converts, counting every outcome per rule and hour, and keeping those rules the same across a portfolio of domains. That last part is the reason 301.st exists, and the TDS is the piece of it that runs in front of your server.&lt;/p&gt;

</description>
      <category>tracking</category>
      <category>redirects</category>
      <category>cloudflare</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Bot analytics wants a Worker on every request. What it costs on Cloudflare</title>
      <dc:creator>301ST</dc:creator>
      <pubDate>Wed, 30 Sep 2026 14:00:40 +0000</pubDate>
      <link>https://dev.to/301st/bot-analytics-wants-a-worker-on-every-request-what-it-costs-on-cloudflare-j1a</link>
      <guid>https://dev.to/301st/bot-analytics-wants-a-worker-on-every-request-what-it-costs-on-cloudflare-j1a</guid>
      <description>&lt;p&gt;You want to know which bots crawl your site: which AI crawlers, how often, and which pages they get a 404 on. A tool offers to show you, and the setup screen asks for a Cloudflare API token with Workers Scripts Edit. With that token it deploys its own Worker and sets up a route that runs it on every request to your site.&lt;/p&gt;

&lt;p&gt;Ahrefs Bot Analytics works this way, and so do Known Agents (formerly Dark Visitors), Profound and Scrunch. The Worker is short and does what it says. The route in front of it is the part that changes how your site runs, and none of these setup screens mention the cost.&lt;/p&gt;

&lt;p&gt;This article covers what that route does on Cloudflare's Free plan, what Cloudflare already gives you without it, and how to log bots yourself in a Worker you already have. Every limit below was checked against Cloudflare's documentation on 30 September 2026, and every traffic number comes from this site, 301.sh, which runs on the Free plan.&lt;/p&gt;

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

&lt;p&gt;Ahrefs asks for three permissions: Account Workers Scripts Edit, Zone Workers Routes Edit and Zone Read. Its setup screen lists three steps: "Find the zone matching" your site, "Deploy the Ahrefs analytics worker", "Set up a route to run on all requests". It adds: "Your token is used once and not stored." The manual setup in &lt;a href="https://docs.ahrefs.com/en/bot-analytics/docs/cloudflare-worker" rel="noopener noreferrer"&gt;its documentation&lt;/a&gt; uses the route pattern &lt;code&gt;*yourwebsite.com/*&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The Worker passes each request through unchanged with &lt;code&gt;fetch(event.request)&lt;/code&gt;. After the response goes out, it sends a report inside &lt;code&gt;event.waitUntil&lt;/code&gt;, so the report does not delay the page. The report carries the full URL including the query string, the visitor's IP from &lt;code&gt;cf-connecting-ip&lt;/code&gt;, the user agent, the referer, the method, the status and the content type. It also carries three headers from Web Bot Auth: &lt;code&gt;signature&lt;/code&gt;, &lt;code&gt;signature-input&lt;/code&gt; and &lt;code&gt;signature-agent&lt;/code&gt;. Those are how a &lt;a href="https://301.sh/agent-readiness-audit/" rel="noopener noreferrer"&gt;signed AI agent proves who it is&lt;/a&gt;, and they are the most interesting part of the payload.&lt;/p&gt;

&lt;p&gt;Known Agents and Profound publish Workers of the same shape. Profound's skips &lt;code&gt;/checkout&lt;/code&gt;, &lt;code&gt;/cart&lt;/code&gt;, &lt;code&gt;/admin&lt;/code&gt; and &lt;code&gt;/api&lt;/code&gt; before it reports (&lt;a href="https://docs.tryprofound.com/agent-analytics/cloudflare_worker" rel="noopener noreferrer"&gt;its docs&lt;/a&gt;). The Ahrefs one reports everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the route costs
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Every request becomes a Worker invocation.&lt;/strong&gt; The Workers Free plan allows 100,000 requests a day, reset at midnight UTC (&lt;a href="https://developers.cloudflare.com/workers/platform/limits/" rel="noopener noreferrer"&gt;limits&lt;/a&gt;). A route on &lt;code&gt;*example.com/*&lt;/code&gt; matches the page and every image, stylesheet, script and font the page loads. A page with twenty files spends twenty one requests of that quota on one visit. The report the Worker sends is a subrequest, and Cloudflare does not bill subrequests; the invocation itself counts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Static files stop being free.&lt;/strong&gt; If your site is Workers static assets, Cloudflare says "Requests to static assets are free and unlimited", and bills only the requests that invoke a Worker (&lt;a href="https://developers.cloudflare.com/workers/static-assets/billing-and-limitations/" rel="noopener noreferrer"&gt;billing&lt;/a&gt;). A route Worker in front makes every one of them invoke a Worker.&lt;/p&gt;

&lt;p&gt;Here is the difference on 301.sh. In the seven days to 30 September the zone served about 19,200 requests, and our own Worker ran 2,421 times, because it runs only on page URLs and lets the static files go out without it. A route on &lt;code&gt;/*&lt;/code&gt; would have run a Worker on all 19,200, eight times as many. This site stays far below 100,000 a day either way. A site with 10,000 page views a day and twenty files a page would not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The route runs before your own Worker.&lt;/strong&gt; Cloudflare's documentation on custom domains: "Any Workers running on routes before your Custom Domain can optionally call the Worker registered on your Custom Domain by issuing &lt;code&gt;fetch(request)&lt;/code&gt;" (&lt;a href="https://developers.cloudflare.com/workers/configuration/routing/custom-domains/" rel="noopener noreferrer"&gt;custom domains&lt;/a&gt;). So on a site that already runs its own Worker, the tool's Worker is the first code that sees each request. The documentation does not say how the pair is counted against the quota.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Know what happens at 100,000.&lt;/strong&gt; Past the daily limit a route either fails open, which "Bypasses the Worker. Requests behave as if no Worker is configured", or fails closed, which "Returns a Cloudflare &lt;code&gt;1027&lt;/code&gt; error page". The mode is a toggle on the route. The documentation does not say which one a new route gets, and the route request in the Ahrefs manual setup does not set it. Open Workers &amp;amp; Pages, find the route the tool created, and check it. A route that fails closed turns a traffic spike into an error page for your whole site.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A more specific route wins.&lt;/strong&gt; "The most specific route pattern wins" (&lt;a href="https://developers.cloudflare.com/workers/configuration/routing/routes/" rel="noopener noreferrer"&gt;routes&lt;/a&gt;). If you already have a Worker on &lt;code&gt;www.example.com/*&lt;/code&gt; or &lt;code&gt;example.com/api/*&lt;/code&gt;, those requests go to your Worker, and the analytics tool never sees them. Nothing tells you they are missing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every visitor's data leaves.&lt;/strong&gt; The route cannot tell a bot from a person before it runs, so the report goes out for people too: their IP, the page they opened, the query string with whatever your site puts in it. That makes the tool a processor of your visitors' personal data, which your privacy policy may need to say.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The token is broader than the job.&lt;/strong&gt; The Ahrefs documentation suggests a token on All accounts and All zones, and mentions narrowing it only as an alternative. Workers Scripts Edit on an account lets the holder upload any Worker to it. Create the token for the one zone the tool needs, and delete it once the Worker is running. "Not stored" is then something you do not have to take on trust.&lt;/p&gt;

&lt;h3&gt;
  
  
  What lifting the quota costs
&lt;/h3&gt;

&lt;p&gt;The Workers Paid plan is $5 a month per account with 10 million requests included, then $0.30 per additional million (&lt;a href="https://developers.cloudflare.com/workers/platform/pricing/" rel="noopener noreferrer"&gt;pricing&lt;/a&gt;). There is no daily limit on Paid, so the fail mode stops mattering. For most sites that settles the quota question for $5. It does not change the other items above: static files are still billed as invocations, and the data still leaves.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Cloudflare already shows you
&lt;/h2&gt;

&lt;p&gt;Before adding a Worker, look at what Cloudflare already records for the zone.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;What it shows&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GraphQL Analytics API&lt;/td&gt;
&lt;td&gt;all plans; on Free 31 days back, up to 30 days per query&lt;/td&gt;
&lt;td&gt;every bot by user agent and Cloudflare's verified category, with path and response status&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI Crawl Control&lt;/td&gt;
&lt;td&gt;all plans; referral data on paid plans&lt;/td&gt;
&lt;td&gt;AI crawlers by operator, the most crawled paths, response status codes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security Analytics&lt;/td&gt;
&lt;td&gt;all plans; the last seven days on Free&lt;/td&gt;
&lt;td&gt;sampled individual requests with their properties and scores&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bot Analytics&lt;/td&gt;
&lt;td&gt;Business and Enterprise&lt;/td&gt;
&lt;td&gt;bot scores and verified bot traffic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Logpush&lt;/td&gt;
&lt;td&gt;Enterprise&lt;/td&gt;
&lt;td&gt;every request, sent to your storage&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://developers.cloudflare.com/ai-crawl-control/features/analyze-ai-traffic/" rel="noopener noreferrer"&gt;AI Crawl Control&lt;/a&gt; is the dashboard answer for AI crawlers. The API answers for every bot, and it is the row we checked on our own zone.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ask the API
&lt;/h3&gt;

&lt;p&gt;On 301.sh, a Free zone, the &lt;code&gt;settings&lt;/code&gt; node of the GraphQL API lists &lt;code&gt;userAgent&lt;/code&gt;, &lt;code&gt;verifiedBotCategory&lt;/code&gt;, &lt;code&gt;clientRequestPath&lt;/code&gt; and &lt;code&gt;edgeResponseStatus&lt;/code&gt; among the fields of &lt;code&gt;httpRequestsAdaptiveGroups&lt;/code&gt;. That is enough to ask which verified bots got a 404 this week:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="k"&gt;query&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$zone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;!,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$since&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;!,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$until&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Time&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="n"&gt;viewer&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="n"&gt;zones&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filter&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="n"&gt;zoneTag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$zone&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="n"&gt;httpRequestsAdaptiveGroups&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;filter&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="n"&gt;datetime_geq&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$since&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="n"&gt;datetime_lt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$until&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="n"&gt;verifiedBotCategory_neq&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="n"&gt;edgeResponseStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;404&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="n"&gt;orderBy&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="n"&gt;count_DESC&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="n"&gt;count&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;dimensions&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="n"&gt;verifiedBotCategory&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;userAgent&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;clientRequestPath&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;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;POST it to &lt;code&gt;https://api.cloudflare.com/client/v4/graphql&lt;/code&gt; with a token that has Zone Analytics Read, the zone ID and a time range in the variables. Drop the &lt;code&gt;edgeResponseStatus&lt;/code&gt; line to see all verified bot traffic. For the bots Cloudflare has not verified, filter on &lt;code&gt;verifiedBotCategory: ""&lt;/code&gt; and &lt;code&gt;userAgent_like: "%bot%"&lt;/code&gt; instead.&lt;/p&gt;

&lt;p&gt;On our zone, over the seven days to 30 September, about 1,400 of 19,200 requests came from verified bots in nine categories. Search engine crawlers led, then AI crawlers, SEO tools and aggregators. The 404 query found two things worth fixing or knowing. DuckDuckBot asked for &lt;code&gt;/favicon.ico&lt;/code&gt; thirteen times; the site had only &lt;code&gt;/favicon.svg&lt;/code&gt;, and has both since this query. AI crawlers asked for addresses such as &lt;code&gt;/2018-gopherconuk-slides&lt;/code&gt; that this site has never had.&lt;/p&gt;

&lt;p&gt;The unverified query found something else. A client with the user agent &lt;code&gt;Claude-SearchBot/1.0&lt;/code&gt; asked 36 times for &lt;code&gt;/api/uploads/..%2F..%2F.env&lt;/code&gt;, a path that tries to climb out of an upload folder to a file of secrets. Cloudflare did not verify it, and no search crawler needs that path. A tool that counts by user agent alone files those 36 requests under Anthropic.&lt;/p&gt;

&lt;p&gt;The category values are the dashboard names with spaces, such as &lt;code&gt;"AI Crawler"&lt;/code&gt; and &lt;code&gt;"Search Engine Crawler"&lt;/code&gt;. A filter on &lt;code&gt;"AICrawler"&lt;/code&gt; returns nothing and no error.&lt;/p&gt;

&lt;p&gt;What the API does not give you is history past 31 days, or the Web Bot Auth headers. For those you need your own log.&lt;/p&gt;

&lt;h2&gt;
  
  
  Log bots in the Worker you already have
&lt;/h2&gt;

&lt;p&gt;If your site already runs a Worker for its pages, add the log there. The request already invokes that Worker, so the log costs no extra invocation, and you decide what leaves your account. 301.sh is built this way: its Worker runs first on page URLs only, set with &lt;code&gt;run_worker_first&lt;/code&gt; in &lt;code&gt;wrangler.toml&lt;/code&gt;, and images and styles are served as free static assets without it.&lt;/p&gt;

&lt;p&gt;Workers Analytics Engine is on the Free plan: 100,000 data points written and 10,000 read queries a day (&lt;a href="https://developers.cloudflare.com/analytics/analytics-engine/pricing/" rel="noopener noreferrer"&gt;pricing&lt;/a&gt;). The Workers Paid plan at $5 a month raises that to 10 million data points a month. Bind a dataset:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="c"&gt;# wrangler.toml&lt;/span&gt;
&lt;span class="nn"&gt;[[analytics_engine_datasets]]&lt;/span&gt;
&lt;span class="py"&gt;binding&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"BOTS"&lt;/span&gt;
&lt;span class="py"&gt;dataset&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"bot_hits"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then record bots on the way out:&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="nx"&gt;BOT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/bot|crawl|spider|slurp|GPTBot|ClaudeBot|PerplexityBot|Bytespider|CCBot|meta-externalagent/i&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="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;fetch&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;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&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;handle&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;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// your existing logic&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ua&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;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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user-agent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;verified&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;cf&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;verifiedBotCategory&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;verified&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;BOT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ua&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;BOTS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeDataPoint&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;indexes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;verified&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unverified&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="na"&gt;blobs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
          &lt;span class="nx"&gt;ua&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;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;URL&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;url&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&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;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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;signature-agent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&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="p"&gt;],&lt;/span&gt;
        &lt;span class="na"&gt;doubles&lt;/span&gt;&lt;span class="p"&gt;:&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;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&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;Three details matter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Only bots are written.&lt;/strong&gt; People's requests never reach the dataset, so there is nothing personal in it to disclose, and the 100,000 daily data points go to the traffic you want to study.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;verifiedBotCategory&lt;/code&gt; is Cloudflare's check, the user agent is the bot's claim.&lt;/strong&gt; Anyone can send &lt;code&gt;GPTBot&lt;/code&gt; in a header. Cloudflare fills &lt;code&gt;request.cf.verifiedBotCategory&lt;/code&gt; only for bots it has verified. Write both and compare.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;writeDataPoint&lt;/code&gt; is not awaited.&lt;/strong&gt; It returns at once and the runtime stores the point in the background, the same as in &lt;a href="https://301.sh/count-clicks-on-a-redirect/" rel="noopener noreferrer"&gt;counting clicks on a redirect&lt;/a&gt;, which also covers the read side: one SQL query, and &lt;code&gt;SUM(_sample_interval)&lt;/code&gt; rather than &lt;code&gt;COUNT()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Which bots get a 404:&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;SELECT&lt;/span&gt; &lt;span class="n"&gt;index1&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;blob1&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;blob2&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_sample_interval&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bot_hits&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;blob3&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'404'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="nb"&gt;timestamp&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'7'&lt;/span&gt; &lt;span class="k"&gt;DAY&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your site has no Worker today, adding one only for this brings back the quota question. You can still narrow it: a route on the page paths rather than &lt;code&gt;/*&lt;/code&gt; leaves images and styles alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this breaks
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The regex decides what a bot is.&lt;/strong&gt; A crawler that sends a browser user agent is not in your dataset unless Cloudflare verified it. Every tool that reads the user agent has the same limit, the paid ones included.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Analytics Engine keeps data for three months.&lt;/strong&gt; For a year of history, export it on a schedule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The API numbers are estimates.&lt;/strong&gt; Cloudflare samples high volume and "returns an estimate derived from the sampled value" (&lt;a href="https://developers.cloudflare.com/analytics/graphql-api/sampling/" rel="noopener noreferrer"&gt;sampling&lt;/a&gt;). Our week came back with an average sample interval of about 2.5. Good for which bots and which paths; not for exact counts of a rare event.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Thirty one days is all the API keeps on Free.&lt;/strong&gt; Save a weekly result if you want a trend.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Web Bot Auth headers are rare today.&lt;/strong&gt; Recording &lt;code&gt;signature-agent&lt;/code&gt; costs nothing, and the column will fill as more agents sign their requests. Do not read an empty column as proof that no agents visit.&lt;/p&gt;

&lt;h2&gt;
  
  
  When 301.st is worth it
&lt;/h2&gt;

&lt;p&gt;For one site you do not need us. Run the query above once a week, and if you want a longer history, add the log to your Worker.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://301.st" rel="noopener noreferrer"&gt;301.st&lt;/a&gt; manages redirects and traffic rules across a portfolio of domains. On the domains where you give it rules, it runs a Worker in your Cloudflare account, so this is the same trade described above, taken for routing the traffic rather than only watching it. That Worker sorts each request into seven bot categories, from search engines and AI crawlers to link previews and uptime monitors, using the user agent and Cloudflare's verified bot flag. A rule checks those categories before any other rule on the domain: AI Guard blocks or diverts AI training crawlers and leaves search engines alone, and Bot Shield stops every bot on a domain that should never be crawled.&lt;/p&gt;

&lt;p&gt;A screen that shows those bots per domain, with paths and response codes, is what we are building next.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>cloudflare</category>
      <category>limits</category>
      <category>webdev</category>
    </item>
    <item>
      <title>What Cloudflare's free plan alerts you about, and what you check yourself</title>
      <dc:creator>301ST</dc:creator>
      <pubDate>Wed, 23 Sep 2026 14:00:18 +0000</pubDate>
      <link>https://dev.to/301st/what-cloudflares-free-plan-alerts-you-about-and-what-you-check-yourself-1ed8</link>
      <guid>https://dev.to/301st/what-cloudflares-free-plan-alerts-you-about-and-what-you-check-yourself-1ed8</guid>
      <description>&lt;p&gt;You have a few dozen domains on Cloudflare's Free plan, and most of them only redirect. You want to hear about a broken one before a customer does. Cloudflare can tell you some of that by email, for free, once you switch it on. It will not tell you that a redirect sends people to the wrong place, that someone changed a DNS record, or that a domain at another registrar is about to lapse. That holds on every plan, Enterprise included.&lt;/p&gt;

&lt;p&gt;This article lists what the Free plan watches, what paying adds and for how much, and gives you a Worker that checks the redirects themselves. If you are still choosing how to redirect, start with &lt;a href="https://301.sh/every-way-to-redirect-on-cloudflare/" rel="noopener noreferrer"&gt;every way to redirect on Cloudflare&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Cloudflare watches, by plan
&lt;/h2&gt;

&lt;p&gt;Checked on 23 September 2026 against Cloudflare's documentation and against the notification API of our own account.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What breaks&lt;/th&gt;
&lt;th&gt;Free plan&lt;/th&gt;
&lt;th&gt;What lifts it&lt;/th&gt;
&lt;th&gt;Left uncovered&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Origin unreachable&lt;/td&gt;
&lt;td&gt;Passive Origin Monitoring, email; stayed silent in our test&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;low traffic domains, and &lt;code&gt;522&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Origin errors, &lt;code&gt;522&lt;/code&gt; included&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;td&gt;Origin Error Rate Alert, Enterprise only&lt;/td&gt;
&lt;td&gt;everything below Enterprise&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Active checks of a URL&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;td&gt;Health Checks: 10 on Pro ($20 a month billed yearly), 50 on Business ($200)&lt;/td&gt;
&lt;td&gt;anything past 10 or 50 URLs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edge certificate (Universal SSL)&lt;/td&gt;
&lt;td&gt;Universal SSL Alert, email&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Origin CA certificate expiry&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;td&gt;every plan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Domain at Cloudflare Registrar expiring&lt;/td&gt;
&lt;td&gt;monthly email to the Super Administrator&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Domain at another registrar expiring&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;td&gt;every plan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DNS record or nameserver change&lt;/td&gt;
&lt;td&gt;Audit Logs, no alert&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;td&gt;every plan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redirect sends people to the wrong place&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;td&gt;every plan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Where alerts can go&lt;/td&gt;
&lt;td&gt;email&lt;/td&gt;
&lt;td&gt;webhooks with a paid subscription, PagerDuty from Business&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two rows need a note.&lt;/p&gt;

&lt;p&gt;Passive Origin Monitoring is missing from the current list of available notifications, last updated 24 April 2026. The API still offers it: on our account it is listed as &lt;code&gt;real_origin_monitoring&lt;/code&gt;, "Cloudflare is unable to reach your origin". Cloudflare's announcement from 2019 says it "is available to customers on all Cloudflare plans".&lt;/p&gt;

&lt;p&gt;The webhook page says the feature "is only available if your account has at least one zone with a pro plan or above." Our account has two Free zones and a Workers Paid subscription, which starts at $5 a month, and the API reports webhooks as eligible. The getting started page has the rule that matches what we see: "Accounts with a paid service will additionally have access to webhooks."&lt;/p&gt;

&lt;h2&gt;
  
  
  Nothing is switched on until you add it
&lt;/h2&gt;

&lt;p&gt;On 23 September our account had been serving this site since July. It held one notification policy: a billing budget alert that Cloudflare created by itself. Neither of the two free alerts above was there.&lt;/p&gt;

&lt;p&gt;Add them under &lt;strong&gt;Notifications&lt;/strong&gt;, then &lt;strong&gt;Add&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Passive Origin Monitoring.&lt;/strong&gt; One policy covers every zone in the account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Universal SSL Alert.&lt;/strong&gt; Validation, issuance, renewal and expiration of the certificate visitors get.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both go to an email address you type in. Pick one that somebody reads. A notification sent to a former colleague's inbox is the same failure as the renewal email in &lt;a href="https://301.sh/auto-renew-on-domain-still-expired/" rel="noopener noreferrer"&gt;auto-renew was on and the domain expired anyway&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  We broke a hostname twice and the free alert said nothing
&lt;/h2&gt;

&lt;p&gt;Cloudflare describes the trigger of Passive Origin Monitoring in one place only, its post of July&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The notification:&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;tells you if every request to your origin is returning a 521 error (web server down) for a full five minutes&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A &lt;code&gt;521&lt;/code&gt; means the server refused the connection. A domain set up the way the &lt;a href="https://301.sh/redirect-200-parked-domains/" rel="noopener noreferrer"&gt;portfolio runbook&lt;/a&gt; recommends has no server to refuse anything: a proxied &lt;code&gt;A&lt;/code&gt; record points at &lt;code&gt;192.0.2.1&lt;/code&gt;, an address nobody answers on, and the redirect runs at the edge. When someone deletes or narrows the redirect rule, Cloudflare forwards the request to &lt;code&gt;192.0.2.1&lt;/code&gt;, waits, and gives the visitor a &lt;a href="https://301.sh/redirect-silently-fails/" rel="noopener noreferrer"&gt;&lt;code&gt;522&lt;/code&gt;&lt;/a&gt;, a timeout.&lt;/p&gt;

&lt;p&gt;So we tested both on 23 September, with the notification switched on in our account.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A proxied hostname pointing at &lt;code&gt;192.0.2.1&lt;/code&gt; with no redirect rule. From 08:24 to 08:44 UTC we requested it every 15 seconds. All 35 responses were &lt;code&gt;522&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The same hostname pointing at a server that refuses connections on port 443, the port Cloudflare uses in our Full SSL mode. From 08:46 to 09:06 UTC, the same schedule. All 78 responses were &lt;code&gt;521&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The account's notification history stayed empty through both runs and until 09:24 UTC, when we stopped looking. No email arrived at the address on the policy. The second run is the case the 2021 post describes, and it did not fire either. The documentation names no minimum amount of traffic, but a request every 15 seconds was not enough.&lt;/p&gt;

&lt;p&gt;A parked domain that only redirects gets less than that. Turn Passive Origin Monitoring on, since it costs nothing, and do not read its silence as proof that anything works. The one notification documented to count &lt;code&gt;522&lt;/code&gt; is the Origin Error Rate Alert, sold on Enterprise only.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check the redirects yourself on a cron Worker
&lt;/h2&gt;

&lt;p&gt;What no plan offers is a request to your domain and a look at where the answer points. A Worker on a schedule can do that, and a Worker with no route of its own runs on &lt;code&gt;workers.dev&lt;/code&gt;, so it needs no domain.&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="c1"&gt;// Each row: the URL, the status you expect, the Location you expect.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CHECKS&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://old-brand.com/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;301&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://brand.com/&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://www.old-brand.com/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;301&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://brand.com/&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://promo-brand.net/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;302&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://brand.com/spring/&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;failures&lt;/span&gt; &lt;span class="o"&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;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;CHECKS&lt;/span&gt;&lt;span class="p"&gt;)&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;manual&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;got&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;location&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;got&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&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;url&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; → &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&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;got&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;(no Location)&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="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="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&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;url&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; → &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;failures&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="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;scheduled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;failures&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;probe&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;failures&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;failures&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ALERT_WEBHOOK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="c1"&gt;// Slack reads `text`, Discord reads `content`.&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;text&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="py"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"redirect-check"&lt;/span&gt;
&lt;span class="py"&gt;main&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"index.js"&lt;/span&gt;
&lt;span class="py"&gt;compatibility_date&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"2026-09-01"&lt;/span&gt;

&lt;span class="nn"&gt;[triggers]&lt;/span&gt;
&lt;span class="py"&gt;crons&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"*/30 * * * *"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store the webhook address as a secret with &lt;code&gt;wrangler secret put ALERT_WEBHOOK&lt;/code&gt;, so it stays out of the code. &lt;code&gt;redirect: 'manual'&lt;/code&gt; is the important part: the Worker gets the first answer and does not follow it, so you compare exactly what a visitor's browser receives.&lt;/p&gt;

&lt;p&gt;We ran the &lt;code&gt;probe()&lt;/code&gt; function from &lt;code&gt;workers.dev&lt;/code&gt; on 23 September against our own domains, which sit on the same Cloudflare account as the Worker. The correct redirects passed without a message. A row where we deliberately expected the wrong target was reported. A domain that does not exist came back as a &lt;code&gt;530&lt;/code&gt; response rather than an error, and the status check caught it just the same.&lt;/p&gt;

&lt;p&gt;On the Free plan two limits decide how far this goes. A Worker invocation may make 50 subrequests, so one run checks 49 URLs and keeps one request for the alert. An account may have 5 cron triggers in total. Our account runs nine, seven of them for the workers that distribute this blog, so we were past the Free limit before monitoring came up. Workers Paid raises the numbers to 10,000 subrequests and 250 cron triggers, from $5 a month.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this breaks
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The checker lives inside Cloudflare.&lt;/strong&gt; When Cloudflare has a bad day, the Worker and the notifications have it too. From 19 June 11:35 to 23 June 02:50 UTC this year, some security and DDoS alerts were not delivered, as a &lt;a href="https://community.cloudflare.com/t/935405" rel="noopener noreferrer"&gt;community thread&lt;/a&gt; and the status page recorded. A monitor outside Cloudflare, such as UptimeRobot, sees what visitors see, a &lt;code&gt;522&lt;/code&gt; included.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Notifications belong to the account, not the domain.&lt;/strong&gt; You cannot mute one zone in an account wide policy, and a question asking how to do that has sat without a reply &lt;a href="https://community.cloudflare.com/t/935877" rel="noopener noreferrer"&gt;since June&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DNS changes leave a record and nothing else.&lt;/strong&gt; Audit Logs are available on every plan and kept for 18 months, but no notification reads them. In January someone found their nameservers changed after the zone was added to a second account, and learned about it &lt;a href="https://community.cloudflare.com/t/879850" rel="noopener noreferrer"&gt;when the site went down&lt;/a&gt;. Polling the Audit Logs API, or resolving each domain's NS records on a schedule, is the only way to hear about it sooner.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Origin CA certificates expire in silence.&lt;/strong&gt; Cloudflare says so directly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Cloudflare does not currently send expiration notifications for origin CA certificates.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://301.sh/200-day-certificates-on-a-domain-portfolio/" rel="noopener noreferrer"&gt;Certificates now expire in 200 days&lt;/a&gt; has a command that reads the one on your server.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Domains at other registrars are invisible.&lt;/strong&gt; Cloudflare Registrar sends a monthly email listing domains "expiring in the next 60-90 days". A domain registered anywhere else gets nothing from Cloudflare.&lt;/p&gt;

&lt;h2&gt;
  
  
  When 301.st is worth it
&lt;/h2&gt;

&lt;p&gt;For a handful of domains you do not need us. Switch on the two alerts, deploy the Worker with your list, and read the email.&lt;/p&gt;

&lt;p&gt;With hundreds of domains, the gap that hurts most is the one nothing on Cloudflare covers: domains whose nameservers no longer point where the redirect rules live. &lt;a href="https://301.st" rel="noopener noreferrer"&gt;301.st&lt;/a&gt; resolves the NS records of the domains it manages on a loop, each domain at least twice a day, and compares them with the ones Cloudflare assigned. It rereads each zone's status from Cloudflare on a rolling schedule. When a domain with active rules stops getting traffic through them, because the nameservers moved or the zone left the account, it emails you the list.&lt;/p&gt;

&lt;p&gt;The rest shows on one screen for the whole portfolio rather than as alerts: the VirusTotal verdict for each domain, using your own API key; Cloudflare's phishing block; a daily count of redirect responses per host that flags a drop of half, nine tenths, or to zero; and the registration expiry read from RDAP, whichever registrar holds the domain.&lt;/p&gt;

&lt;p&gt;It does not request each redirect and compare the target with what you expected. That is the Worker above, and it is worth running next to 301.st.&lt;/p&gt;

</description>
      <category>redirects</category>
      <category>cloudflare</category>
      <category>dns</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The .com price rises on 1 November. Lock today's for up to ten years</title>
      <dc:creator>301ST</dc:creator>
      <pubDate>Mon, 21 Sep 2026 14:00:18 +0000</pubDate>
      <link>https://dev.to/301st/the-com-price-rises-on-1-november-lock-todays-for-up-to-ten-years-2epn</link>
      <guid>https://dev.to/301st/the-com-price-rises-on-1-november-lock-todays-for-up-to-ten-years-2epn</guid>
      <description>&lt;p&gt;On 1 November every .com renewal gets more expensive. Verisign, which runs the .com registry, announced it with its quarterly results on 23 April:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Verisign announces that it will increase the annual registry-level wholesale fee for each new and renewal .com domain name registration from $10.26 to $10.97 effective Nov. 1, 2026.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is 71 cents per domain per year, and it is the first of up to four such steps. The same registry contract that allows the steps also gives every domain owner a way around them. Section 7.3(f) of the .com Registry Agreement:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Registry Operator shall provide no less than six months prior notice in advance of any increase for new and renewal domain name registrations and for transferring a domain name registration from one ICANN-accredited registrar to another and shall continue to offer for periods of up to ten years new and renewal domain name registrations fixed at the price in effect at the time such offer is accepted.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Until 1 November, a renewal of up to ten years is charged at today's wholesale price, at any registrar. The rest of this article is about which of your domains to do that for, how many years each one can take, and what it costs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the contract allows from here
&lt;/h2&gt;

&lt;p&gt;The price can move once per Pricing Year, which the agreement defines as 26 October to 25 October, and only in the last four Pricing Years of each six year period. The first period started on 26 October 2018, so the current one runs to October 2030 and its four increase years start in October 2026, 2027, 2028 and 2029. Each step is capped at 7 percent. In the previous period Verisign took all four, the last one bringing the price to $10.26 in September 2024.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;From&lt;/th&gt;
&lt;th&gt;Wholesale&lt;/th&gt;
&lt;th&gt;At Cloudflare Registrar&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;today&lt;/td&gt;
&lt;td&gt;$10.26&lt;/td&gt;
&lt;td&gt;$10.46&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 November 2026&lt;/td&gt;
&lt;td&gt;$10.97&lt;/td&gt;
&lt;td&gt;$11.17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;26 October 2027&lt;/td&gt;
&lt;td&gt;up to about $11.7&lt;/td&gt;
&lt;td&gt;about $11.9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;26 October 2028&lt;/td&gt;
&lt;td&gt;up to about $12.6&lt;/td&gt;
&lt;td&gt;about $12.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;26 October 2029&lt;/td&gt;
&lt;td&gt;up to about $13.4&lt;/td&gt;
&lt;td&gt;about $13.6&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rows after November are ceilings, not announcements; each one needs its own six months' notice. The Cloudflare column is the wholesale price plus ICANN's fee of $0.20 per domain year, which has applied since 1 July 2025. Cloudflare adds nothing on top, and its public domain search showed .com at $10.46 on 19 September 2026. If you see $10.44 quoted somewhere, it was computed with the old fee.&lt;/p&gt;

&lt;h2&gt;
  
  
  The date that sets your price is the renewal date
&lt;/h2&gt;

&lt;p&gt;A domain does not renew on its expiry date. Cloudflare's documentation, updated 24 April 2026:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The first auto-renew attempt will occur approximately 30 days prior to expiration.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So a .com expiring on 15 November renews in mid October, at $10.46. One expiring on 15 December renews in mid November, at $11.17. Auto-renew keeps a domain registered; it does not lock a price for the years ahead. A manual renewal for several years does. Auto-renew has &lt;a href="https://301.sh/auto-renew-on-domain-still-expired/" rel="noopener noreferrer"&gt;failure modes of its own&lt;/a&gt; too.&lt;/p&gt;

&lt;h2&gt;
  
  
  How many years each domain can take
&lt;/h2&gt;

&lt;p&gt;The registry caps a registration at ten years from today:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Renewal registrations of Registered Names (where available according to functional specifications and other requirements) may be made in the registry for terms not to exceed a total of ten years.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A domain that expires next summer can take nine more years. One already paid up to 2033 can take three. Verisign publishes the expiry date of every .com over RDAP, so the headroom for a whole list is one loop. The script needs &lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;jq&lt;/code&gt; and GNU &lt;code&gt;date&lt;/code&gt;; on macOS use &lt;code&gt;date -j -f '%Y-%m-%dT%H:%M:%SZ'&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-uo&lt;/span&gt; pipefail

&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%-26s %-30s %-11s %s\n'&lt;/span&gt; DOMAIN REGISTRAR EXPIRES CAN_ADD

&lt;span class="nv"&gt;now&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; domain&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$domain&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;continue

  &lt;/span&gt;&lt;span class="nv"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://rdap.verisign.com/com/v1/domain/&lt;/span&gt;&lt;span class="nv"&gt;$domain&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;expires&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.events[]? | select(.eventAction=="expiration") | .eventDate'&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$json&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$expires&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%-26s %s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$domain&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"not registered"&lt;/span&gt;
    &lt;span class="k"&gt;continue
  fi
  &lt;/span&gt;&lt;span class="nv"&gt;registrar&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.entities[]? | select(.roles | index("registrar"))
                     | .vcardArray[1][] | select(.[0]=="fn") | .[3]'&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$json&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

  &lt;span class="c"&gt;# The registry caps a registration at ten years from today.&lt;/span&gt;
  &lt;span class="nv"&gt;left&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$expires&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; now&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;86400&lt;/span&gt; &lt;span class="k"&gt;))&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$left&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nv"&gt;can_add&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"expired"&lt;/span&gt;
  &lt;span class="k"&gt;else
    &lt;/span&gt;&lt;span class="nv"&gt;can_add&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="m"&gt;3652&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; left&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;365&lt;/span&gt; &lt;span class="k"&gt;))&lt;/span&gt;
  &lt;span class="k"&gt;fi

  &lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%-26s %-30.30s %-11s %s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$domain&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$registrar&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;expires&lt;/span&gt;&lt;span class="p"&gt;%%T*&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$can_add&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;sleep &lt;/span&gt;0.5
&lt;span class="k"&gt;done&lt;/span&gt; &amp;lt; domains.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A run on 19 September 2026 against some well known names:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DOMAIN                     REGISTRAR                      EXPIRES     CAN_ADD
example.com                RESERVED-Internet Assigned Num 2027-08-13  9
cloudflare.com             Cloudflare, Inc.               2033-02-17  3
google.com                 MarkMonitor Inc.               2028-09-14  8
verisign.com               CSC Corporate Domains, Inc.    2034-06-01  2
namecheap.com              NameCheap, Inc.                2027-08-11  9
godaddy.com                GoDaddy.com, LLC               2032-11-01  3
stripe.com                 SafeNames Ltd.                 2027-09-11  9
github.com                 MarkMonitor Inc.               2028-10-09  7
this-domain-does-not-exist-9f3.com not registered
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The endpoint serves .com only. The &lt;code&gt;REGISTRAR&lt;/code&gt; column matters as much as the count: it tells you where each renewal has to be clicked.&lt;/p&gt;

&lt;h2&gt;
  
  
  The arithmetic for one domain
&lt;/h2&gt;

&lt;p&gt;Take a .com that expires in August 2027, so it can take nine years.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Renewed now at Cloudflare, nine years cost 9 × $10.46 = $94.14, paid today.&lt;/li&gt;
&lt;li&gt;Renewed year by year, if Verisign stops after November: 9 × $11.17 = $100.53.&lt;/li&gt;
&lt;li&gt;Renewed year by year, if Verisign takes every step at the ceilings above and none after 2030: about $118.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The saving is roughly $6 to $23 per domain over nine years, and more if the pattern repeats after 2030, which the contract allows. Against that, the $94.14 leaves your account today, and it does not come back:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;All renewals are final and Cloudflare will not issue refunds.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Which domains to lock
&lt;/h2&gt;

&lt;p&gt;A domain you will keep no matter what, the name of a business or a site with traffic, should take every year it can. The saving is certain and the money would have been spent anyway.&lt;/p&gt;

&lt;p&gt;A domain that only redirects is a judgement you can make with numbers. The &lt;a href="https://301.sh/audit-300-domains-one-script/" rel="noopener noreferrer"&gt;portfolio audit&lt;/a&gt; sorts domains by whether they still answer and how much authority they carry. The ones it marks as worth fixing are worth locking. The ones it marks to let lapse are the trap here: nine prepaid years on a domain you drop after two leave seven of them, $73.22, paid for nothing.&lt;/p&gt;

&lt;p&gt;A domain with two or three years of headroom left gains little either way, because most of its term is already paid at an older price.&lt;/p&gt;

&lt;h2&gt;
  
  
  Doing it at Cloudflare
&lt;/h2&gt;

&lt;p&gt;In the dashboard: &lt;strong&gt;Manage domains&lt;/strong&gt;, the domain's &lt;strong&gt;Manage&lt;/strong&gt;, then &lt;strong&gt;Renew/Extend Domain&lt;/strong&gt; under &lt;strong&gt;Registration&lt;/strong&gt;. The &lt;strong&gt;Renew for&lt;/strong&gt; menu goes up to 10 years. The added years land after the current expiry, not after today:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;When renewing a domain, additional years are always added to the current expiration date regardless of when the renewal takes place.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There is no bulk path through the API. The Registrar API searches, checks, registers and lists domains, and its one call that changes an existing registration says so plainly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This endpoint currently supports updating &lt;code&gt;auto_renew&lt;/code&gt; only.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For a portfolio that means the dashboard, and the documented path renews one domain at a time. Start well before 31 October. The contract fixes the price in effect when the registry accepts the order, and a renewal sits in &lt;strong&gt;Renewal Pending&lt;/strong&gt; until it completes.&lt;/p&gt;

&lt;p&gt;At other registrars the same contract applies, but the price you pay is theirs, including their markup, and what they charge after 1 November is their decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  When 301.st helps, and where it stops
&lt;/h2&gt;

&lt;p&gt;For a handful of .com names in one Cloudflare account you do not need anything else: run the script, open the dashboard, and it is ten minutes of work.&lt;/p&gt;

&lt;p&gt;A portfolio spread across several registrars starts with a different question: which domains expire when, and where each one is registered. &lt;a href="https://301.st" rel="noopener noreferrer"&gt;301.st&lt;/a&gt; fills in the expiry date of every domain it manages from RDAP, and from whois for .ru, whatever the registrar, and sorts the list by it. That list is the order to work through before 31 October. It does not renew anything, and through Cloudflare's API nothing can. The clicks at each registrar are still yours.&lt;/p&gt;

</description>
      <category>dns</category>
      <category>cloudflare</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Certificates now expire in 200 days. Find the domains you still renew by hand</title>
      <dc:creator>301ST</dc:creator>
      <pubDate>Sun, 20 Sep 2026 14:00:18 +0000</pubDate>
      <link>https://dev.to/301st/certificates-now-expire-in-200-days-find-the-domains-you-still-renew-by-hand-29b2</link>
      <guid>https://dev.to/301st/certificates-now-expire-in-200-days-find-the-domains-you-still-renew-by-hand-29b2</guid>
      <description>&lt;p&gt;If you bought a one year certificate for a domain this spring, the certificate you installed lasts 199 days, and the reissue for the rest of the year is yours to install. The rules for publicly trusted certificates changed on 15 March 2026, and DigiCert and Sectigo moved before that date: DigiCert has issued nothing longer than 199 days since 24 February, Sectigo since 12 March. A DigiCert certificate issued on 24 February ran out at the end of 10 September. One issued on 15 March for the full 200 days runs out at the end of September.&lt;/p&gt;

&lt;p&gt;The 200 days are the first step of a schedule written into the Baseline Requirements, the rules every publicly trusted certificate authority follows. Version 2.3.0, dated 7 September 2026:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Certificate issued&lt;/th&gt;
&lt;th&gt;Longest allowed&lt;/th&gt;
&lt;th&gt;Domain check reusable for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;before 15 March 2026&lt;/td&gt;
&lt;td&gt;398 days&lt;/td&gt;
&lt;td&gt;398 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;from 15 March 2026&lt;/td&gt;
&lt;td&gt;200 days&lt;/td&gt;
&lt;td&gt;200 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;from 15 March 2027&lt;/td&gt;
&lt;td&gt;100 days&lt;/td&gt;
&lt;td&gt;100 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;from 15 March 2029&lt;/td&gt;
&lt;td&gt;47 days&lt;/td&gt;
&lt;td&gt;10 days&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The authorities are not waiting for the dates. DigiCert's own table puts its next step, 99 days, in early 2027. Let's Encrypt runs further ahead: from 10 February 2027 its default certificates last 64 days, from 16 February 2028 they last 45, and the time a domain check stays valid drops to 10 days and then to 7 hours.&lt;/p&gt;

&lt;p&gt;The right column is the one that hurts a manual process. A certificate ordered by hand starts with proving control of the domain, by a DNS record, a file or an email, unless the last proof is recent enough. From March 2029 recent enough means ten days, so for a 47 day certificate the proof is part of every order. A domain whose certificate a person renews needs that person about twice a year now, about four times a year from 2027 and about eight times from 2029.&lt;/p&gt;

&lt;p&gt;On a domain that only redirects, nobody has to do this at all. The rest of this article finds the domains where somebody still does. If you are still choosing how to redirect in the first place, &lt;a href="https://301.sh/every-way-to-redirect-on-cloudflare/" rel="noopener noreferrer"&gt;every way to redirect on Cloudflare&lt;/a&gt; compares the options.&lt;/p&gt;

&lt;h2&gt;
  
  
  A redirect domain should have no certificate you renew
&lt;/h2&gt;

&lt;p&gt;A parked domain, or one that only forwards, needs a certificate for a single reason: a visitor who types &lt;code&gt;https://&lt;/code&gt; has to finish the TLS handshake before your &lt;code&gt;301&lt;/code&gt; can be sent. Nothing else on that domain uses it. Put the record behind Cloudflare's proxy and the certificate becomes Cloudflare's job. The documentation, updated 16 April 2026:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Universal certificates have a 90-day validity period. The auto renewal period starts 30 days before expiration.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Universal SSL is included on every plan, Free too. Its 90 days are already inside the 2026 and 2027 caps. They are longer than the 47 days of 2029, and closing that gap is not your job either:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;For Universal certificates, Cloudflare controls the validity periods and certificate authorities (CAs), making sure that renewal always occur.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The setup that removes the question entirely is the one from the &lt;a href="https://301.sh/redirect-200-parked-domains/" rel="noopener noreferrer"&gt;portfolio runbook&lt;/a&gt;: a proxied &lt;code&gt;A&lt;/code&gt; record pointing at &lt;code&gt;192.0.2.1&lt;/code&gt;, an address reserved for documentation that no server answers on, and a redirect rule at the edge. There is no server behind the domain, so there is no server certificate. The only certificate in the path is the one Cloudflare renews.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sort your domains by who renews them
&lt;/h2&gt;

&lt;p&gt;You do not need an inventory to find the domains that do not fit that setup. The certificate each domain serves carries its own start and end dates, and the distance between them tells you a lot about how it got there. The script below needs only &lt;code&gt;openssl&lt;/code&gt; and GNU &lt;code&gt;date&lt;/code&gt;, the same pair the &lt;a href="https://301.sh/audit-300-domains-one-script/" rel="noopener noreferrer"&gt;audit script&lt;/a&gt; uses for its certificate column. On macOS use &lt;code&gt;date -j -f '%b %d %T %Y %Z'&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-uo&lt;/span&gt; pipefail

&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%-34s %-24s %-9s %s\n'&lt;/span&gt; DOMAIN ISSUER LIFETIME LEFT

&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; domain&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$domain&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;continue

  &lt;/span&gt;&lt;span class="nv"&gt;cert&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; | openssl s_client &lt;span class="nt"&gt;-servername&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$domain&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-connect&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$domain&lt;/span&gt;&lt;span class="s2"&gt;:443"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
         | openssl x509 &lt;span class="nt"&gt;-noout&lt;/span&gt; &lt;span class="nt"&gt;-issuer&lt;/span&gt; &lt;span class="nt"&gt;-startdate&lt;/span&gt; &lt;span class="nt"&gt;-enddate&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cert&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%-34s %s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$domain&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"no certificate"&lt;/span&gt;
    &lt;span class="k"&gt;continue
  fi

  &lt;/span&gt;&lt;span class="nv"&gt;issuer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cert&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'s/^issuer=.*O *= *\([^,]*\).*/\1/p'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;from&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cert&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'s/^notBefore=//p'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;until&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cert&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'s/^notAfter=//p'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;

  &lt;span class="nv"&gt;life&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;until&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; from &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="m"&gt;43200&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;86400&lt;/span&gt; &lt;span class="k"&gt;))&lt;/span&gt;
  &lt;span class="nv"&gt;left&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;until&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;86400&lt;/span&gt; &lt;span class="k"&gt;))&lt;/span&gt;

  &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%-34s %-24.24s %-9s %s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$domain&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;issuer&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="p"&gt;-&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;life&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;d"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;left&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;d"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt; &amp;lt; domains.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lifetime is rounded to the nearest day, because authorities pad both ends by hours. A run on 19 September 2026 against this site, our Russian host, and a few large sites for contrast:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DOMAIN                             ISSUER                   LIFETIME  LEFT
301.sh                             Google Trust Services    90d       89d
www.301.sh                         Google Trust Services    90d       89d
ru.301.sh                          Let's Encrypt            90d       72d
www.paypal.com                     DigiCert Inc             199d      176d
www.salesforce.com                 DigiCert Inc             199d      13d
www.zoom.us                        DigiCert Inc             366d      101d
www.adobe.com                      DigiCert Inc             365d      107d
this-domain-does-not-exist-9f3.com no certificate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;LIFETIME&lt;/code&gt; column is the one to sort by.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;90 days or less.&lt;/strong&gt; A machine issued this and a machine will replace it. Behind Cloudflare's proxy you should see 90 days from Google Trust Services, Let's Encrypt or SSL.com; &lt;code&gt;301.sh&lt;/code&gt; was reissued a few hours before the run. A short lifetime does not tell you whose machine it is. &lt;code&gt;ru.301.sh&lt;/code&gt; is not behind Cloudflare at all: it is served by GitHub Pages, and GitHub renews its Let's Encrypt certificate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;199 or 200 days.&lt;/strong&gt; The longest certificate a commercial authority issues under the new rules, the kind that comes with an order. The &lt;code&gt;www.salesforce.com&lt;/code&gt; certificate was issued on 18 March and has 13 days left. Whoever renews it will do it again within 200 days, and from 15 March 2027 within 100.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;365 days or more.&lt;/strong&gt; Issued before 15 March 2026, and among the last of their kind. The next certificate for that domain lasts 200 days at most, or 100 if it is ordered after 15 March 2027. On these domains the renewal date moves closer and nothing announces it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No certificate.&lt;/strong&gt; The handshake never happened: the name does not resolve, or nothing answers on port 443. Visitors typing &lt;code&gt;https://&lt;/code&gt; get an error before any redirect can run.&lt;/p&gt;

&lt;p&gt;On a portfolio where every domain is meant to sit behind Cloudflare, a lifetime over 90 days means Cloudflare is not the one serving that domain. On Business and Enterprise it can be a custom certificate you uploaded, which Cloudflare does not renew either. Everywhere else it is a record that was never proxied, or a domain whose nameservers never moved.&lt;/p&gt;

&lt;h2&gt;
  
  
  The certificate the script does not see
&lt;/h2&gt;

&lt;p&gt;Behind the proxy, the script reads Cloudflare's certificate, the one visitors get. When Cloudflare forwards the request to your server, there is a second certificate on that connection. In SSL/TLS mode Full (Strict), Cloudflare checks it, and on the day it expires visitors get a &lt;a href="https://301.sh/redirect-silently-fails/" rel="noopener noreferrer"&gt;&lt;code&gt;526&lt;/code&gt;&lt;/a&gt; instead of your page or your redirect. No browser ever sees this certificate, so nothing warns you before that day.&lt;/p&gt;

&lt;p&gt;To read it, connect to the server's address directly and name the domain:&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;echo&lt;/span&gt; | openssl s_client &lt;span class="nt"&gt;-connect&lt;/span&gt; 203.0.113.10:443 &lt;span class="nt"&gt;-servername&lt;/span&gt; example.com 2&amp;gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
  | openssl x509 &lt;span class="nt"&gt;-noout&lt;/span&gt; &lt;span class="nt"&gt;-issuer&lt;/span&gt; &lt;span class="nt"&gt;-enddate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are three ways to take this certificate off your calendar.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Automate it on the server.&lt;/strong&gt; Any ACME client renews on its own. For the move to 64 days in February 2027, the advice from Let's Encrypt is one sentence: "you should verify that your automation is compatible with certificates that have shorter validity periods."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use a Cloudflare Origin CA certificate.&lt;/strong&gt; It is free on every plan and trusted by Cloudflare and nobody else, so the public schedule does not apply to it: the API offers validity from 7 days up to 5,475, which is 15 years. Cloudflare's own page names two conditions. The first:&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;Cloudflare does not currently send expiration notifications for origin CA certificates.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The second: it only works while the record stays proxied.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Site visitors may see untrusted certificate errors if you pause Cloudflare or disable proxying on subdomains that use Cloudflare origin CA certificates.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Write the expiry date down somewhere you will look, because Cloudflare will not remind you.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Take the server out of the path.&lt;/strong&gt; If the server exists only to send a redirect, move the redirect to Cloudflare and point the record at &lt;code&gt;192.0.2.1&lt;/code&gt; as above. The second certificate goes away with the server.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Where this breaks
&lt;/h2&gt;

&lt;p&gt;Universal SSL has conditions. On a portfolio, each of them is a domain somewhere in the list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The record is not proxied.&lt;/strong&gt; A record set to DNS only gets no certificate from Cloudflare:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Cloudflare can only serve an SSL/TLS certificate for a DNS record when you set the record's proxy status to Proxied. If you do not do this, the origin server your record points to will be responsible for supporting SSL/TLS connections.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;The zone is still pending.&lt;/strong&gt; Universal SSL is issued to domains added to and activated on Cloudflare. Until the nameservers change at the registrar there is no certificate at all, and adding many zones at once runs into &lt;a href="https://301.sh/api-token-cannot-add-more-zones/" rel="noopener noreferrer"&gt;a limit of its own&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The name is two levels deep.&lt;/strong&gt; On a full setup the certificate covers &lt;code&gt;example.com&lt;/code&gt; and &lt;code&gt;www.example.com&lt;/code&gt;, but not &lt;code&gt;www.shop.example.com&lt;/code&gt;. The fix is Advanced Certificate Manager with Total TLS, listed at $10 a month on Cloudflare's plans page on 19 September 2026.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloudflare will not certify the name.&lt;/strong&gt; From the limitations page:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Some domains are not eligible for Universal SSL if they contain words that conflict with trademarked domains.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A portfolio of names close to brands will find some of its domains here. Cloudflare's way out is a paid advanced certificate, or a custom one on Business and Enterprise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A CAA record elsewhere in the chain.&lt;/strong&gt; When you add CAA records to a zone with Universal SSL, Cloudflare adds the ones its authorities need. It cannot change the records of a zone you point to:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If your hostname CNAMEs to a domain whose zone has restrictive CAA records, those records take precedence — even if your own domain has no CAA records.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  When 301.st is worth it
&lt;/h2&gt;

&lt;p&gt;For a few domains you do not need us. Proxy the records, move the redirects to the edge, and run the script once a quarter. It takes a minute.&lt;/p&gt;

&lt;p&gt;With hundreds of domains the setup has to happen at the moment a domain is added. &lt;a href="https://301.st" rel="noopener noreferrer"&gt;301.st&lt;/a&gt; builds it for every domain it adds: the apex gets a proxied &lt;code&gt;A&lt;/code&gt; record at &lt;code&gt;192.0.2.1&lt;/code&gt; and the redirect runs at the edge, so those domains have no server certificate at all. It also reads the certificate state of every zone from Cloudflare each day and shows which domains have a certificate that is pending or failing, such as one stuck on validation because of a CAA record two hops away.&lt;/p&gt;

&lt;p&gt;It does not look at certificates installed on your own servers, and a record someone later switches to DNS only is not a certificate error in Cloudflare's eyes. The record shows up in the script's &lt;code&gt;LIFETIME&lt;/code&gt; column, the server certificate in the &lt;code&gt;openssl&lt;/code&gt; line from the previous section. Put both in a cron job.&lt;/p&gt;

</description>
      <category>tls</category>
      <category>redirects</category>
      <category>cloudflare</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The zone limit your API token hits and your Global API Key does not</title>
      <dc:creator>301ST</dc:creator>
      <pubDate>Wed, 02 Sep 2026 14:00:39 +0000</pubDate>
      <link>https://dev.to/301st/the-zone-limit-your-api-token-hits-and-your-global-api-key-does-not-293i</link>
      <guid>https://dev.to/301st/the-zone-limit-your-api-token-hits-and-your-global-api-key-does-not-293i</guid>
      <description>&lt;p&gt;Cloudflare's own guide for adding many sites by script ends with two limits. The page was last updated on 5 May 2026 and it says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;There are limitations on the number of domains you can add at a time - specifically, you can only sign up a maximum of 25 domains every 10 minutes.&lt;/p&gt;

&lt;p&gt;In addition, if you have over 50 domains and, of those domains, more are pending than active, you will be blocked from adding more. We recommend waiting until your pending sites have been activated before adding more.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The &lt;a href="https://301.sh/redirect-200-parked-domains/" rel="noopener noreferrer"&gt;portfolio runbook&lt;/a&gt; on this site reads the second one the way it is written: a ratio with a threshold. Under fifty domains there is nothing to plan around. That is also what we believed when 301.st started adding zones for the accounts it onboards, with a scoped API token, the credential Cloudflare tells you to use. This is what came back:&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;"success"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&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;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1118&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"You have exceeded the limit for adding zones. Please activate some zones."&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;"messages"&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;"result"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It came back on accounts with a handful of domains. On a fresh account it came back on the second one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule that fits the evidence
&lt;/h2&gt;

&lt;p&gt;The error is old and the community has been reporting it since before the guide had a number in it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;March 2019.&lt;/strong&gt; A Cloudflare staff member closes a thread about code 1118: there is a limit on the number of pending zones at any one time, and validating pending zones is what raises it. No threshold is named.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;September 2021.&lt;/strong&gt; A poster quotes the support article of the day, which had no threshold either: you cannot have more pending sites than active sites on the account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;December 2024.&lt;/strong&gt; Ten zones, three active, seven pending, blocked. The poster had read that the limit was fifty and asks why theirs is ten.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;January 2025.&lt;/strong&gt; A Free account with one zone. Adding the second returns 1118. The poster deletes everything and tries again: with no zones, one goes in; the next does not.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One rule fits every case, and it is the rule we see on the accounts we onboard: a new zone is refused once the account has more pending zones than active ones. The fifty domain threshold from the guide did not show up in any of them. Put plainly, you can add about as many zones as you already have active, and a new account with nothing active gets one.&lt;/p&gt;

&lt;p&gt;That alone changes the plan for a portfolio. It is not add fifty, activate, add fifty. It is add one, activate it, add two, activate them, add four. The ceiling doubles with each round and the first rounds are the slow ones, because activation waits on a nameserver change at the registrar and on Cloudflare noticing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that depends on who is asking
&lt;/h2&gt;

&lt;p&gt;Here is the piece we have not found written down anywhere.&lt;/p&gt;

&lt;p&gt;The same account, the same list of domains, the same request to the same endpoint. With an API token, scoped as Cloudflare recommends, the block lands as soon as pending outnumbers active. With the user's Global API Key, the batch goes in. The only limits we have run into with the key are the two documented ones: 25 domains per 10 minutes, and the ratio once the account is past fifty.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Request signed with&lt;/th&gt;
&lt;th&gt;A new zone is refused when&lt;/th&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;API token, user or account owned&lt;/td&gt;
&lt;td&gt;pending zones outnumber active ones, from the second domain on a fresh account&lt;/td&gt;
&lt;td&gt;observed on the accounts we onboard, matches every thread above&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Global API Key&lt;/td&gt;
&lt;td&gt;over 50 domains on the account and pending outnumber active&lt;/td&gt;
&lt;td&gt;the guide, and what we see with the key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Either&lt;/td&gt;
&lt;td&gt;more than 25 domains in 10 minutes&lt;/td&gt;
&lt;td&gt;the guide&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Our reading is that the key acts as the user, the same principal as the dashboard, while a token is automation, and the pending cap applied to automation is the stricter one. That is a reading, not a statement from Cloudflare. Treat the whole section as observed behaviour on accounts we run, and check it on yours before you build on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check your account in two requests
&lt;/h2&gt;

&lt;p&gt;Count what you have. The zones endpoint filters on status and reports the total without paging through the list:&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="k"&gt;for &lt;/span&gt;s &lt;span class="k"&gt;in &lt;/span&gt;active pending&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s: '&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.cloudflare.com/client/v4/zones?account.id=&lt;/span&gt;&lt;span class="nv"&gt;$ACCOUNT_ID&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;status=&lt;/span&gt;&lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;per_page=1"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$CF_API_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="s1"&gt;'.result_info.total_count'&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If pending is already at or above active, try adding one domain you own with the token:&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="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.cloudflare.com/client/v4/zones"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$CF_API_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;name&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$DOMAIN&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;account&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;id&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$ACCOUNT_ID&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;},&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;type&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;full&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="s1"&gt;'.success, .errors'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;false&lt;/code&gt; followed by code 1118 means your account is under the rule above, not under the one in the guide. We have not found a plan that lifts it; the staff answer from 2019 names activation as the only lever. From here there are three ways forward.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Activate what is pending.&lt;/strong&gt; Change the nameservers at the registrar, wait for the zone to go active, and the ceiling rises by one. This is the right answer and the slow one: hours per round, sometimes a day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add through the dashboard by hand.&lt;/strong&gt; One at a time, which is the work you were scripting away.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use the Global API Key for the adding step.&lt;/strong&gt; Which raises the question of where that key is allowed to live.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why the key must not leave your browser
&lt;/h2&gt;

&lt;p&gt;Cloudflare's page on the Global API Key lists what is wrong with it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Access to all Cloudflare resources&lt;/strong&gt; - Global API key has access to all of a user's resources. This makes it impossible to safely use Global API key to access non-production resources when a user also has access to production resources.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Full permissions&lt;/strong&gt; - Similarly, Global API key has the exact same permissions as the user, which means if the user can delete zones or change DNS records, so can the Global API key.&lt;/p&gt;

&lt;p&gt;For these reasons, Global API key is not recommended for new customers. Current customers using Global API key are encouraged to migrate and use API tokens instead.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A platform that stores your key holds everything you hold. 301.st does not store it: onboarding takes a scoped token, and the quick path that accepts a key uses it once to mint a scoped token and discards it. That is the right design, and it means the server side of 301.st cannot perform the one step where the key is what gets through.&lt;/p&gt;

&lt;p&gt;So the adding step moved to the one place where the key can stay yours: your browser. &lt;a href="https://chromewebstore.google.com/detail/gncbekdjakchefiiahjbjlbhhfijoikp" rel="noopener noreferrer"&gt;Cloudflare Tools&lt;/a&gt; is an extension we wrote in January 2026 for exactly this, and it is &lt;a href="https://github.com/investblog/cloudflare-tools" rel="noopener noreferrer"&gt;open source&lt;/a&gt;. You paste the key into the side panel. It is encrypted with AES-256-GCM under a random key that lives in session storage and is gone when the browser closes. Every request goes from your browser to api.cloudflare.com and nowhere else; no server of ours is in the path. Paste a domain list, run a preflight that sorts it into will create, exists, invalid and duplicate, and start. The batch runs at Cloudflare's pace, 25 per 10 minutes, backs off on a 429 with &lt;code&gt;Retry-After&lt;/code&gt;, and resumes after a browser restart. The same panel does check and export to CSV, bulk delete and bulk cache purge.&lt;/p&gt;

&lt;p&gt;It is our tool, not Cloudflare's, and it is not affiliated with or endorsed by Cloudflare.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed in 0.2.0
&lt;/h2&gt;

&lt;p&gt;Version 0.2.0 shipped on 1 September 2026. It is live on Firefox Add-ons and going through review on the Chrome Web Store and Edge Add-ons, so those two links carry the previous version until it clears.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tokens, not only the key.&lt;/strong&gt; User tokens (&lt;code&gt;cfut_&lt;/code&gt;) and account owned tokens (&lt;code&gt;cfat_&lt;/code&gt;) are accepted alongside the Global API Key, and the kind is detected from the pasted secret. When your account already has active zones to spend, a token with Zone, Zone, Edit on the account is enough and the key never has to be pasted anywhere. Add Cache Purge if you purge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Profiles.&lt;/strong&gt; Several accounts or credentials, switched from the header. A running batch stays on the profile it started under, whatever you switch to meanwhile.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The toolbar button opens the panel directly&lt;/strong&gt;, the UI follows the browser language in English and Russian, and the publisher news feed is opt in and off by default.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The next release, 0.3.0, closes the loop on the limit itself: when one account's pending count hits its ceiling, the rest of the list spills to the next profile. The limit is per account, so a portfolio spread over several accounts is the ceiling multiplied.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the extension stops
&lt;/h2&gt;

&lt;p&gt;It adds zones. It does not move them from pending to active, because that happens at the registrar, and it does not tell you next month which of them are still pending, which record went grey, or which domain was supposed to point where. Its job ends when the batch is in.&lt;/p&gt;

&lt;p&gt;Keeping the portfolio true after that is what &lt;a href="https://301.st" rel="noopener noreferrer"&gt;301.st&lt;/a&gt; does, and the &lt;a href="https://301.sh/redirect-200-parked-domains/" rel="noopener noreferrer"&gt;runbook&lt;/a&gt; lists the steps it takes over. For the batch in front of you today, install the extension and paste the list.&lt;/p&gt;

</description>
      <category>limits</category>
      <category>dns</category>
      <category>browser</category>
      <category>cloudflare</category>
    </item>
    <item>
      <title>Your redirect rule says Active and nothing happens</title>
      <dc:creator>301ST</dc:creator>
      <pubDate>Tue, 18 Aug 2026 14:00:57 +0000</pubDate>
      <link>https://dev.to/301st/your-redirect-rule-says-active-and-nothing-happens-46ec</link>
      <guid>https://dev.to/301st/your-redirect-rule-says-active-and-nothing-happens-46ec</guid>
      <description>&lt;p&gt;The rule is in the list. Its toggle says Active. You open the URL and the old page loads, exactly as before. Nothing in the dashboard suggests a problem, because nothing in the dashboard is wrong: Active means the rule was saved and is eligible to run. It says nothing about whether a request ever reached it.&lt;/p&gt;

&lt;p&gt;That gap is the whole diagnosis. Four questions close it, in this order, and each one is answered by a single request rather than by another look at the configuration. We ran every check in this article against a live free-plan zone on 17 August 2026, including the ones whose answer is "the rule does not fire" — those are the useful ones. If you are still choosing a method, the &lt;a href="https://301.sh/every-way-to-redirect-on-cloudflare/" rel="noopener noreferrer"&gt;comparison of every way to redirect on Cloudflare&lt;/a&gt; is the map; this is what to do when the method you picked appears to do nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question one: does the request reach your zone at all
&lt;/h2&gt;

&lt;p&gt;Every other explanation assumes Cloudflare saw the request. Check that first, and check it with a rule rather than with a header, because a rule is the thing whose absence you are diagnosing.&lt;/p&gt;

&lt;p&gt;Add a temporary redirect rule on a path that nothing on your site serves, matching exactly, and point it anywhere:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(http.request.uri.path eq "/zone-probe-9f3")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then ask for it:&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="nt"&gt;-sI&lt;/span&gt; &lt;span class="s2"&gt;"https://your-domain/zone-probe-9f3"&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;302&lt;/code&gt; means your zone terminated that request and your rules run. A &lt;code&gt;404&lt;/code&gt; from your own application, or anything else your origin would say, means they do not — and no amount of editing the redirect rule will change that. On our zone the probe went from &lt;code&gt;404&lt;/code&gt; before the rule to &lt;code&gt;302&lt;/code&gt; about three seconds after it was created, and back to &lt;code&gt;404&lt;/code&gt; after it was deleted, though the deletion took longer to reach every edge server than the creation did. A first response is not evidence in either direction; ask twice.&lt;/p&gt;

&lt;p&gt;If the probe stays negative, there are three ways to be outside your own zone, and they look identical from the dashboard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The record is not proxied.&lt;/strong&gt; Cloudflare states the requirement plainly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Single Redirects and Bulk Redirects require that you proxy the DNS records of your domain (or subdomain) through Cloudflare.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A grey cloud means DNS only: visitors get your origin's address and connect to it directly. There is a warning for this, but it is narrow. The dashboard raises &lt;em&gt;This rule may not apply to your traffic&lt;/em&gt; only when your expression names a hostname that has no proxied record. A rule written as &lt;code&gt;http.request.uri.path eq "/"&lt;/code&gt; names no hostname, so it saves in silence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The zone never activated.&lt;/strong&gt; If the nameservers at the registrar were never switched, the zone sits pending and everything you configured is real but not live. That failure has its own signature and its own &lt;a href="https://301.sh/redirect-silently-fails/" rel="noopener noreferrer"&gt;article on silent redirect failures&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your host is on Cloudflare too.&lt;/strong&gt; This is the one that fools people, because everything on your side looks right. If your hostname is onboarded to a platform that uses Cloudflare for SaaS, the arrangement Cloudflare calls Orange-to-Orange decides whose rules run, and it has exact conditions: the record must be a &lt;strong&gt;CNAME&lt;/strong&gt; to the provider's target, it must be &lt;strong&gt;proxied&lt;/strong&gt; in your zone, and the two zones must be in &lt;strong&gt;different accounts&lt;/strong&gt;. When that holds, your zone is first in line and your rules do run. When it does not hold — an &lt;code&gt;A&lt;/code&gt; record to the provider's address, or no zone of your own — the provider's zone is the only one applying settings, and your redirect rule is a note in a file nobody reads. There is no flag to check: Cloudflare's documentation says outright that no API field or zone setting reports whether Orange-to-Orange is active.&lt;/p&gt;

&lt;p&gt;Two traps around this check. &lt;code&gt;https://your-domain/cdn-cgi/trace&lt;/code&gt; proves you are behind &lt;em&gt;a&lt;/em&gt; Cloudflare, not behind &lt;em&gt;yours&lt;/em&gt;, so it cannot settle the question — and we confirmed the flip side, that a redirect rule matching &lt;code&gt;/cdn-cgi/trace&lt;/code&gt; does not fire at all, leaving that endpoint honest. And zone analytics showing traffic proves the hostname resolves somewhere in your account's view, not that this request was evaluated by your ruleset.&lt;/p&gt;

&lt;p&gt;The dashboard's own confirmation is &lt;a href="https://developers.cloudflare.com/rules/trace-request/" rel="noopener noreferrer"&gt;Cloudflare Trace&lt;/a&gt;, in beta and on every plan. It simulates a request and lists the configurations that matched, in evaluation order. Its input has the hint built in: the URL "must include a hostname that belongs to your account". If Trace will not accept the hostname, the answer to question one is no. Note that Trace runs against your configuration, not against live traffic, so it cannot see the routing problem itself — only the probe request can. The API route for it exists, but it is scoped separately; a token that can create and delete redirect rules got &lt;code&gt;Authentication error&lt;/code&gt; from it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question two: does the rule match what you are actually sending
&lt;/h2&gt;

&lt;p&gt;This is where most of the time goes, and where one behaviour accounts for more of it than everything else combined.&lt;/p&gt;

&lt;p&gt;A wildcard pattern is compared against the entire URL, query string included. Cloudflare's operator documentation says so in an example: a request to &lt;code&gt;https://sub.example.com/folder2/page.html?s=value&lt;/code&gt; does not match &lt;code&gt;*.example.com/*/page.html&lt;/code&gt; because "http.request.full_uri includes the query string and its full value does not match". Stated as a rule you can act on: &lt;strong&gt;a wildcard pattern that does not end in &lt;code&gt;*&lt;/code&gt; will never match a request that carries a query string.&lt;/strong&gt; Which is exactly what happens when you test the rule by adding &lt;code&gt;?v=2&lt;/code&gt; to defeat your browser cache. The cache-buster defeats the rule instead, the page loads normally, and you conclude the rule is broken.&lt;/p&gt;

&lt;p&gt;Here is that pair, measured, along with the rest of the matching behaviour worth knowing:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;th&gt;Request&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;wildcard &lt;code&gt;https://301.sh/rule-probe/*.html&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/rule-probe/x.html&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;302&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;same rule&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/rule-probe/x.html?v=2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;404&lt;/code&gt;, no redirect&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;same rule&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/rule-probe/deep/x.html&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;302&lt;/code&gt; — &lt;code&gt;*&lt;/code&gt; crosses slashes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;same rule&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/rule-probe/x.HTML&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;302&lt;/code&gt; — &lt;code&gt;wildcard&lt;/code&gt; ignores case&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;http.request.uri.path eq "/Rule-Probe-Case"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/Rule-Probe-Case&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;302&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;same rule&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/rule-probe-case&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;404&lt;/code&gt;, &lt;code&gt;eq&lt;/code&gt; is case-sensitive&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;http.request.uri.path eq "/rule-probe-slash"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/rule-probe-slash/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;404&lt;/code&gt;, the slash is part of the path&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The case row is worth a second look, because the two interfaces disagree. The &lt;code&gt;wildcard&lt;/code&gt; operator is case-insensitive by documentation and by measurement; &lt;code&gt;strict wildcard&lt;/code&gt; is the case-sensitive variant. But &lt;code&gt;eq&lt;/code&gt; on a path compares strings, so a rule typed with capitals from a design document quietly matches nothing that visitors send.&lt;/p&gt;

&lt;p&gt;Bulk Redirects have a different matching algorithm, and the difference cuts the other way. A bulk redirect matches on scheme, hostname and path only — &lt;strong&gt;the query string takes no part in matching at all&lt;/strong&gt;, so the cache-buster that kills a wildcard rule is irrelevant here. What substitutes for it is defaults: &lt;code&gt;Subpath matching&lt;/code&gt; and &lt;code&gt;Include subdomains&lt;/code&gt; are both off unless you turned them on, so &lt;code&gt;/blog&lt;/code&gt; does not cover &lt;code&gt;/blog/post&lt;/code&gt;, and &lt;code&gt;example.com&lt;/code&gt; does not cover &lt;code&gt;www.example.com&lt;/code&gt;. And a list on its own does nothing. Cloudflare checks the redirects of each list "that is enabled by a Bulk Redirect Rule" — uploading a list and never creating the rule that switches it on is a complete, silent no-op.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question three: did something answer before your rule got its turn
&lt;/h2&gt;

&lt;p&gt;Rules products run in a fixed order, and a redirect is a terminating action. Cloudflare puts it without hedging:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;for terminating actions (Block, Redirect, or one of the challenge actions), rule evaluation will stop and the action will be executed immediately&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Three consequences, one of which we measured. Two redirect rules matching the same request: the first one wins, always — we deployed a pair pointing at different targets, and the answer carried the target of the first. Single Redirects run before Bulk Redirects in the product order, so when both have a rule for a URL, the Single Redirect is the one that answers. And Page Rules, if you still have them, lose to all of it: the modern rules products "take precedence over Page Rules". An old forwarding Page Rule is not what is fighting your new rule. It is what runs when your new rule does not match — which produces the more confusing symptom of a redirect happening to somewhere you did not configure.&lt;/p&gt;

&lt;p&gt;The same phase order is why a redirect cannot be counted at the edge that issues it, which is &lt;a href="https://301.sh/count-clicks-on-a-redirect/" rel="noopener noreferrer"&gt;its own article&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question four: did it fire while you looked at a cached answer
&lt;/h2&gt;

&lt;p&gt;A redirect that works is easy to hide. A &lt;code&gt;301&lt;/code&gt; is a permanent redirect, and Cloudflare's rules send it without a &lt;code&gt;Cache-Control&lt;/code&gt; header, so a browser may cache it heuristically and keep sending you to the old target long after the rule changed. The reverse also holds: a browser that once got a &lt;code&gt;200&lt;/code&gt; for that URL can go on serving it from cache while the edge is redirecting everyone else. Test with &lt;code&gt;curl -sI&lt;/code&gt;, which caches nothing, before you test with a browser tab.&lt;/p&gt;

&lt;p&gt;And when you do reach for a cache-buster, remember question two: appending &lt;code&gt;?v=2&lt;/code&gt; changes whether a wildcard rule matches. The standard advice for making a browser fetch fresh is the standard way to make this particular test lie. Use a private window, a different browser, or &lt;code&gt;curl&lt;/code&gt; — not a query parameter.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the redirect was never Cloudflare's to run
&lt;/h2&gt;

&lt;p&gt;One case belongs outside the tree. If the redirect lives at your origin — a rewrite in &lt;code&gt;.htaccess&lt;/code&gt;, a framework route — then Cloudflare has no rule to fire and nothing to trace. It forwards whatever the origin says, and the origin can be inconsistent in ways that are invisible from the dashboard. A report in July described exactly that: the same URL answering &lt;code&gt;301&lt;/code&gt; when proxied through one Cloudflare datacenter and &lt;code&gt;200&lt;/code&gt; when proxied through another, with the origin returning a clean &lt;code&gt;301&lt;/code&gt; on a direct request every time. Whatever the cause, the fix is structural. A redirect expressed as an edge rule answers before the origin is contacted, so it cannot vary with what the origin decides to do that second.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this gets untidy
&lt;/h2&gt;

&lt;p&gt;A rule whose target equals its source produces a loop, and Cloudflare does not stop it. We pointed a rule at its own path and followed the chain: six hops in, still redirecting to itself. A browser calls that too many redirects; a monitoring check may call it a &lt;code&gt;302&lt;/code&gt; and pass.&lt;/p&gt;

&lt;p&gt;Free zones get 10 Single Redirect rules, and the quota is per zone. Hitting it fails at creation rather than at runtime, so it is not a cause of this symptom — but it is a reason people put logic into one wildcard rule, where the query-string behaviour above starts to bite. The full quota table sits in the &lt;a href="https://301.sh/cloudflare-free-plan-redirect-limits/" rel="noopener noreferrer"&gt;article on redirect limits&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Trace does not evaluate inactive rules, which is correct and occasionally misleading: a rule switched off looks the same in Trace results as a rule that did not match. And Trace reflects configuration, so a request that never reaches your zone traces perfectly while failing in production. That is why question one comes first and is answered with a real request.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this costs across a portfolio
&lt;/h2&gt;

&lt;p&gt;For one domain, this is a ten-minute job. Two &lt;code&gt;curl&lt;/code&gt; calls and a temporary rule tell you which of the four questions is the wrong one, and the fix follows from the answer.&lt;/p&gt;

&lt;p&gt;The arithmetic changes when the same question has to be asked of two hundred domains that nobody opened this month, because none of these failures announce themselves. A rule that stopped matching after a platform migration looks exactly like a rule that works, from the inside. That is the case for asking every domain the same question on a schedule — the loop from the &lt;a href="https://301.sh/audit-300-domains-one-script/" rel="noopener noreferrer"&gt;audit script&lt;/a&gt; if you like scripts, and &lt;a href="https://301.st" rel="noopener noreferrer"&gt;301.st&lt;/a&gt; if you would rather the portfolio be watched continuously and tell you when a row changes. For a handful of domains, the script and a cron entry are genuinely enough.&lt;/p&gt;

</description>
      <category>redirects</category>
      <category>cloudflare</category>
      <category>dns</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to audit agent readiness when the scanner cannot reach the site</title>
      <dc:creator>301ST</dc:creator>
      <pubDate>Fri, 07 Aug 2026 14:00:58 +0000</pubDate>
      <link>https://dev.to/301st/how-to-audit-agent-readiness-when-the-scanner-cannot-reach-the-site-gg4</link>
      <guid>https://dev.to/301st/how-to-audit-agent-readiness-when-the-scanner-cannot-reach-the-site-gg4</guid>
      <description>&lt;p&gt;Cloudflare runs a scanner that grades a domain on how ready it is for AI agents. We went through &lt;a href="https://301.sh/agent-readiness-audit/" rel="noopener noreferrer"&gt;all twenty one of its checks&lt;/a&gt; in July: which four were worth implementing, and why eight of the failures were the right answer.&lt;/p&gt;

&lt;p&gt;You point it at your site, get a number, and fix what it flags. Then you try to check the version that actually matters — the staging build with the change in it, the site that has not launched, the pages behind a login — and there is nothing to point the scanner at.&lt;/p&gt;

&lt;p&gt;That is not a bug in the scanner. It runs in somebody else's datacenter, so it can only answer for origins that datacenter can reach. Both failure modes are one &lt;code&gt;curl&lt;/code&gt; away, checked 4 August 2026:&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;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://isitagentready.com/api/scan &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="go"&gt;    -H 'content-type: application/json' -d '{"url":"http://localhost:3000"}'
{"error":"Invalid URL provided"}

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://isitagentready.com/api/scan &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="go"&gt;    -H 'content-type: application/json' -d '{"url":"https://staging.301.sh"}'
{"url":"https://staging.301.sh","scannedAt":"2026-08-04T18:31:38.462Z",
 "siteError":{"httpStatus":530,"statusText":"","bodyPreview":null,
 "retryAfter":null,"server":"cloudflare"}}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A local address is rejected outright. A host the scanner cannot resolve returns a site error instead of a scorecard. Not a low score: no score.&lt;/p&gt;

&lt;p&gt;This matters more than it sounds, because of when you want the answer. Taking this site from Level 1 to Level 5 meant a batch of static files, then a &lt;code&gt;/mcp&lt;/code&gt; server, then DNS records, and every one of those steps was a deploy to production first and a scan second. Publish, then find out. For a redirect you would never accept that loop.&lt;/p&gt;

&lt;p&gt;The other place to run the checks is the browser, which reaches whatever you can reach. This article is about what that changes, what it costs, and the one rule that decides whether the result is worth anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the browser has that the datacenter does not
&lt;/h2&gt;

&lt;p&gt;Three things, and only the first one is obvious.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reachability.&lt;/strong&gt; &lt;code&gt;localhost:5173&lt;/code&gt;, a staging host behind a VPN, an internal tool, a domain whose DNS is not public yet. If the tab loads, the audit runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A session.&lt;/strong&gt; Your cookies are attached to the page request, so a site that shows an anonymous visitor a login wall gets graded on what a logged-in client actually receives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An in-page runtime.&lt;/strong&gt; Some of what an agent consumes is not an HTTP response at all. WebMCP exposes tools through &lt;code&gt;document.modelContext&lt;/code&gt; inside the page, after scripts run. An HTTP probe from outside cannot see it; a script in the page can.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule that keeps a browser audit honest
&lt;/h2&gt;

&lt;p&gt;The session is also the trap, and it is worth understanding before you write your own script, never mind which tool you use.&lt;/p&gt;

&lt;p&gt;An agent readiness audit fetches two very different classes of thing. There is the page, which may legitimately live behind a login. And there are the machine files — &lt;code&gt;robots.txt&lt;/code&gt;, the sitemap, &lt;code&gt;llms.txt&lt;/code&gt;, everything under &lt;code&gt;/.well-known/&lt;/code&gt; — which are public by definition, because the client reading them has no account and never will.&lt;/p&gt;

&lt;p&gt;Send your session to both and you grade a site that nobody but you can see. A &lt;code&gt;/.well-known/&lt;/code&gt; document that only answers for authenticated requests is a document an agent gets a 401 or a login page from. Passing it in your browser tells you nothing true.&lt;/p&gt;

&lt;p&gt;So the split is per request, not per scan:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What is fetched&lt;/th&gt;
&lt;th&gt;Credentials&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The page itself&lt;/td&gt;
&lt;td&gt;&lt;code&gt;include&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;It may be behind a login, and that is the case the external scanner cannot cover&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Accept: text/markdown&lt;/code&gt; on the page&lt;/td&gt;
&lt;td&gt;&lt;code&gt;include&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Same URL, same session, a different representation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;robots.txt&lt;/code&gt;, &lt;code&gt;sitemap.xml&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;omit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Public machine files — judge them as a crawler receives them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;llms.txt&lt;/code&gt;, &lt;code&gt;llms-full.txt&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;omit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Same&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Everything under &lt;code&gt;/.well-known/&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;omit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Discovery documents are read by clients with no account&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Redirects follow the same logic. The probe follows hops the way a navigation does, each hop attaching its own cookies, and the response records the final URL. A check that passed on a URL you did not request is a check you want to see the address for.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changes shape when the audit moves into a browser
&lt;/h2&gt;

&lt;p&gt;Moving the audit into the browser is not a free upgrade. It trades one set of blind spots for another, and a tool that hides the trade is worse than the gap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DNS-AID needs a different question, not a different tool.&lt;/strong&gt; The extension APIs have no DNS resolver, which is where this write-up originally stopped — wrongly, because a resolver is not what you need. DNS-over-HTTPS is an ordinary &lt;code&gt;fetch&lt;/code&gt;, and its JSON answer carries both halves of the verdict: the SVCB records under &lt;code&gt;_index._agents&lt;/code&gt;, and &lt;code&gt;AD&lt;/code&gt;, the resolver's DNSSEC judgement. That second half matters, because a site publishing records without a validated chain is failed by the scanner too. No token and no account are involved, so the answer is the same for every user. On this site it was &lt;a href="https://301.sh/agent-readiness-audit/" rel="noopener noreferrer"&gt;the last check to pass&lt;/a&gt; — the resolver cache in front of it took about forty minutes of rescans after the records were already live.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WebMCP only works here.&lt;/strong&gt; In-page detection returns one of three answers, and the third one is the point: detected, confirmed absent, or detection unavailable. A browser without the API, or a page where the injected script could not run, produces "unavailable" — not a failure. Not detectable is not the same as not present, and a check that scores those the same way manufactures a defect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The MCP Server Card needs five addresses.&lt;/strong&gt; The audit &lt;a href="https://301.sh/agent-readiness-audit/" rel="noopener noreferrer"&gt;already flagged this&lt;/a&gt;, and it has not improved: the draft-canonical path, the IETF one, the two Cloudflare probes and the de-facto legacy file are all in play because the specification moved and the scanners did not move with it. A checker that probes one of them reports the state of that address, not the state of your server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it breaks: the number is not one number
&lt;/h2&gt;

&lt;p&gt;Three separate reasons a score you read today will not match a score somewhere else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not-applicable checks leave the denominator.&lt;/strong&gt; The composite is passing checks over applicable ones, and a check that cannot apply is excluded rather than failed. Anything the browser genuinely cannot reach — a resolver that will not answer, an in-page runtime that never ran — drops out there and stays in from outside. Same site, same day, different arithmetic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloudflare's own two surfaces disagree.&lt;/strong&gt; On 30 July 2026 this site read 79 of 100 and Level 5 "Agent-Native" in the web interface, while &lt;code&gt;/api/scan&lt;/code&gt; reported Level 4 for the same domain in the same hour. That is stable, not a glitch we caught mid-deploy, and it means "what level are we" has two correct answers depending on which endpoint you ask.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The check matrix itself moves.&lt;/strong&gt; The MCP Server Card above is one example; DNS-AID is another, resting on an individual Internet-Draft that expires on 28 November 2026, probed through labels that draft does not define. And &lt;code&gt;llms.txt&lt;/code&gt; is not a scored check in either tool: the audit measures content negotiation, so a site can publish a complete &lt;code&gt;llms.txt&lt;/code&gt; surface and score zero on content accessibility, which is exactly how the July audit opened. What counts as readiness is being decided while you measure it.&lt;/p&gt;

&lt;p&gt;The conclusion is not that the score is worthless. It is that the score is only meaningful against itself. What did this origin do yesterday, and what changed after the last deploy. Comparing your number to another site's number compares two arithmetics.&lt;/p&gt;

&lt;h2&gt;
  
  
  The extension
&lt;/h2&gt;

&lt;p&gt;That is what &lt;a href="https://chromewebstore.google.com/detail/agent-readiness-inspector/diofmjhnegmcccocikabageabmaokobd" rel="noopener noreferrer"&gt;Agent Readiness Inspector&lt;/a&gt; does, and shipping it is the occasion for this article. It is our own extension, it runs 22 checks in the browser against whatever origin the current tab is on, and it is free. Firefox and Edge builds are with the stores for review; today the link above is the one that works.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;22 checks, versioned as data.&lt;/strong&gt; Robots and AI crawler rules, sitemaps, &lt;code&gt;Link&lt;/code&gt; headers, Markdown negotiation, Content Signals, Agent Skills, API Catalog, MCP Server Card, OAuth discovery, Web Bot Auth, WebMCP, plus the agentic commerce protocols as an unscored preview. The matrix carries a version and a CI job watches the upstream one for drift, because the section above is a permanent condition rather than a bad month.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Evidence per check.&lt;/strong&gt; The response that produced the verdict, including the final URL after redirects, and a copy-ready prompt for the fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Saved sites and regressions.&lt;/strong&gt; Scheduled rescans, history, and an alert when a check that used to pass stops passing. Alerts land in a local inbox first; the notification permission is optional and asked for only if you turn it on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local-first.&lt;/strong&gt; Scans, history, settings and alerts live in browser storage. The optional outside comparison uses your own Cloudflare URL Scanner credentials and is off by default.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is an independent implementation of open web standards, not affiliated with or endorsed by Cloudflare.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which tool to use
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You need&lt;/th&gt;
&lt;th&gt;isitagentready.com&lt;/th&gt;
&lt;th&gt;Agent Readiness Inspector&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A public production site&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Staging, localhost, pre-launch, VPN&lt;/td&gt;
&lt;td&gt;no, site error or invalid URL&lt;/td&gt;
&lt;td&gt;yes, if the tab loads&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pages behind a login&lt;/td&gt;
&lt;td&gt;anonymous view only&lt;/td&gt;
&lt;td&gt;your session, on the page probes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebMCP in the page&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes, in-page detection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DNS-AID&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes, over DNS-over-HTTPS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The score Cloudflare will quote at you&lt;/td&gt;
&lt;td&gt;yes, this is the source&lt;/td&gt;
&lt;td&gt;no, a second opinion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rescan on a schedule, alert on regression&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes, on the machine it is installed on&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Where the extension stops
&lt;/h2&gt;

&lt;p&gt;It audits the origin of the tab you have open, on the machine it is installed on, while the browser is running. Scheduled rescans go through a bounded batch per cycle, so a watch list is a handful of sites you care about, not an inventory.&lt;/p&gt;

&lt;p&gt;For getting a site agent-ready before you publish it, that is the whole job, and it costs nothing. The point where it stops is the point where the question changes from "is this site ready" to "are all of them still ready" — every domain in a portfolio, checked on a schedule that does not depend on someone's laptop being awake, with the alert going to whoever is on call. That is the same boundary the &lt;a href="https://301.sh/see-the-redirect-chain-your-browser-followed/" rel="noopener noreferrer"&gt;redirect chain recorder&lt;/a&gt; runs into, for the same reason, and it is what &lt;a href="https://301.st/features" rel="noopener noreferrer"&gt;301.st&lt;/a&gt; does across a portfolio. For the site in front of you, install the extension and read the evidence.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>browser</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A unique click id on a free Cloudflare redirect, no Worker involved</title>
      <dc:creator>301ST</dc:creator>
      <pubDate>Thu, 06 Aug 2026 14:00:21 +0000</pubDate>
      <link>https://dev.to/301st/a-unique-click-id-on-a-free-cloudflare-redirect-no-worker-involved-1750</link>
      <guid>https://dev.to/301st/a-unique-click-id-on-a-free-cloudflare-redirect-no-worker-involved-1750</guid>
      <description>&lt;p&gt;You send paid or partner traffic through a redirect and you want every click to arrive at the destination carrying its own identifier — something to join against a conversion later. The obvious tool is a Worker, and for anything that needs to &lt;em&gt;record&lt;/em&gt; the click, a Worker &lt;a href="https://301.sh/count-clicks-on-a-redirect/" rel="noopener noreferrer"&gt;is still the only primitive that can&lt;/a&gt;. But if all you need is for each click to &lt;em&gt;carry&lt;/em&gt; a unique id, there is a way to get one on the free plan, inside a plain Single Redirect, with no code in the request path at all.&lt;/p&gt;

&lt;p&gt;We tested it on this site's zone on 30 July 2026. It works, and three of its edges bite. Here is the rule, the measurements, and the traps.&lt;/p&gt;

&lt;h2&gt;
  
  
  The function that is banned, and the field that is not
&lt;/h2&gt;

&lt;p&gt;The ruleset expression language has &lt;code&gt;uuidv4()&lt;/code&gt;. You cannot use it here. The documentation restricts it to rewrite expressions of Transform Rules, and the API enforces that at validation time. This is the live answer to an attempt to put it in a redirect target:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"'concat(\"https://301.sh/?click_id=\", uuidv4(cf.random_seed))' is not a valid
value for target_url because the use of field cf.random_seed is not allowed,
the use of function uuidv4 is not allowed" (code 20083)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both the function and its seed field are named as forbidden. So the expression language cannot &lt;em&gt;mint&lt;/em&gt; randomness in a redirect. What it can do is reuse an identifier Cloudflare has already minted for the request: &lt;code&gt;cf.ray_id&lt;/code&gt;, the ray ID that every request through Cloudflare gets, the same value the &lt;code&gt;CF-RAY&lt;/code&gt; response header and the dashboard logs show. Its field documentation carries no product restriction, and the validator accepts it in a redirect target.&lt;/p&gt;

&lt;h2&gt;
  
  
  One rule, measured
&lt;/h2&gt;

&lt;p&gt;A dynamic Single Redirect. The match is ordinary; the target is an expression instead of a static URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Expression:  (http.host eq "301.sh" and http.request.uri.path eq "/go")
Target URL:  concat("https://destination.example/?click_id=", cf.ray_id)
Status:      302, preserve query string off
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five requests in a row through that rule, on this zone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Location: https://301.sh/…/?click_id=a23684c8a885b183-BTS   CF-RAY: a23684c8a885b183-BTS
Location: https://301.sh/…/?click_id=a23684cbfd5a2915-BTS   CF-RAY: a23684cbfd5a2915-BTS
Location: https://301.sh/…/?click_id=a23684cf493d696b-BTS   CF-RAY: a23684cf493d696b-BTS
Location: https://301.sh/…/?click_id=a23684d2ddcf1948-BTS   CF-RAY: a23684d2ddcf1948-BTS
Location: https://301.sh/…/?click_id=a23684d64c0a46d5-BTS   CF-RAY: a23684d64c0a46d5-BTS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three properties, all visible in the output. Every request got a different id. Each id is identical, byte for byte, to the &lt;code&gt;CF-RAY&lt;/code&gt; header of the same response — which means the click id in your destination's logs joins directly against Cloudflare's own logs and dashboard. And the substituted value is the full form with the data center suffix (&lt;code&gt;-BTS&lt;/code&gt; here), not just the sixteen hex characters, so parse accordingly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The preserve_query_string trap
&lt;/h2&gt;

&lt;p&gt;The obvious next step is to keep the campaign parameters the click arrived with. The rule has a switch for that, and combining it with a target expression that already carries a query is where it breaks — measured, not guessed:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;Incoming request&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;Location&lt;/code&gt; actually sent&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;preserve_query_string: true&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/go?utm_source=tg&amp;amp;gclid=abc123&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;…/?utm_source=tg&amp;amp;gclid=abc123&lt;/code&gt; — click_id &lt;strong&gt;gone&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;preserve_query_string: true&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/go&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;…/&lt;/code&gt; — no query at all, even the target's own&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;manual merge, flag off&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/go?utm_source=tg&amp;amp;gclid=abc123&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;…/?click_id=a2368ff0…-BTS&amp;amp;utm_source=tg&amp;amp;gclid=abc123&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;preserve_query_string&lt;/code&gt; does not merge the incoming query into your target. It replaces the target's query entirely — including when the incoming query is empty, which deletes the &lt;code&gt;click_id&lt;/code&gt; you just built. The two features are mutually exclusive.&lt;/p&gt;

&lt;p&gt;The working form leaves the switch off and does the merge in the expression:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;concat("https://destination.example/?click_id=", cf.ray_id, "&amp;amp;", http.request.uri.query)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One cosmetic edge: when the incoming query is empty, the result ends in a bare &lt;code&gt;&amp;amp;&lt;/code&gt;. Every query parser we care about ignores it, but it is in the URL, so know it is there before you diff logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  A 301 will replay yesterday's id
&lt;/h2&gt;

&lt;p&gt;The same rule with status 301 substitutes a fresh ray ID per request at the edge — we measured that too. The problem is in front of the edge: the 301 comes back with no &lt;code&gt;Cache-Control&lt;/code&gt; header, and a permanent redirect without explicit freshness information is exactly what browsers are allowed to cache heuristically. A repeat visitor can then be redirected by their own cache, carrying the click id from their first visit, and the request never reaches Cloudflare at all — a duplicate id in your logs and an undercounted click. For this pattern the status you want is 302 or 307.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it breaks
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Nothing on Cloudflare's side records the id.&lt;/strong&gt; Redirect is a terminating action in the first request phase; &lt;a href="https://301.sh/count-clicks-on-a-redirect/" rel="noopener noreferrer"&gt;no analytics product ever sees the request&lt;/a&gt;, and the rule cannot write anywhere. The id exists only in the &lt;code&gt;Location&lt;/code&gt; URL. If the destination does not log its query string, the id evaporates in flight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A ray ID is an identifier, not a secret.&lt;/strong&gt; It is unique, but it is not random in any adversarial sense — do not use it as a token that authorizes anything. Join key: yes. Capability: no.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every fetch gets an id, not every human.&lt;/strong&gt; Link prefetchers, scanners and preview bots follow redirects too, and each gets its own perfectly valid click id. The rule cannot tell them apart; dedup and filtering stay the destination's job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The quota is real but roomy.&lt;/strong&gt; Ten Single Redirect rules per zone on the free plan — per zone, not per account — then 25 on Pro, 50 on Business, 300 on Enterprise (checked 30 July 2026). One tracking rule with a wildcard covers a whole path family, so ten goes further than it sounds. Regular expressions need Business; this pattern does not need them.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you need more than a stamp
&lt;/h2&gt;

&lt;p&gt;If the destination is yours, this rule plus your own analytics reading &lt;code&gt;click_id&lt;/code&gt; may be the whole system: no Worker, no code, nothing to maintain, and the id joins against Cloudflare's logs by construction.&lt;/p&gt;

&lt;p&gt;The pattern stops being enough exactly where the &lt;a href="https://301.sh/attribution-when-the-conversion-page-is-not-yours/" rel="noopener noreferrer"&gt;attribution article&lt;/a&gt; starts: the destination is not yours, or you need the click recorded even when the destination logs nothing, or you run this across a portfolio of domains rather than one zone. Recording at the redirect side is precisely the thing a terminating rule cannot do — that side needs to be operated, which is what &lt;a href="https://301.st" rel="noopener noreferrer"&gt;301.st&lt;/a&gt; is for: it owns the redirect end, mints and records the id there, and hands the same id downstream, so the join works whether or not the destination cooperates.&lt;/p&gt;

</description>
      <category>tracking</category>
      <category>redirects</category>
      <category>cloudflare</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Every job your VPS does, and what Cloudflare's free plan does with it</title>
      <dc:creator>301ST</dc:creator>
      <pubDate>Wed, 05 Aug 2026 14:00:26 +0000</pubDate>
      <link>https://dev.to/301st/every-job-your-vps-does-and-what-cloudflares-free-plan-does-with-it-5eao</link>
      <guid>https://dev.to/301st/every-job-your-vps-does-and-what-cloudflares-free-plan-does-with-it-5eao</guid>
      <description>&lt;p&gt;The fear of leaving a VPS is rarely about any one feature. A small server quietly does ten jobs at once — serves a site, renews certificates, answers DNS, redirects old URLs, runs a cron script, forwards mail, keeps a database, stores uploads, hosts a small API, rate limits a scraper — and the migration question is really ten questions asked as one. Ask "can Cloudflare replace my VPS" and you get advocacy in both directions. Ask it per job and you get answers you can check.&lt;/p&gt;

&lt;p&gt;So here is the job by job mapping, every number read off the live documentation on 29 July 2026, with the allowance, the price of lifting it, and the honest rows where the answer is "keep the server." At the end: two systems that already live this way, one of them a production SaaS.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mapping
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;VPS job&lt;/th&gt;
&lt;th&gt;On the free plan&lt;/th&gt;
&lt;th&gt;The allowance&lt;/th&gt;
&lt;th&gt;Where it bites&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Serve a static site&lt;/td&gt;
&lt;td&gt;Workers static assets&lt;/td&gt;
&lt;td&gt;asset requests "free and unlimited"; 20,000 files, 25 MiB each&lt;/td&gt;
&lt;td&gt;paths routed through Worker code count against 100,000 requests a day&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TLS certificates&lt;/td&gt;
&lt;td&gt;Universal SSL&lt;/td&gt;
&lt;td&gt;90 day certs, auto renewed from 30 days out&lt;/td&gt;
&lt;td&gt;proxied hostnames only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DNS&lt;/td&gt;
&lt;td&gt;free, with unmetered DDoS protection&lt;/td&gt;
&lt;td&gt;200 records per zone (zones created since September 2024; older free zones keep 1,000)&lt;/td&gt;
&lt;td&gt;nameservers must move to Cloudflare&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redirects&lt;/td&gt;
&lt;td&gt;Single + Bulk Redirects&lt;/td&gt;
&lt;td&gt;10 rules; 10,000 bulk URLs&lt;/td&gt;
&lt;td&gt;&lt;a href="https://301.sh/cloudflare-free-plan-redirect-limits/" rel="noopener noreferrer"&gt;the bulk quota may lag in your account&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cron scripts&lt;/td&gt;
&lt;td&gt;Cron Triggers on a Worker&lt;/td&gt;
&lt;td&gt;5 per account documented, minimum interval 1 minute&lt;/td&gt;
&lt;td&gt;see the next section&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Small APIs, dynamic bits&lt;/td&gt;
&lt;td&gt;Workers&lt;/td&gt;
&lt;td&gt;100,000 requests a day, 10 ms CPU each&lt;/td&gt;
&lt;td&gt;computing breaks the CPU budget; serving does not&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Key value storage&lt;/td&gt;
&lt;td&gt;Workers KV&lt;/td&gt;
&lt;td&gt;1 GB, 100,000 reads / 1,000 writes a day&lt;/td&gt;
&lt;td&gt;1,000 writes is less than it sounds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A real database&lt;/td&gt;
&lt;td&gt;D1 (SQLite)&lt;/td&gt;
&lt;td&gt;5 GB, 5 million reads / 100,000 writes a day&lt;/td&gt;
&lt;td&gt;daily caps error out, not throttle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Object storage&lt;/td&gt;
&lt;td&gt;R2&lt;/td&gt;
&lt;td&gt;10 GB, free egress&lt;/td&gt;
&lt;td&gt;free tier covers Standard storage only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mail forwarding&lt;/td&gt;
&lt;td&gt;Email Routing&lt;/td&gt;
&lt;td&gt;free on all plans; 200 rules, 200 destinations&lt;/td&gt;
&lt;td&gt;forwarding, not sending&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Firewall, rate limits&lt;/td&gt;
&lt;td&gt;WAF free tier&lt;/td&gt;
&lt;td&gt;5 custom rules, 1 rate limiting rule&lt;/td&gt;
&lt;td&gt;one rate limit means choosing what to protect&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Request logs&lt;/td&gt;
&lt;td&gt;Workers Logs, Analytics Engine&lt;/td&gt;
&lt;td&gt;logs with sampling; 100,000 data points a day&lt;/td&gt;
&lt;td&gt;Logpush is Enterprise; Workers Trace Events unlock at $5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three of those rows deserve more than a cell.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cron entry that should not fire, and does
&lt;/h2&gt;

&lt;p&gt;The documentation says the free plan allows 5 Cron Triggers per account. The account this blog runs on executes 7, every day, across five distribution Workers — and has for a week, posting to five platforms on schedule. We did not negotiate anything; the limit simply is not enforced against this account.&lt;/p&gt;

&lt;p&gt;That is not a loophole to build on. It is the same phenomenon we documented when &lt;a href="https://301.sh/cloudflare-free-plan-redirect-limits/" rel="noopener noreferrer"&gt;the Bulk Redirects quota said 10,000 and dashboards enforced 20&lt;/a&gt;: Cloudflare's documented numbers and per account entitlements drift apart, in both directions, sometimes for months. Read the number, then test the number. On a migration that habit costs you an afternoon and saves you an architecture built around a limit that was never real for you — or one that assumed an allowance you do not actually have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Certificates are the quiet argument
&lt;/h2&gt;

&lt;p&gt;On a VPS the TLS routine was a yearly renewal, or a certbot timer. That era is ending on a schedule: since 15 March 2026 the CA/Browser Forum rules cap public certificates at 200 days, in March 2027 the cap drops to 100 days, and in March 2029 to 47 days with domain validation reuse down to 10 days. Manual renewal stops being a viable habit and becomes an incident generator — a portfolio of parked domains on a VPS meets that wave at its first short renewal this autumn.&lt;/p&gt;

&lt;p&gt;Behind the proxy, Universal SSL already issues 90 day certificates and renews them starting 30 days out, which sits below every step of that schedule. Of everything in the table, this is the row where the free plan is not a cheaper version of the VPS job but a categorically better one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ceiling is real, and it is measurable
&lt;/h2&gt;

&lt;p&gt;The 10 ms CPU budget is the free plan's one hard wall, and we have measured both sides of it on this site. A Worker that serves — &lt;a href="https://301.sh/mcp-server-on-workers-free-plan/" rel="noopener noreferrer"&gt;our MCP server&lt;/a&gt; answering from static assets — spends 0 to 2 ms of the budget, because waiting on I/O does not count as CPU. A Worker that computes — the template engine we measured in &lt;a href="https://301.sh/agent-readiness-audit/" rel="noopener noreferrer"&gt;the agent readiness audit&lt;/a&gt; — blew through the same budget by a factor of eight. That is the honest split for a migration: routing, redirecting, serving, forwarding all fit with room to spare; parsing, rendering and hashing at volume do not.&lt;/p&gt;

&lt;p&gt;Per this site's house rule, the price of the lift sits next to the limit: the Workers Paid plan at $5 a month raises the per request cap to 30 seconds by default and includes 10 million requests and 30 million CPU milliseconds a month.&lt;/p&gt;

&lt;h2&gt;
  
  
  What honestly stays on the server
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Long running processes.&lt;/strong&gt; Anything that holds a socket open for hours, speaks a protocol that is not HTTP, or needs SSH. Arbitrary TCP proxying exists (Spectrum) but only as an Enterprise extra — that is not a migration, that is procurement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Heavy compute&lt;/strong&gt;, per the measurement above, unless $5 and 30 seconds cover it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The paid tier gaps.&lt;/strong&gt; Snippets are absent from Free entirely, Logpush is Enterprise, Load Balancing and Argo are paid extras. If your VPS job maps to one of these, the free plan answer is "no", not "yes with effort".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Control you did not know you had.&lt;/strong&gt; On Cloudflare Registrar you cannot change nameservers at all, and &lt;a href="https://301.sh/auto-renew-on-domain-still-expired/" rel="noopener noreferrer"&gt;renewal is an instruction, not a guarantee&lt;/a&gt;. A migration hands real levers to one vendor; that is a decision, not a detail.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And there is a bridge for the undecided: Cloudflare Tunnel is available on all plans, so the VPS can stay exactly where it is, hidden behind the edge with no open ports, while jobs migrate off it one row at a time. Nothing about this table is all or nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two systems that already live like this
&lt;/h2&gt;

&lt;p&gt;This blog is the small case: six Workers — the site plus five distribution posters — seven cron firings a day, a shared KV namespace, 317 static files, an MCP server, at a running cost of exactly zero, with the measurements published as articles.&lt;/p&gt;

&lt;p&gt;The larger case is &lt;a href="https://301.st" rel="noopener noreferrer"&gt;301.st&lt;/a&gt;, the platform this blog belongs to: a production SaaS for domains, redirects and traffic distribution, built as a serverless application on Cloudflare Workers with D1 as the source of truth and KV for cache and sessions. Its architecture documentation states the design goal plainly: customers do not need a paid Cloudflare plan — everything runs on free Workers, and Workers Paid is the growth path, not the entry fee. A platform whose job is other people's domains chose the same table you just read. That is as honest an endorsement of the free plan as we can offer. And where a portfolio outgrows the rows you can run yourself, taking that work over is exactly what the platform exists for.&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>dns</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
