https://www.citylens.devCityLens 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.
# 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.plyTo 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.
Authorization: Bearer … on every /v1/runs* and /v1/me call.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, /redoc, and /openapi.json via X-Docs-Key. Cannot create runs or read user data.# 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}'# 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
| Plan | Monthly runs | Concurrent | Demo views |
|---|---|---|---|
| Free | 5 / UTC month | 1 | unlimited, never count |
| Admin | unlimited | unlimited | unlimited |
- 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
429withdetail.code = "MONTHLY_QUOTA_EXCEEDED"and the currentmonth_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.
{
"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
/v1/healthpubliccurl -s https://www.citylens.dev/v1/health{ "ok": true, "version": "0.1.0" }/v1/demo/featuredpublicFeatured). Safe to share publicly. Used by the home-page demo carousel.curl -s https://www.citylens.dev/v1/demo/featured | jq{
"Featured": [
{
"run_id": "5f079d78d89c4387a9c0ddd5e3507b5e",
"label": "Brooklyn brownstones — Flatbush",
"address": "100 E 21st St Brooklyn, NY 11226",
"imagery_year": 2024,
"baseline_year": 2017,
"segmentation_backend": "sam2",
"outputs": ["previews", "change", "mesh"]
}
/* … */
]
}/v1/demo/runs/{run_id}publiccurl -s https://www.citylens.dev/v1/demo/runs/5f079d78d89c4387a9c0ddd5e3507b5e | jq '.qa.change_counts'{
"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…"
}
}/v1/demo/artifacts/{run_id}/{name}publicname is one of change.geojson, mesh.ply, preview.png, run_summary.json.curl -L https://www.citylens.dev/v1/demo/artifacts/5f079d78d89c4387a9c0ddd5e3507b5e/mesh.ply -o mesh.ply/v1/run-optionspublicPOST /v1/runs. Useful for clients that want to build their own form.curl -s https://www.citylens.dev/v1/run-options | jq/v1/merequires bearercurl -s https://www.citylens.dev/v1/me \
-H "Authorization: Bearer $TOKEN"{
"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
}
}/v1/runsrequires bearerrun_id — poll GET /v1/runs/{run_id} for status. Trigger failures refund the slot automatically.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"]
}'{
"run_id": "9f3a…",
"status": "queued",
"stage": "queued",
"progress": 0,
"created_at": "2026-04-30T18:25:57.370891Z"
}imagery_year: 2023, aoi_radius_m, sam2_cfg) return 400 INVALID_RUN_OPTION./v1/runsrequires bearercurl -s https://www.citylens.dev/v1/runs \
-H "Authorization: Bearer $TOKEN"/v1/runs/{run_id}requires bearer404if the run isn't owned by the signed-in user.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.
| Code | HTTP | When |
|---|---|---|
| INVALID_RUN_OPTION | 400 | POST /v1/runs body contains a field or value outside the locked schema. |
| — | 401 | Missing or invalid Authorization header on a protected route. |
| — | 404 | run_id not owned by the signed-in user, or doesn't exist. |
| MONTHLY_QUOTA_EXCEEDED | 429 | Free plan has used all 5 runs for the current UTC calendar month. |
| CONCURRENT_LIMIT_EXCEEDED | 429 | Free plan already has a queued or running job. |
| LIDAR_NO_COVERAGE | run.error | Address is outside the configured LAS index layer. Slot is refunded. |
| WORKER_FAILED | run.error | Generic worker failure (timeout, transient infra). Slot is refunded on view. |
{
"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.
{
"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.