# Pexels stock adapter fails on every request — missing `User-Agent` header (Cloudflare 401/403)

Author: churchofcoolCreated Aug 30, 2026Updated Aug 31, 2026
Labelsbug

Summary

Summary

tools/video/stock_sources/pexels.py sends requests to the Pexels API and CDN with no User-Agent header. The Python default (python-requests/x.y.z) is blocked by Pexels' edge (Cloudflare), so every search returns 401 Unauthorized / 403 Forbidden even with a valid PEXELS_API_KEY. Adding a browser-like UA makes the identical key return 200.

Impact

  • direct_clip_search and corpus_builder return zero clips whenever Pexels is a source.
  • Preflight (provider_menu_summary()) reports Pexels as configured because the env var is present, so the failure only surfaces mid-run at the asset stage.
  • Blocks the documentary-montage pipeline (Pexels is its primary source) and any pipeline relying on Pexels stock.

Repro

  1. Set a valid PEXELS_API_KEY in .env.
  2. Run any Pexels-backed search, e.g.:
    python
    from tools.tool_registry import registry
    registry.discover()
    registry._tools["direct_clip_search"].execute({
        "output_dir": "/tmp/probe",
        "queries": [{"query": "rain city street"}],
        "sources": ["pexels"],
    })
  3. Result: errors: [{"phase": "search", "source": "pexels", "error": "HTTPError: 401 Client Error: Unauthorized for url: https://api.pexels.com/videos/search?..."}]

Minimal confirmation:

python
import os, urllib.request
key = os.environ["PEXELS_API_KEY"]
req = urllib.request.Request("https://api.pexels.com/videos/search?query=rain&per_page=2",
                             headers={"Authorization": key})
urllib.request.urlopen(req)          # -> HTTPError 403
req.add_header("User-Agent", "Mozilla/5.0 ... Chrome/122.0 Safari/537.36")
urllib.request.urlopen(req)          # -> 200 OK

Cause

_headers() returns only {"Authorization": key}; the download path calls requests.get(...) with no headers at all. Neither sends a UA.

Fix

Send a browser-like User-Agent on both the API calls (via _headers(), used by _search_videos and _search_images) and the file download.

File: tools/video/stock_sources/pexels.py (against cd9f3c1)

diff
@@ -87,7 +87,16 @@ class PexelsSource:
         out_path.parent.mkdir(parents=True, exist_ok=True)
 
         with requests.get(
-            candidate.download_url, stream=True, timeout=120
+            candidate.download_url,
+            stream=True,
+            timeout=120,
+            headers={
+                "User-Agent": (
+                    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
+                    "AppleWebKit/537.36 (KHTML, like Gecko) "
+                    "Chrome/122.0 Safari/537.36"
+                )
+            },
         ) as r:
             r.raise_for_status()
             with open(out_path, "wb") as f:
@@ -107,7 +116,16 @@ class PexelsSource:
                 "PEXELS_API_KEY not set. Get a free key at "
                 "https://www.pexels.com/api/ and add it to .env."
             )
-        return {"Authorization": key}
+        # Pexels' edge (Cloudflare) 403s the default `python-requests/x`
+        # User-Agent, so send a browser-like UA alongside the API key.
+        return {
+            "Authorization": key,
+            "User-Agent": (
+                "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
+                "AppleWebKit/537.36 (KHTML, like Gecko) "
+                "Chrome/122.0 Safari/537.36"
+            ),
+        }

Follow-ups (not in this diff)

  • Consider a shared UA constant so other keyless/scraper adapters (unsplash, wikimedia, mixkit, coverr, …) don't rot the same way.
  • Preflight could do a live 1-request auth probe for Pexels so a blocked/invalid key shows as degraded instead of configured.

Operating system

macOS Tahoe 26.6.2

Pipeline

short documentary

Runtime / renderer

No response

Steps to reproduce

Summary

tools/video/stock_sources/pexels.py sends requests to the Pexels API and CDN with no User-Agent header. The Python default (python-requests/x.y.z) is blocked by Pexels' edge (Cloudflare), so every search returns 401 Unauthorized / 403 Forbidden even with a valid PEXELS_API_KEY. Adding a browser-like UA makes the identical key return 200.

Impact

  • direct_clip_search and corpus_builder return zero clips whenever Pexels is a source.
  • Preflight (provider_menu_summary()) reports Pexels as configured because the env var is present, so the failure only surfaces mid-run at the asset stage.
  • Blocks the documentary-montage pipeline (Pexels is its primary source) and any pipeline relying on Pexels stock.

Repro

  1. Set a valid PEXELS_API_KEY in .env.
  2. Run any Pexels-backed search, e.g.:
    python
    from tools.tool_registry import registry
    registry.discover()
    registry._tools["direct_clip_search"].execute({
        "output_dir": "/tmp/probe",
        "queries": [{"query": "rain city street"}],
        "sources": ["pexels"],
    })
  3. Result: errors: [{"phase": "search", "source": "pexels", "error": "HTTPError: 401 Client Error: Unauthorized for url: https://api.pexels.com/videos/search?..."}]

Minimal confirmation:

python
import os, urllib.request
key = os.environ["PEXELS_API_KEY"]
req = urllib.request.Request("https://api.pexels.com/videos/search?query=rain&per_page=2",
                             headers={"Authorization": key})
urllib.request.urlopen(req)          # -> HTTPError 403
req.add_header("User-Agent", "Mozilla/5.0 ... Chrome/122.0 Safari/537.36")
urllib.request.urlopen(req)          # -> 200 OK

Cause

_headers() returns only {"Authorization": key}; the download path calls requests.get(...) with no headers at all. Neither sends a UA.

Fix

Send a browser-like User-Agent on both the API calls (via _headers(), used by _search_videos and _search_images) and the file download.

File: tools/video/stock_sources/pexels.py (against cd9f3c1)

diff
@@ -87,7 +87,16 @@ class PexelsSource:
         out_path.parent.mkdir(parents=True, exist_ok=True)
 
         with requests.get(
-            candidate.download_url, stream=True, timeout=120
+            candidate.download_url,
+            stream=True,
+            timeout=120,
+            headers={
+                "User-Agent": (
+                    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
+                    "AppleWebKit/537.36 (KHTML, like Gecko) "
+                    "Chrome/122.0 Safari/537.36"
+                )
+            },
         ) as r:
             r.raise_for_status()
             with open(out_path, "wb") as f:
@@ -107,7 +116,16 @@ class PexelsSource:
                 "PEXELS_API_KEY not set. Get a free key at "
                 "https://www.pexels.com/api/ and add it to .env."
             )
-        return {"Authorization": key}
+        # Pexels' edge (Cloudflare) 403s the default `python-requests/x`
+        # User-Agent, so send a browser-like UA alongside the API key.
+        return {
+            "Authorization": key,
+            "User-Agent": (
+                "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
+                "AppleWebKit/537.36 (KHTML, like Gecko) "
+                "Chrome/122.0 Safari/537.36"
+            ),
+        }

Follow-ups (not in this diff)

  • Consider a shared UA constant so other keyless/scraper adapters (unsplash, wikimedia, mixkit, coverr, …) don't rot the same way.
  • Preflight could do a live 1-request auth probe for Pexels so a blocked/invalid key shows as degraded instead of configured.

Expected behavior

Summary

tools/video/stock_sources/pexels.py sends requests to the Pexels API and CDN with no User-Agent header. The Python default (python-requests/x.y.z) is blocked by Pexels' edge (Cloudflare), so every search returns 401 Unauthorized / 403 Forbidden even with a valid PEXELS_API_KEY. Adding a browser-like UA makes the identical key return 200.

Impact

  • direct_clip_search and corpus_builder return zero clips whenever Pexels is a source.
  • Preflight (provider_menu_summary()) reports Pexels as configured because the env var is present, so the failure only surfaces mid-run at the asset stage.
  • Blocks the documentary-montage pipeline (Pexels is its primary source) and any pipeline relying on Pexels stock.

Repro

  1. Set a valid PEXELS_API_KEY in .env.
  2. Run any Pexels-backed search, e.g.:
    python
    from tools.tool_registry import registry
    registry.discover()
    registry._tools["direct_clip_search"].execute({
        "output_dir": "/tmp/probe",
        "queries": [{"query": "rain city street"}],
        "sources": ["pexels"],
    })
  3. Result: errors: [{"phase": "search", "source": "pexels", "error": "HTTPError: 401 Client Error: Unauthorized for url: https://api.pexels.com/videos/search?..."}]

Minimal confirmation:

python
import os, urllib.request
key = os.environ["PEXELS_API_KEY"]
req = urllib.request.Request("https://api.pexels.com/videos/search?query=rain&per_page=2",
                             headers={"Authorization": key})
urllib.request.urlopen(req)          # -> HTTPError 403
req.add_header("User-Agent", "Mozilla/5.0 ... Chrome/122.0 Safari/537.36")
urllib.request.urlopen(req)          # -> 200 OK

Cause

_headers() returns only {"Authorization": key}; the download path calls requests.get(...) with no headers at all. Neither sends a UA.

Fix

Send a browser-like User-Agent on both the API calls (via _headers(), used by _search_videos and _search_images) and the file download.

File: tools/video/stock_sources/pexels.py (against cd9f3c1)

diff
@@ -87,7 +87,16 @@ class PexelsSource:
         out_path.parent.mkdir(parents=True, exist_ok=True)
 
         with requests.get(
-            candidate.download_url, stream=True, timeout=120
+            candidate.download_url,
+            stream=True,
+            timeout=120,
+            headers={
+                "User-Agent": (
+                    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
+                    "AppleWebKit/537.36 (KHTML, like Gecko) "
+                    "Chrome/122.0 Safari/537.36"
+                )
+            },
         ) as r:
             r.raise_for_status()
             with open(out_path, "wb") as f:
@@ -107,7 +116,16 @@ class PexelsSource:
                 "PEXELS_API_KEY not set. Get a free key at "
                 "https://www.pexels.com/api/ and add it to .env."
             )
-        return {"Authorization": key}
+        # Pexels' edge (Cloudflare) 403s the default `python-requests/x`
+        # User-Agent, so send a browser-like UA alongside the API key.
+        return {
+            "Authorization": key,
+            "User-Agent": (
+                "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
+                "AppleWebKit/537.36 (KHTML, like Gecko) "
+                "Chrome/122.0 Safari/537.36"
+            ),
+        }

Follow-ups (not in this diff)

  • Consider a shared UA constant so other keyless/scraper adapters (unsplash, wikimedia, mixkit, coverr, …) don't rot the same way.
  • Preflight could do a live 1-request auth probe for Pexels so a blocked/invalid key shows as degraded instead of configured.

Actual behavior

Summary

tools/video/stock_sources/pexels.py sends requests to the Pexels API and CDN with no User-Agent header. The Python default (python-requests/x.y.z) is blocked by Pexels' edge (Cloudflare), so every search returns 401 Unauthorized / 403 Forbidden even with a valid PEXELS_API_KEY. Adding a browser-like UA makes the identical key return 200.

Impact

  • direct_clip_search and corpus_builder return zero clips whenever Pexels is a source.
  • Preflight (provider_menu_summary()) reports Pexels as configured because the env var is present, so the failure only surfaces mid-run at the asset stage.
  • Blocks the documentary-montage pipeline (Pexels is its primary source) and any pipeline relying on Pexels stock.

Repro

  1. Set a valid PEXELS_API_KEY in .env.
  2. Run any Pexels-backed search, e.g.:
    python
    from tools.tool_registry import registry
    registry.discover()
    registry._tools["direct_clip_search"].execute({
        "output_dir": "/tmp/probe",
        "queries": [{"query": "rain city street"}],
        "sources": ["pexels"],
    })
  3. Result: errors: [{"phase": "search", "source": "pexels", "error": "HTTPError: 401 Client Error: Unauthorized for url: https://api.pexels.com/videos/search?..."}]

Minimal confirmation:

python
import os, urllib.request
key = os.environ["PEXELS_API_KEY"]
req = urllib.request.Request("https://api.pexels.com/videos/search?query=rain&per_page=2",
                             headers={"Authorization": key})
urllib.request.urlopen(req)          # -> HTTPError 403
req.add_header("User-Agent", "Mozilla/5.0 ... Chrome/122.0 Safari/537.36")
urllib.request.urlopen(req)          # -> 200 OK

Cause

_headers() returns only {"Authorization": key}; the download path calls requests.get(...) with no headers at all. Neither sends a UA.

Fix

Send a browser-like User-Agent on both the API calls (via _headers(), used by _search_videos and _search_images) and the file download.

File: tools/video/stock_sources/pexels.py (against cd9f3c1)

diff
@@ -87,7 +87,16 @@ class PexelsSource:
         out_path.parent.mkdir(parents=True, exist_ok=True)
 
         with requests.get(
-            candidate.download_url, stream=True, timeout=120
+            candidate.download_url,
+            stream=True,
+            timeout=120,
+            headers={
+                "User-Agent": (
+                    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
+                    "AppleWebKit/537.36 (KHTML, like Gecko) "
+                    "Chrome/122.0 Safari/537.36"
+                )
+            },
         ) as r:
             r.raise_for_status()
             with open(out_path, "wb") as f:
@@ -107,7 +116,16 @@ class PexelsSource:
                 "PEXELS_API_KEY not set. Get a free key at "
                 "https://www.pexels.com/api/ and add it to .env."
             )
-        return {"Authorization": key}
+        # Pexels' edge (Cloudflare) 403s the default `python-requests/x`
+        # User-Agent, so send a browser-like UA alongside the API key.
+        return {
+            "Authorization": key,
+            "User-Agent": (
+                "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
+                "AppleWebKit/537.36 (KHTML, like Gecko) "
+                "Chrome/122.0 Safari/537.36"
+            ),
+        }

Follow-ups (not in this diff)

  • Consider a shared UA constant so other keyless/scraper adapters (unsplash, wikimedia, mixkit, coverr, …) don't rot the same way.
  • Preflight could do a live 1-request auth probe for Pexels so a blocked/invalid key shows as degraded instead of configured.

Relevant logs or error output

bash