documentation

Search the
camera index.

Cameras are everywhere, but their feeds are fragmented and hard to use. OpenEye is building an agent-first visual layer for the planet. Find cameras by coordinates, place, bounding box, route, category, or text through REST or the MCP server; use WHEP when you need live video.

Coverage combines deliverable open-data feeds with source-only public camera pages, including validated official directories such as France's ASFA motorway map and official zoo and aquarium wildlife cams from San Diego, Smithsonian, El Paso, Aquarium of the Pacific, Monterey Bay, Georgia Aquarium, Toledo Zoo, and Wonders of Wildlife. Explore.org's complete 238-record active index contributes 236 source-only records with working official pages, stable feed identities, coordinates where fixed, and retrievable public previews. Every pin has a public path to an image, stream, or per-camera viewer; generic promotional video links do not count as camera-health evidence. Directory media is maintained every 72 hours. Loading a pullable OpenEye-hosted proxy preview refreshes a stale snapshot inline, marks that camera active before cached bytes return, and keeps subsequent pulls on the camera's configured cadence. Opening any eligible directory preview also queues a throttled refresh when its verified image is over 24 hours old. Eligible still responses are reused byte-for-byte as the preview with their upstream Content-Type; there is no resize, watermark, recompression, Images transformation, or second fetch. Every source, including ODOT TripCheck, receives the same seven-day unchanged-image window before being hidden as frozen. Video preview extraction remains a separate pipeline.

Check the Portland I‑5 commute monitorClosure countdown followed by a Powell's Index section with no eyebrow label, its route map, ten full-width route graphs stacked one per line, ODOT corridor times, and then curated camera pressure zones with guaranteed I‑5 freeway views. Includes 24h, 7d, and All time views, green faster-than-usual sections, red slower-than-usual sections, readable dark map popups, labeled axes, exact hover or tap values, and a clean I‑5 shutdown-sign social preview.Open →
§ Overview

The database normalizes cameras from otherwise incompatible networks into one searchable record shape. Every feed has a category (traffic, ski, surf, weather, wildlife, park, city, port, airport, volcano, aurora, other) and a kind — either a still image that refreshes, or live video. Each feed also has a human-readable handle (e.g. vail-windy-7k2) that people can open on the site and agents can resolve through MCP or /v1/feeds/:handle.

Public and community-submitted feeds are free to access. Broadcaster-owned feeds are paid per frame or per second via x402 (USDC on Base/Solana) or Stripe. Payment happens at read time — the 402 handshake is documented below.

§ REST API

All endpoints return JSON unless noted. Public read endpoints are anonymous.

GET/v1/catalog/map

Lean projection for rendering many pins. Filter by bbox or category; add zoom + cluster to scale the result to a viewport.

# ski cams in Colorado
curl "https://api.openeye.cam/v1/catalog/map?category=ski&bbox=-109,37,-102,41&limit=500"

# {
#   "mode": "items",
#   "items": [
#     { "id": "stream_01...", "handle": "breck-ski-7k2",
#       "title": "Breck — Peak 8 base",
#       "lat": 39.4755, "lon": -106.0679, "category": "ski",
#       "is_free": true }
#   ],
#   "total": 96, "free": 96
# }
# Dots only — price, attribution and preview come from
# GET /v1/catalog/{id} when you open one.

# The directory contains hundreds of thousands of cameras, too many to draw at once.
# Pass the map's zoom and let the server pick the level of detail:
# count bubbles below zoom 10, then one camera per grid cell.
curl "https://api.openeye.cam/v1/catalog/map?bbox=-125,24,-66,50&zoom=4&cluster=auto"

# {
#   "mode": "clusters",
#   "clusters": [
#     { "id": "stream_01...", "lat": 40.71, "lon": -74.01,
#       "count": 1832, "free": 1830,
#       "categories": { "traffic": 1510, "city": 212, "weather": 110 } }
#   ],
#   "total": 33336, "free": 33200
# }
# total/free are the real counts for the query either way —
# never the size of the sample you were handed.
GET/v1/catalog/random

One uniformly random public, live, non-dead camera. The homepage featured set is excluded whenever another public camera is available; it is only a last-resort fallback.

curl https://api.openeye.cam/v1/catalog/random
# { "id": "stream_01...", "title": "...", "view": { ... }, ... }
GET/v1/catalog/featured

Curated visual showcase. Every returned row is render:image. OpenEye-hosted rows normally use an authoritative verified preview object; fresh legacy rows awaiting pointer backfill can use the preview route's ring-frame fallback. candidate_count and omitted_count keep filtered failures visible to monitoring.

curl https://api.openeye.cam/v1/catalog/featured
# { "items": [...], "candidate_count": 24, "omitted_count": 1 }
GET/v1/catalog/categories

Counts per category. Use for filter sidebars.

curl https://api.openeye.cam/v1/catalog/categories
# { "items": [{ "category": "traffic", "count": 789 }, ...],
#   "total": 45409, "countries": 71 }
GET/v1/catalog?near=lat,lon&radius_km=N

Spatial search via H3. near= rows return nearest-first, filtered to the exact radius, each with distance_km (single page; sort=popularity re-enables cursor paging). Also ?bbox=w,s,e,n, ?q= for FTS, ?embeddable=1 for showable-only. Permissive CORS. THE SAME enriched row shape is returned by every camera endpoint — list, featured (homepage), search, corridor, nearest, and /catalog/:id — so you never make a second call to render one. Featured is stricter: it only returns render:image rows, verifies storage for pointer-backed OpenEye images, and temporarily admits fresh legacy ring frames awaiting preview backfill.

curl "https://api.openeye.cam/v1/catalog?near=39.6,-106.3&radius_km=50"

# Every row (here and from search, /catalog/:id, featured, nearest, …):
# {
#   "id": "stream_01…", "title": "Loveland Pass — summit",
#   "lat": 39.66, "lon": -105.88, "category": "traffic", "is_free": true,
#   "timezone": "America/Denver",          // civil IANA zone derived from coordinates
#   "live": true,                         // has a recent frame → render it
#   "last_frame_age_s": 12, "frame_interval_s": 30,
#   "frame_ts": 1710000000000,            // shown bytes; format in timezone above
#   "preview_url": "…/preview.webp?v=1&exp=…&sig=…",
#   "preview_refresh": {                  // /catalog/:id only; failed pulls retry after 60s
#     "status": "queued",                 // queued|retrying|current|unsupported
#     "stale_after_s": 86400
#   },
#   "view": {                             // ← switch on this to render anything
#     "render": "image",                  //   image=<img> | link=<a> | none=skip
#     "url": "…/preview.webp?v=1&exp=…&sig=…",
#     "url_type": "image",                //   (for link: image|html)
#     "hosted": "openeye"                 //   who serves the bytes (openeye|source)
#   },
#   "redistribution": { "preview_embed": true, "frame_reuse": "personal-cache",
#     "frame_access": "proxy",
#     "attribution": { "name": "CDOT", "url": "https://…", "required": true } }
# }
#
# Directory-only/source rows still appear on the map. If no sanctioned poster
# exists, preview_url is null and view.render is "link" or "none"; show a
# neutral unavailable card instead of hiding the camera.
#
# Open direct-image networks (NYC DOT, 511NY/ON/AB) render as an image the
# source itself serves — fetch it straight from them, keep the attribution:
#   "view": { "render": "image", "url": "https://webcams.nyctmc.org/api/cameras/…/image",
#             "url_type": "image", "hosted": "source" }
#
# A restricted cam (hotlink-hostile source) instead returns:
#   "view": { "render": "link", "url": "https://…", "url_type": "html", "hosted": "source" }
GET/v1/catalog/nearest?near=lat,lon

The single closest live camera to a point, with an auto-expanding radius — no radius_km tuning. Also ?place=<name>. Defaults to free cams; is_free=0 includes paid. Returns { item, radius_km } with distance_km.

curl "https://api.openeye.cam/v1/catalog/nearest?near=40.7561,-73.9857"
curl "https://api.openeye.cam/v1/catalog/nearest?place=Times%20Square"
GET/v1/streams/:id/preview.webp

Header-free sample backed by a verified last-known-good asset. Loading a pullable OpenEye-hosted proxy preview first refreshes it inline when the stored frame is older than twice its configured cadence; refresh failure still serves the last-known-good image. The request also marks it active for subsequent cadence-based pulls. Every free preview preserves source bytes and Content-Type with no Images call, resize, recompression, or watermark; .webp/.jpg are route aliases. Only paid samples are delayed, downscaled, and watermarked. Placeholder cards are locally encoded PNGs and also bypass Images.

# preview_url is ready to paste anywhere — no keyed proxy needed:
# <img src="https://api.openeye.cam/v1/streams/STREAM_ID/preview.webp?v=1&exp=...&sig=..." />

# Swap .webp → .jpg on the same link, or revalidate with the ETag:
curl -H 'If-None-Match: "<etag>"' "<preview_url>"   # → 304 Not Modified when unchanged

# No frame right now? You get the "camera unavailable" card, not a broken image:
#   x-openeye-placeholder: 1
#   x-openeye-reason: camera_down | no_frame_yet | no_preview | sample_disabled | link_expired
# Real last-known-good bytes report:
#   x-openeye-preview-state: ready | stale
#   x-openeye-captured-at: 1787290313932
# Want the error instead? ask for JSON:
curl -H 'accept: application/json' "<preview_url>"  # → 404/410 problem+json

# Paid previews are downscaled, watermarked, and delayed. Free previews preserve source bytes and Content-Type; trust Content-Type rather than the route suffix.
# Pace refreshes off frame_interval_s; display x-openeye-captured-at honestly.
GET/v1/offline.webp

The “camera unavailable” card on its own — public, unsigned, cacheable, always an image. Point an <img> onerror at it when the picture you are rendering is not ours to repair: a view.hosted:'source' cam whose upstream host is down.

<img src="{cam.view.url}"
     onerror="this.src='https://api.openeye.cam/v1/offline.webp'">

# ?reason= picks the caption (default camera_down):
# camera_down | no_frame_yet | no_preview | sample_disabled | link_expired
GET/v1/streams/:id/frame

Single frame as image bytes. API key required; free for public streams, 402 for paid.

# Get a free API key once (open signup), then set $OPENEYE_KEY
curl -X POST "https://api.openeye.cam/v1/agents/register" \
  -H "content-type: application/json" -d '{"email":"you@example.com"}'

# Public stream — free, but the key is still required
curl "https://api.openeye.cam/v1/streams/STREAM_ID/frame?format=webp&max_w=1024" \
  -H "Authorization: Bearer $OPENEYE_KEY" -o frame.webp

# Paid stream — same key, plus an x402 client that settles payment
const fetch402 = withX402(fetch, { wallet: '0x...' });
await fetch402("https://api.openeye.cam/v1/streams/STREAM_ID/frame", {
  headers: { authorization: 'Bearer ' + process.env.OPENEYE_KEY },
});
GET/v1/streams/:id/frames

Session-mode NDJSON stream. Metered per-frame against a reservation.

curl "https://api.openeye.cam/v1/streams/STREAM_ID/frames?fps=2&duration_s=60&format=webp_b64" \
  -H "Authorization: Bearer $OPENEYE_KEY" -H "Accept: application/x-ndjson"
POST/v1/streams/:id/frame/analyze

Frame + vision model. Frontier: Gemini 3.8 Flash; fast: Gemini 3.5 Flash-Lite; edge: Moondream 3.1. Reserve-then-refund pricing.

curl -X POST "https://api.openeye.cam/v1/streams/STREAM_ID/frame/analyze" \
  -H "Authorization: Bearer $OPENEYE_KEY" \
  -H "content-type: application/json" \
  -d '{
    "prompt": "Is the road icy? Count cars.",
    "model": "fast",
    "max_tokens": 256
  }'
POST/v1/streams/:id/whep/offer

WHEP signaling for live video (live_video feed_kind only). Per-second billing.

# SDP offer in request body; SDP answer in response body.
# Requires a live_video stream. See docs/spec/whep.md.
POST/v1/subscriptions

Condition subscription — on your cadence, POST a signed webhook. trigger_mode=vlm runs analyze_frame (billed, always/on-match); trigger_mode=change is FREE and fires only when the frame changes (no VLM, no prompt). Push instead of poll. min cadence 60s. GET/PATCH/DELETE /v1/subscriptions[/:id] to manage.

curl -X POST "https://api.openeye.cam/v1/subscriptions" \
  -H "Authorization: Bearer $OPENEYE_KEY" -H "content-type: application/json" \
  -d '{
    "stream_id": "STREAM_ID",
    "prompt": "is the parking lot more than half full?",
    "cadence_s": 300,
    "match_only": true,
    "webhook_url": "https://your.app/openeye-hook"
  }'

# Response includes webhook_secret (shown once). Each delivery carries
# X-OpenEye-Signature: sha256=<hmac over the raw body> — recompute to verify.
GET/v1/traffic/portland-i5/powells-index

Public Powell's Index behind the I-5 monitor: a stripped-down incident-clock hero followed immediately by a Powell's Index section that starts directly with its heading, without an eyebrow label. Ten named server-sampled routes—including Alberta Co-op, Kenton's Paul Bunyan Statue, Tienda Santa Cruz, Mall 205, Division, the Hollywood Theatre, and Beaverton City Library—appear together on one Google map with current minutes and high-contrast dark marker popups. Full-width 24-hour, seven-day, or all-time graphs and a glanceable four-route ODOT scoreboard follow. Non-overlapping camera zones sit below the travel data, prioritize the views useful to the closure, and use compact 2×2 grids on phones, with badges reserved for refreshing, out-of-date, or unknown-time images. The page rechecks camera metadata five seconds after first load so inline image refreshes are reflected immediately. A dedicated Rose Quarter query guarantees visible slots for I-5 at Rose Quarter—Weidler, Winning Way, and the southbound ramp—plus I-5 at Broadway. Every route card uses a prominent origin heading, a separated Now/Usual/Difference data row, green faster-than-usual line sections, red slower-than-usual sections, a gray usual baseline, labeled axes, exact date spans, and hover, tap, or keyboard sample inspection. The monitor has a dedicated 1200×630 Open Graph preview with an I-5 shield above SHUTDOWN in jagged metal lettering over a dark grid of Portland traffic-camera views. No API key.

curl "https://api.openeye.cam/v1/traffic/portland-i5/powells-index?hours=all&route_id=alberta&step_minutes=60"
# hours: 0–168 or all; step_minutes: 5–1440 in five-minute increments
# → { cadence_s: 300, sampled_at, history_hours: "all", history_step_s: 3600, routes: [
#      { origin_label, current_minutes, usual_minutes,
#        difference_minutes, maps_url, route_polyline, history: [...] }
#    ] }
§ MCP server

Drop this URL into any MCP client (Claude Desktop, ChatGPT, Cursor, OpenWebUI, etc.). No SDK needed.

https://api.openeye.cam/mcp

Example: Claude Desktop config at ~/Library/Application Support/Claude/claude_desktop_config.json:

{
  "mcpServers": {
    "openeye": {
      "url": "https://api.openeye.cam/mcp"
    }
  }
}
§ MCP tool reference

Free tools return data synchronously. Paid tools enter the 402 bridge.

list_streams
Paginated list of active streams. Optional tag / camera_kind / is_mobile filters.
FREE
search_streams
Full-text search over title, description, tags (FTS5).
FREE
find_streams_near
Spatial lookup via H3 — cameras within `radius_km` of (lat, lon).
FREE
find_streams_in_bbox
Spatial lookup inside a w/s/e/n bounding box.
FREE
find_streams_by_category
Filter by category (ski, traffic, surf, wildlife, …). Optional bbox.
FREE
list_categories
Counts per category — use to show a filter UI.
FREE
get_stream
Single stream metadata.
FREE
get_camera_position
Latest (lat, lon, heading) fix for a mobile camera.
FREE
get_powells_index
Portland drive times to Powell's, sampled server-side every five minutes with 24-hour, seven-day, or all-time history.
FREE
get_frame
Fetch a watermarked frame. Returns a signed URL.
$0.01 · per frame
open_frame_stream
Open an NDJSON pipe of frames at a requested fps for up to N seconds.
$0.01 · per frame
open_live
Start a WHEP session for live video.
$0.002 · per second
analyze_frame
Fetch a frame and run a VLM prompt. Reserve-then-refund.
$0.02 · per call
watch_stream
Condition subscription — run a prompt on a cadence and POST a signed webhook (always or on match). Runs bill to your balance.
FREE
list_subscriptions / unwatch_stream
List or cancel your condition subscriptions.
FREE
§ Payment

Public and user-submitted streams are free. For paid streams, two protocols are accepted side-by-side:

  • x402 (USDC on Base or Solana) — cryptographic, zero human in the loop, single-round-trip with dual-auth legs.
  • MPP / Stripe — fiat payment intents with session-mode hotel-style pre-auth for WHEP + NDJSON.

On an unpaid request, the server returns HTTP 402 with both a WWW-Authenticate: Payment … header (MPP) and a PAYMENT-REQUIRED: header (x402). Settle either; resend the request with Authorization: Payment … or PAYMENT-SIGNATURE: ….

import { withX402Client } from '@openeye/sdk';

const client = withX402Client({
  wallet: { privateKey: process.env.WALLET_KEY },
  network: 'base',
});

const frame = await client.getFrame({ streamId: 'stream_...' });
§ Submit a camera

No login required. Anyone can submit — admins can take down abuse.

POST/v1/submit-camera

Anonymous. IP-rate-limited to 20/hour.

curl -X POST https://api.openeye.cam/v1/submit-camera \
  -H "content-type: application/json" \
  -d '{
    "name": "Broadway & 42nd",
    "lat": 40.7561, "lon": -73.9857,
    "url": "https://example.com/cam.jpg",
    "feed_kind": "snapshot_pull",
    "category": "city",
    "snapshot_interval_s": 60
  }'

Or use the web form with a click-to-pin map.