v0.1 · stablehttps://www.citylens.dev

CityLens API

A small, opinionated REST API for urban building change detection and 3D reconstruction from aerial imagery. Each run produces a classified change.geojson, a LOD1 mesh.ply, a rendered preview, and a reproducible audit trail of input SHA-256s. NYC-only for now (5 boroughs); extending to additional regions is on the roadmap.

Quickstart

Three commands, no sign-in required. Pulls a featured demo run and downloads its change.geojson and mesh.ply.

curl
# 1. List featured demo runs (public)
curl -s https://www.citylens.dev/v1/demo/featured | jq '.Featured[0]'

# 2. Get the demo run detail
curl -s https://www.citylens.dev/v1/demo/runs/5f079d78d89c4387a9c0ddd5e3507b5e | jq '{run_id, status, qa: .qa.change_counts}'

# 3. Download artifacts (signed URLs, no auth)
curl -L https://www.citylens.dev/v1/demo/artifacts/5f079d78d89c4387a9c0ddd5e3507b5e/change.geojson -o change.geojson
curl -L https://www.citylens.dev/v1/demo/artifacts/5f079d78d89c4387a9c0ddd5e3507b5e/mesh.ply       -o mesh.ply

To create a real run on a new address, you need a free account. Sign up at /sign-up and follow the authenticated flow below.

Authentication

CityLens has three credential surfaces. Each protects a different thing — they don't overlap and don't substitute for each other.

User login
normal users
Email + password via /sign-up. Browser exchanges credentials for a short-lived JWT, attached as Authorization: Bearer … on every /v1/runs* and /v1/me call.
Programmatic API keys
reserved
Mint Bearer keys (prefix clk_live_) at /account/api-keys. Each key inherits your plan + monthly quota; revoke any time. Send as Authorization: Bearer clk_live_… — same header as user JWTs.
Docs access key
ops only
Gates the engine's interactive /docs, /redoc, and /openapi.json via X-Docs-Key. Cannot create runs or read user data.
bearer auth example
# Get a token by signing in (browser does this automatically).
# Then attach it on protected routes:
curl -s https://www.citylens.dev/v1/me \
  -H "Authorization: Bearer $TOKEN" | jq '{email, plan_type, quota}'
user api key example
# Mint a key at /account/api-keys, then use it from any script:
curl -s https://www.citylens.dev/v1/me \
  -H "Authorization: Bearer clk_live_…" | jq '{user, quota}'

# API keys are interchangeable with JWTs — they hit the same routes
# and use the same plan/quota state. Revoke any time from the dashboard.

Plans & quotas

PlanMonthly runsConcurrentDemo views
Free5 / UTC month1unlimited, never count
Adminunlimitedunlimitedunlimited
  • Failed runs (e.g. addresses outside LiDAR coverage, worker timeouts) refund their slot the next time you view the run.
  • When you exceed the monthly limit the API returns 429 with detail.code = "MONTHLY_QUOTA_EXCEEDED" and the current month_key.
  • Inspect your live quota at any time with GET /v1/me.

Run options (server-locked)

The MVP exposes a fixed set of run parameters. Anything else is rejected with 400 INVALID_RUN_OPTION. Discover the current schema at GET /v1/run-options — the response below is live as of this writing.

GET /v1/run-options
{
  "imagery_years": [2024],
  "baseline_years": [2017],
  "segmentation_backends": ["sam2"],
  "outputs": ["change", "mesh", "previews"],
  "defaults": {
    "imagery_year": 2024,
    "baseline_year": 2017,
    "segmentation_backend": "sam2",
    "outputs": ["previews", "change", "mesh"],
    "aoi_radius_m": 250
  }
}

aoi_radius_mis server-fixed at 250m. Clients must not send it. Geographic scope is currently NYC's five boroughs (the GDB footprint baseline + NYS LiDAR coverage).

Endpoint reference

GET/v1/healthpublic
Service health
Liveness probe. Returns the deployed version.
Request
bash
curl -s https://www.citylens.dev/v1/health
Response
json
{ "ok": true, "version": "0.1.0" }
GET/v1/demo/runs/{run_id}public
Get a demo run detail
Full run document for one of the precomputed featured runs: request, status, artifacts (with public signed URLs), QA metrics, and the per-input audit trail.
Request
bash
curl -s https://www.citylens.dev/v1/demo/runs/5f079d78d89c4387a9c0ddd5e3507b5e | jq '.qa.change_counts'
Response
json
{
  "run_id": "5f079d78d89c4387a9c0ddd5e3507b5e",
  "status": "succeeded",
  "stage": "done",
  "progress": 100,
  "request": { "address": "100 E 21st St Brooklyn, NY 11226", … },
  "artifacts": [
    { "name": "change.geojson", "signed_url": "/v1/demo/artifacts/5f079d78d89c4387a9c0ddd5e3507b5e/change.geojson", … },
    { "name": "mesh.ply",       "signed_url": "/v1/demo/artifacts/5f079d78d89c4387a9c0ddd5e3507b5e/mesh.ply",       … },
    { "name": "preview.png",    "signed_url": "/v1/demo/artifacts/5f079d78d89c4387a9c0ddd5e3507b5e/preview.png",    … }
  ],
  "qa": {
    "change_counts": { "unchanged": 134, "modified": 0, "demolished": 0, "added": 2 },
    "orthophoto_sha256": "1d7b3564…",
    "baseline_sha256":   "c66eb293…",
    "lidar_sha256":      "3ae465d8…"
  }
}
GET/v1/demo/artifacts/{run_id}/{name}public
Download a demo artifact
Streams the GCS-backed artifact via a server-side proxy. No auth required for demo artifacts. name is one of change.geojson, mesh.ply, preview.png, run_summary.json.
Request
bash
curl -L https://www.citylens.dev/v1/demo/artifacts/5f079d78d89c4387a9c0ddd5e3507b5e/mesh.ply -o mesh.ply
GET/v1/run-optionspublic
Discover run-option schema
The fixed set of values the server will accept on POST /v1/runs. Useful for clients that want to build their own form.
Request
bash
curl -s https://www.citylens.dev/v1/run-options | jq
GET/v1/merequires bearer
Current user, plan, and live quota
Returns the signed-in user, their plan, and the current month's usage.
Request
bash
curl -s https://www.citylens.dev/v1/me \
  -H "Authorization: Bearer $TOKEN"
Response
json
{
  "user":  { "id": "…", "email": "you@example.com", "plan_type": "free", "is_admin": false },
  "quota": {
    "month_key": "2026-04",
    "monthly_run_limit": 5,
    "runs_used": 1,
    "runs_remaining": 4,
    "unlimited": false,
    "max_concurrent_runs": 1
  }
}
POST/v1/runsrequires bearer
Create a new run
Reserves a monthly quota slot and triggers the Cloud Run worker. Returns immediately with a run_id — poll GET /v1/runs/{run_id} for status. Trigger failures refund the slot automatically.
Request
bash
curl -s https://www.citylens.dev/v1/runs \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "address": "100 E 21st St Brooklyn, NY 11226",
    "outputs": ["previews", "change", "mesh"]
  }'
Response
json
{
  "run_id": "9f3a…",
  "status": "queued",
  "stage": "queued",
  "progress": 0,
  "created_at": "2026-04-30T18:25:57.370891Z"
}
Body fields outside the locked schema (e.g. imagery_year: 2023, aoi_radius_m, sam2_cfg) return 400 INVALID_RUN_OPTION.
GET/v1/runsrequires bearer
List your runs
Returns runs owned by the calling user, newest first. Demo runs are never returned here.
Request
bash
curl -s https://www.citylens.dev/v1/runs \
  -H "Authorization: Bearer $TOKEN"
GET/v1/runs/{run_id}requires bearer
Get one of your runs
Same shape as the demo run detail. Returns 404if the run isn't owned by the signed-in user.
Request
bash
curl -s https://www.citylens.dev/v1/runs/$RUN_ID \
  -H "Authorization: Bearer $TOKEN"

Errors

All errors come back with a stable detail.code string so clients can branch on outcomes without parsing messages.

CodeHTTPWhen
INVALID_RUN_OPTION400POST /v1/runs body contains a field or value outside the locked schema.
401Missing or invalid Authorization header on a protected route.
404run_id not owned by the signed-in user, or doesn't exist.
MONTHLY_QUOTA_EXCEEDED429Free plan has used all 5 runs for the current UTC calendar month.
CONCURRENT_LIMIT_EXCEEDED429Free plan already has a queued or running job.
LIDAR_NO_COVERAGErun.errorAddress is outside the configured LAS index layer. Slot is refunded.
WORKER_FAILEDrun.errorGeneric worker failure (timeout, transient infra). Slot is refunded on view.
429 example
{
  "detail": {
    "code": "MONTHLY_QUOTA_EXCEEDED",
    "message": "Free plan limit of 5 runs/month reached.",
    "plan_type": "free",
    "monthly_run_limit": 5,
    "runs_used": 5,
    "runs_remaining": 0,
    "month_key": "2026-04"
  }
}

Audit trail

Every CityLens run is byte-level reproducible. The QA block on each run records SHA-256s of every input asset the pipeline read, the geocoded XY of the address, the LiDAR tile id, the county footprint sources, and the SAM2 model mode. Two runs with the same QA hashes produce identical outputs.

run.qa fields
{
  "orthophoto_sha256":      "1d7b35644d882b9271927defe60fa7be2c18929d…",
  "baseline_sha256":        "c66eb2931eacf28752d4468e65ef63b24e13a23d…",
  "lidar_sha256":           "3ae465d8b6373ee114ee215f84350511578e5468…",
  "reference_case_id":      "100_e_21st_st_brooklyn_ny_11226",
  "baseline_footprints_used": true,
  "lidar_used":             true,
  "sam2_used":              true,
  "sam2_mode":              "prompted",
  "preview_source":         "change_classified",
  "change_counts":          { "unchanged": 134, "modified": 0, "demolished": 0, "added": 2 }
}

Useful when CityLens output is an input to a downstream legal, compliance, or insurance workflow — every claim about a building traces back to specific bytes from a known publisher and date.

Need to sign in? /sign-in. Forgot your password? /forgot-password. See an issue with the docs? Open one at joshvern/citylens-web.