documentation

Agents, meet
the world.

OpenEyes is a public camera directory for AI agents. Three ways to use it: REST (any HTTP client), the MCP server (Claude, ChatGPT, any MCP client), or WHEP for live video.

§ Overview

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 agents resolve via MCP or /v1/feeds/:handle.

Public and community-submitted feeds are free for agents. 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.

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

# {
#   "items": [
#     { "id": "stream_01...", "handle": "breck-ski-7k2",
#       "title": "Breck — Peak 8 base",
#       "lat": 39.4755, "lon": -106.0679, "category": "ski",
#       "attribution": { "name": "Breckenridge", "url": "..." },
#       "is_free": true, "price_per_frame_usd": 0,
#       "mcp_hint": "ask your MCP agent: show feed breck-ski-7k2" }
#   ]
# }
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.

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,
#   "live": true,                         // has a recent frame → render it
#   "last_frame_age_s": 12, "frame_interval_s": 30,
#   "frame_ts": 1710000000000,            // capture time of the shown bytes
#   "preview_url": "…/preview.webp?v=1&exp=…&sig=…",
#   "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 } }
# }
#
# 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 degraded sample — the signed preview_url on every catalog row. No key, no payment; embeds straight into an <img>, iframe, or unfurl. Also served as .jpg (same link), supports ETag/If-None-Match → 304, and the catalog link lasts 7 days (refetch to renew).

# 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

# Always downscaled + watermarked (and delayed for paid streams) — it never
# substitutes for the paid frame. Pace refreshes off frame_interval_s.
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 + VLM (Gemini / Claude / GPT). 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.
§ 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_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.