If you are classifying product images or generated artwork through the Claude API as part of an app pipeline, you have probably seen a previously stable code path start returning 400 invalid_request_error: Could not process image the moment you switched from base64 to URL inputs. I get this question often.
In my own case, the first three hours of a wallpaper-classification job — about 800 images — were rejected outright the day I migrated from base64 to URL sources. The cause turned out to be embarrassingly mundane: my CDN was returning a 302 for a subset of regions, and Anthropic's fetcher could not follow the chain to a 200.
These errors converge on four categories: URL scheme, MIME type, file size, or fetch-time reachability. Going through them in order usually clears the failure inside ten minutes. Drawing on lessons from running iOS and Android apps since 2014, here is the diagnostic flow I rely on.
What the error messages typically look like
When you supply an image via URL, the API returns one of three families of errors.
{
"type": "error",
"error": {
"type": "invalid_request_error",
"message": "messages.0.content.0.source.url: Could not process image https://example.com/photo.jpg"
}
}{
"type": "error",
"error": {
"type": "invalid_request_error",
"message": "messages.0.content.0.source.url: Image must be a valid HTTPS URL"
}
}{
"type": "error",
"error": {
"type": "invalid_request_error",
"message": "Image exceeds 5 MB size limit"
}
}The wording narrows things down quickly. Could not process means "the URL fetched, but the bytes were rejected"; must be a valid HTTPS URL means "the URL itself is malformed or non-HTTPS"; exceeds X MB is obviously a size issue.
Starting point: curl the URL before you trust the API call
Staring at the error string alone never gives me confidence. The first thing I do is reproduce, from my own terminal, what Anthropic's fetcher sees.
curl -sIL -A "Anthropic-Image-Fetcher/1.0" "https://example.com/photo.jpg"I check four things: did the response chain land on HTTP/2 200, what is the Content-Type, what is the Content-Length, and how long is the redirect chain. Without those four anchors, no API-side fix will reliably work.
Cause 1: URL is still HTTP
The Claude API requires HTTPS for URL-sourced images. When images come from a CMS or self-hosted media server, it is shockingly common to find a hardcoded http:// left over from local development.
# NG
content = [{
"type": "image",
"source": {"type": "url", "url": "http://example.com/photo.jpg"},
}]The fix is mechanical: force https://. I usually wire in a small normalizer so future code paths can never reintroduce the bug.
from urllib.parse import urlparse, urlunparse
def to_https(url: str) -> str:
p = urlparse(url)
if p.scheme == "https":
return url
if p.scheme == "http":
return urlunparse(p._replace(scheme="https"))
raise ValueError(f"Unsupported scheme: {p.scheme}")If you use presigned URLs from S3 or R2, lock the bucket policy down so only HTTPS URLs can be issued.
Cause 2: MIME type is unsupported
Claude accepts image/jpeg, image/png, image/gif, and image/webp for URL-sourced images. HEIC, AVIF, BMP, TIFF, and SVG are rejected.
The sneaky variant of this bug is "the extension is .jpg but the CDN serves application/octet-stream." The API honors the response header, not the extension.
# expected: Content-Type: image/jpeg
curl -sI "https://example.com/photo.jpg" | grep -i content-typeIf you see application/octet-stream or binary/octet-stream, fix the upload-side metadata. On Cloudflare R2 and AWS S3 I make sure every upload carries an explicit --content-type image/jpeg.
If your images flow in directly from iPhones, expect HEIC files to slip through unless your pipeline re-encodes to JPEG or PNG using heif-convert or Sharp before publication. Skipping that step guarantees a quarterly incident.
Cause 3: File exceeds the 5 MB limit
URL-sourced images max out at 5 MB per file and 8000 px on the longest side. My wallpaper apps deal with 4K source assets, so a raw image will routinely exceed the limit.
I gate the upload with a HEAD request first.
import httpx
async def is_within_limit(url: str, max_mb: int = 5) -> bool:
async with httpx.AsyncClient(follow_redirects=True, timeout=10) as c:
r = await c.head(url)
size = int(r.headers.get("content-length", "0"))
return 0 < size <= max_mb * 1024 * 1024If the image is too large, route through an on-the-fly variant that downsizes to 2000 px wide at JPEG quality 85. Cloudflare Images' ?width=2000&quality=85 style URL is convenient for this.
Cause 4: URL requires auth or fails on redirect
If your CDN restricts access by IP, validates Referer/Origin headers, or relies on signed URLs whose expiry has lapsed, Anthropic's fetcher will hit 401/403. Redirect chains that bounce through HTML login pages (Cloudflare Access, for example) cannot be followed.
The cleanest verification is a plain curl with no extra headers, exactly mimicking an unauthenticated client.
curl -sI -L "https://example.com/photo.jpg" | head -5If you see HTTP/2 401, HTTP/2 403, or a HTTP/2 302 chain that never lands on 200, the URL form simply cannot be used as is. Options:
- Mirror the image to a public bucket and clean it up 24 hours later via a lifecycle rule.
- Issue presigned URLs with at least 5 minutes of remaining validity — fetches are not always immediate.
- Give up on URL sources and download the bytes yourself, then send them as
source.type: base64.
The fetcher will follow up to five HTTP redirects, but redirects to HTML login flows are not followed. Pre-resolve to the final URL before passing it in.
Cause 5: On-the-fly transforms are too slow on first hit
A subtle gotcha: when you pass something like https://example.com/photo.jpg?width=2000&quality=85, the CDN's transform worker can take 5–10 seconds the first time, and the Anthropic fetcher gives up.
In my pipelines I warm the cache from my own client before invoking the API.
async def warmup_and_call(url: str):
async with httpx.AsyncClient(timeout=15) as c:
await c.get(url) # prime the CDN cache
response = await client.messages.create(...)It is the most boring fix imaginable — "go grab it yourself first" — but in my classification pipeline it dropped the failure rate from roughly 2% to under 0.1%.
Fallback when URL sources truly will not work
When every diagnostic above passes and the API still refuses the URL, I switch to base64. The code grows slightly, but the failure surface moves from the opaque Anthropic side to your own client, where you can log and retry.
import base64
import httpx
import anthropic
async def image_block_from_url(url: str) -> dict:
async with httpx.AsyncClient(follow_redirects=True, timeout=15) as c:
r = await c.get(url)
r.raise_for_status()
return {
"type": "image",
"source": {
"type": "base64",
"media_type": r.headers["content-type"].split(";")[0].strip(),
"data": base64.standard_b64encode(r.content).decode("ascii"),
},
}base64 inflates the payload by about 33%, but it removes the fetch-side unknowns entirely. In production I run a two-tier strategy: try the URL form first, fall back to base64 on failure.
Closing thought
Because URL-sourced invalid_request_error has limited visibility on Anthropic's side, the fastest path to a fix is usually to keep a curl -sIL snapshot of the failing URL handy. The moment you find yourself staring at the error string and guessing, run a HEAD from your own machine — within five minutes you will know whether the problem lives in the image origin or in the API call.
If you run into related troubleshooting around invalid_request shapes, you may also find Invalid request error messages for the Claude API and Diagnosing prompt cache misses on the Claude API useful.
Thank you for reading — I hope this saves you a frustrating afternoon.