• Aug 24, 2026
  • 5 min read

Cloudflare Error 1010 Silently Blocked My Own Homelab Alerts

My homelab runs a small job that mirrors my lead inbox from Cloudflare KV into SQLite every 15 minutes. When a pull fails, or recovers, it sends me an ntfy alert. It also has a safety brake: if more than half the known leads vanish in one pull, it refuses to continue and alerts me.

One day the brake tripped exactly as designed, after I deleted a batch of test leads on purpose. The log said so. My phone said nothing.

I didn’t notice that at the time. I noticed a few days later, when I added a weekly digest and ran it by hand for the first time:

$ docker compose run --rm hub digest --send
Leads weekly - 2026-W41
New leads: 9
...
ntfy failed: HTTP Error 403: Forbidden
not sent (NTFY_URL missing or ntfy unreachable)

The digest was the first alert I watched being sent. Every alert before it had failed the same way.

The route an alert takes

My ntfy server runs on the homelab and is published through a Cloudflare Tunnel (how I set that up). So even though the hub and ntfy sit in the same house, the hub reaches ntfy the same way my phone does: over HTTPS, through Cloudflare’s edge, and then down the tunnel.

hub container ── HTTPS ──► Cloudflare edge ──► tunnel ──► ntfy ──► phone
                             ▲
                             └─ request rejected here

That middle hop has opinions about who is allowed in.

Two different 403s

A 403 from ntfy would mean “wrong token”. I assumed that first. The quickest way to tell the two apart was to make the same request with two user agents and look at the response bodies:

curl -s -A "Python-urllib/3.12" -X POST -d probe https://<my ntfy server>/<topic>
# 403  error code: 1010

curl -s -A "leads-hub/0.4" -X POST -d probe https://<my ntfy server>/<topic>
# 403  {"code":40301,"http":403,"error":"forbidden", ...}

Both are 403, but they come from different places:

BodyWho answeredMeaning
error code: 1010CloudflareThe client’s signature (here, its User-Agent) is banned; the request never reached the origin
{"code":40301,...} JSONntfyThe request arrived; it just had no token in this test

Error 1010 is Cloudflare refusing a client by its browser signature. Python’s urllib announces itself as Python-urllib/<version> unless told otherwise, and that string is on the list of things Cloudflare’s bot protection turns away on my zones.

I had already met this exact block a week earlier. A test script calling my own forms endpoint got a 403 while curl and browsers didn’t, and I wrote it down as item 7 in my week of Cloudflare debugging notes. The fix there was “send a normal User-Agent from scripts”. I just hadn’t connected it to the hub, because I didn’t think of the hub as a script calling a Cloudflare site. It is one.

What it had cost

Everything the hub sent to ntfy since it went live:

  • Pull failures: never delivered. If the KV token had expired, I would have found out by noticing stale data, not from an alert.
  • Recovery notices: never delivered.
  • The safety-brake alert: never delivered, the one time it fired for real.

None of this showed up in testing. The unit tests checked the decision (“should we alert now?”), with the HTTP call replaced by a fake. The fake never met Cloudflare.

Other outbound calls from the hub (Cloudflare’s KV API, Mailjet) worked fine with the default user agent, because they aren’t behind my zone’s bot protection. That made the bug easy to miss: most of the hub’s HTTP traffic was healthy.

The fix

Every outbound request from the hub now identifies itself, from one constant:

# leadshub/__init__.py
__version__ = "0.4.1"

# Every outbound HTTP request identifies itself: Cloudflare-proxied hosts (our
# ntfy) answer the default "Python-urllib/x.y" with 403 / error 1010.
USER_AGENT = f"leads-hub/{__version__}"

The ntfy alert, the KV client and both email senders all add "User-Agent": USER_AGENT to their headers. Using the same header everywhere is cheap and means no call is left with the default. It also makes the hub’s requests easy to find in logs.

A test pins it, so a new HTTP call can’t quietly go back to the default:

def test_every_outbound_request_sets_a_user_agent():
    ...
    def opener(req):
        seen["ua"] = req.get_header("User-agent")
        return Resp()

    list(KV("acct", "ns", "tok", opener=opener).keys())
    assert seen["ua"] == leadshub.USER_AGENT and "Python-urllib" not in seen["ua"]

After the fix, the same probe from the laptop got ntfy’s own JSON 403 (no token on the laptop). The homelab, which has the token, sent the digest:

Pulls: 530 (6 failed)
sent to ntfy

Six failed pulls in that window, and I hadn’t seen a single alert for any of them. Some may have happened before the ntfy token was even configured, but the deliberate one (the safety brake after a cleanup) certainly should have buzzed — and didn’t.

Why I didn’t change Cloudflare instead

I could have added a rule to let Python-urllib through on the ntfy hostname. I didn’t, for two reasons:

  • The block is doing its job against real scrapers on every other hostname.
  • A named user agent is better anyway: it says which program sent the request and which version, which is useful the next time something goes wrong.

Takeaways

  • Test the alert path, not only the condition. A test that the job decides to alert says nothing about whether the alert arrives. Send one real alert end to end after every change to how alerts are delivered.
  • A 403 has an author. Read the body: Cloudflare’s error code: 1010 and your app’s own JSON 403 are different problems.
  • Your homelab talks to your own sites through Cloudflare, with the same rules as any stranger, when the service is published through a tunnel.
  • Set a User-Agent on every outbound request from your own tools, from one constant, with a test.

Next: a spam blocklist that stores instead of drops, and why I deferred Turnstile.