◉CLAUDE LABJP
●2.1.311 — Adds idle-hold options for self-hosted runners and fixes startup freezes and stalled interrupts (10/26)●2.1.307 — Fixes MCP failing to reconnect after sleep or a keychain lock (10/21). 2.1.306 also fixed the false claude -p login message●SONNET 4.5 — Retires on the Claude API on 11/30, 33 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●API — In API version 2026-10-21, passing temperature, top_p or top_k returns a 400 error●NEW — I Put a Ten-Line Promise File at the To…●2.1.311 — Adds idle-hold options for self-hosted runners and fixes startup freezes and stalled interrupts (10/26)●2.1.307 — Fixes MCP failing to reconnect after sleep or a keychain lock (10/21). 2.1.306 also fixed the false claude -p login message●SONNET 4.5 — Retires on the Claude API on 11/30, 33 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●API — In API version 2026-10-21, passing temperature, top_p or top_k returns a 400 error●NEW — I Put a Ten-Line Promise File at the To…
Articles/API & SDK
⬡ API & SDK/2026-10-28Intermediate

When web fetch fails, a 400 and a 200 point to different places

Does adding web fetch to tools return a 400, or a 200 with an error_code inside the result block? A two-layer triage across org settings, domain rules, and where a URL came from, plus a small Python helper whose branches I checked.

Claude API137web fetchTroubleshooting24400 errorDomain filtering

I was re-reading the Platform release notes one night when the October 20 entry stopped me. An organization admin can now turn web fetch off for the whole organization from the Capabilities page in the Console. With it off, a Messages API request that includes the web fetch tool fails with a 400.

As an indie developer running small research scripts, I'm both the person who touches the admin page and the person who writes the code. On a team key, those are two different people. And if a 400 showed up with nothing wrong in the code, I know exactly where I'd start looking: the date-stamped type string on the tool.

So I went back through the docs and sorted out how the failures present themselves. What I found is that web fetch failures show up in two layers, and each layer sends you somewhere completely different.

What I'd like to say up front is this: if the organization has switched web fetch off, you get a 400 even when your code is fine.

Two layers of failure

Here is how the docs break down.

LayerWhat you seeWhat it means
Whole requestHTTP 400, invalid_request_error, with a message saying web fetch is not enabledAn org admin turned web fetch off on the Capabilities page in the Console
Result blockHTTP 200, with an error_code inside web_fetch_tool_result_errorThe tool was available, but fetching that URL did not go through

Because the second layer comes back as a 200, exception handling alone will not notice it. Claude receives the error result and carries on with the turn, so the user just sees a sentence saying the page could not be fetched while your program counts the call as a success.

The error codes, and where to look first

I reordered the documented codes by where I would look first.

error_codeWhat is happeningLook here first
url_not_allowedBlocked by domain rules, by your org's settings, or by Anthropic-side limits (private addresses, robots.txt, URLs that look like they contain a credential)allowed_domains / blocked_domains and the org-level lists. The URL has to pass both
url_not_in_prior_contextThe URL did not appear earlier in the conversation, or only appeared in a source excluded by url_sourcesWhether the URL is in a user message or in an allowed tool_result
url_not_accessibleAn HTTP error from the targetOpen the URL in a browser
max_uses_exceededOver max_uses; failed fetches count tooThe limit, and how often the same URL was retried
unsupported_content_typeAnything other than text, HTML, or PDFWhether the link points at an image or a zip
invalid_tool_input / url_too_longMalformed URL, or longer than 250 charactersAn oversized query string
too_many_requests / unavailableRate limit or internal errorWait and retry

The one that trips people up is url_not_in_prior_context. A URL that exists only in Claude's own output or only in the system prompt cannot be fetched. If you want a system-prompt URL fetched, put the same URL in a user message too.

Putting the triage in a function

I stopped reading these by eye and added a small function that tells me which layer failed. It catches BadRequestError for the 400 case and walks the result blocks for the 200 case.

#!/usr/bin/env python3
"""Separate web fetch failures into a whole-request 400 and a result-block error_code."""
import anthropic
 
# For each error_code on a result block (HTTP 200), decide where to look first
NEXT_STEP = {
    "url_not_allowed": "Check allowed_domains / blocked_domains AND the org-level allow/block lists",
    "url_not_in_prior_context": "Confirm the URL is in a user message or an allowed tool_result",
    "url_not_accessible": "HTTP error from the target. Open the URL by hand",
    "max_uses_exceeded": "Revisit max_uses. Failed fetches are counted",
    "unsupported_content_type": "Only text / HTML / PDF can be fetched",
    "invalid_tool_input": "Check the URL spelling and scheme (http / https)",
    "url_too_long": "The URL is over 250 characters",
    "too_many_requests": "Wait a moment and retry",
    "unavailable": "Internal error. Retry after a pause",
}
 
def triage(call):
    """call is a zero-argument function that calls the Messages API. Returns (layer, detail)."""
    try:
        resp = call()
    except anthropic.BadRequestError as e:
        msg = str(getattr(e, "message", e))
        low = msg.lower()
        if "web fetch" in low and "not enabled" in low:
            return ("org-setting", "Web fetch is off for the org. Ask an admin to check the Capabilities page")
        return ("other-400", msg[:200])  # other 400s, such as temperature, land here
    found = []
    for b in getattr(resp, "content", []) or []:
        if getattr(b, "type", "") != "web_fetch_tool_result":
            continue
        c = getattr(b, "content", None)
        if getattr(c, "type", "") == "web_fetch_tool_result_error":
            code = getattr(c, "error_code", "?")
            found.append((code, NEXT_STEP.get(code, "See the error list in the docs")))
    return ("result-error", found) if found else ("ok", None)

You use it by wrapping the call: triage(lambda: client.messages.create(...)).

There are two reasons it looks like this. First, I don't want to lean on the wording of a 400: only the two phrases web fetch and not enabled mark an org setting, and every other 400 falls into other-400. Second, I want a failure inside a 200 to leave at least one line in the log every time.

What I verified, and what I did not

Let me be plain about this. My own organization has not turned web fetch off, so I have not reproduced the org-setting 400 myself. That layer is described from the documentation.

What I did check: I built a BadRequestError by hand, and a message containing "web fetch is not enabled" came back as org-setting while a different message came back as other-400. A response with a url_not_allowed result block came back as result-error, and a response with no error came back as ok. All four returned what I expected.

So the branches work, but I have not seen the real 400 wording. Before you rely on it in production, I'd suggest looking at the actual message once in a test organization where you can flip the setting.

The order I check now

When web fetch does not go through, I look in this order.

  1. Is it a 400 or a 200? If it is a 400 that mentions web fetch, ask an admin about the org setting instead of editing code
  2. If it is a 200, match the error_code against the table
  3. For url_not_allowed, check the request-level domain lists and the org-level lists — both
  4. For url_not_in_prior_context, trace how the URL entered the conversation

Steps 2 and 3 are easy to confuse. The docs say a URL has to pass the organization's allow and block settings as well as the tool's own allowed_domains / blocked_domains. A URL you allowed on the tool can still come back as url_not_allowed if the org side rejects it.

For the design side of web fetch, I wrote Pulling live context in with the Web Fetch tool, and for reading other 400s there is Tracing invalid_request_error back to its cause. A 400 that comes from the API version itself is closer to my notes on sweeping call sites before temperature becomes a 400.

Closing

Split the failure by its HTTP layer first, and only then suspect the code. I try to hold on to that line even on a tired night.

If you'd like a first step, wrap one existing call in triage and log the error_code whenever a 200 contains one. If nothing ever shows up, that is itself a useful confirmation that the calls are going through.

Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

⬡ API & SDK2026-04-26
When Claude's API Returns 'Spanner Temporarily Unavailable': Triage and Retry Design
What to check first when the Claude API returns 'Spanner temporarily unavailable': telling your code apart from the provider side, exponential backoff with jitter, model fallback, and when a nightly batch should simply wait.
⬡ API & SDK2026-10-27
Three Places Python SDK 2.0.0 Breaks Your Code — A Checking Order Built on a Mocked Transport
Upgrading to Python SDK 2.0.0 changes behavior in three places: the temperature argument, the auth header, and double credentials. Here are the outputs I reproduced on a mocked transport, the fix for proxy setups, and the order I check in.
⬡ API & SDK2026-10-25
Before upgrading the SDK turns temperature into a 400, I swept my call sites
With API version 2026-10-21, sending temperature, top_p, or top_k returns a 400 even at the default value. Here is how the switch happens through an SDK upgrade, a small scanner for the lines to fix first, and the order I'd fix them in.