ZoneLens logo Site Screening · Risk Analysis · Feasibility

Developer Quickstart

From zero to first API call in 5 minutes. Covers zoning lookup, risk analysis, screening, and export.

On This Page

  1. Getting Started
  2. Zoning Lookup
  3. Interpretation & Risk Analysis
  4. Search Parcels
  5. Screening Workflow
  6. Export Results
  7. Cross-City Comparison
  8. Rate Limits
  9. Error Codes

1 Getting Started

ZoneLens uses two authentication methods depending on the endpoint type:

Step 1 — Create an account via magic link:

POST /api/v1/auth/magic-link
import requests # Request a magic login link resp = requests.post("http://localhost:8000/api/v1/auth/magic-link", json={ "email": "dev@example.com" }) print(resp.json()) # {"message": "Magic link sent", "token": "..."}
const resp = await fetch("http://localhost:8000/api/v1/auth/magic-link", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "dev@example.com" }) }); const data = await resp.json(); console.log(data); // { message: "Magic link sent", token: "..." }
curl -X POST http://localhost:8000/api/v1/auth/magic-link \ -H "Content-Type: application/json" \ -d '{"email": "dev@example.com"}'

Step 2 — Visit your Dashboard and generate an API key. Copy the key and store it securely.

Base URL: All examples use http://localhost:8000. Replace with your production domain in deployment. Use your_api_key_here as a placeholder — never commit real keys to source control.

2 Zoning Lookup API Key

Look up zoning rules for any parcel by ID and city code. Returns the district, FAR, height limits, setbacks, and overlays.

GET /api/v1/zoning/{parcel_id}?city=nyc
import requests API_KEY = "your_api_key_here" BASE = "http://localhost:8000/api/v1" resp = requests.get( f"{BASE}/zoning/1008720030", params={"city": "nyc"}, headers={"X-API-Key": API_KEY}, ) data = resp.json() print(f"District: {data['zoning_district']}") print(f"FAR: {data['far_base']}") print(f"Height: {data['height_limit_ft']} ft")
const API_KEY = "your_api_key_here"; const BASE = "http://localhost:8000/api/v1"; const resp = await fetch( `${BASE}/zoning/1008720030?city=nyc`, { headers: { "X-API-Key": API_KEY } } ); const data = await resp.json(); console.log(`District: ${data.zoning_district}`); console.log(`FAR: ${data.far_base}`); console.log(`Height: ${data.height_limit_ft} ft`);
curl -s "http://localhost:8000/api/v1/zoning/1008720030?city=nyc" \ -H "X-API-Key: your_api_key_here" | python -m json.tool
Example Response — 200 OK
{ "parcel_id": "1008720030", "city": "nyc", "zoning_district": "R6B", "far_base": 2.0, "height_limit_ft": 55, "lot_coverage_pct": 65, "setbacks": { "front_ft": 10, "side_ft": 8, "rear_ft": 30 }, "overlays": [] }

3 Interpretation & Risk Analysis API Key

The /interpret endpoint returns a plain-English summary, viable development paths, risk signals, approval difficulty, and a decision frame for any parcel.

GET /api/v1/parcel/{parcel_id}/interpret?city=nyc
resp = requests.get( f"{BASE}/parcel/1008720030/interpret", params={"city": "nyc"}, headers={"X-API-Key": API_KEY}, ) result = resp.json() print(result["summary"]) print(f"Decision: {result['decision_summary']}") print(f"Approval difficulty: {result['approval_difficulty_label']}") print(f"Confidence: {result['effective_confidence']}") for sig in result.get("risk_signals", []): print(f" [{sig['severity']}] {sig['category']}: {sig['rationale']}")
const resp = await fetch( `${BASE}/parcel/1008720030/interpret?city=nyc`, { headers: { "X-API-Key": API_KEY } } ); const result = await resp.json(); console.log(result.summary); console.log(`Decision: ${result.decision_summary}`); console.log(`Approval difficulty: ${result.approval_difficulty_label}`); console.log(`Confidence: ${result.effective_confidence}`); for (const sig of result.risk_signals || []) { console.log(` [${sig.severity}] ${sig.category}: ${sig.rationale}`); }
curl -s "http://localhost:8000/api/v1/parcel/1008720030/interpret?city=nyc" \ -H "X-API-Key: your_api_key_here" | python -m json.tool
Example Response — 200 OK
{ "summary": "This R6B parcel in NYC appears feasible for residential development...", "viable_paths": [ { "path": "by_right", "description": "Residential up to FAR 2.0" } ], "red_flags": [], "citations": ["NYC ZR \u00a723-145", "NYC ZR \u00a723-632"], "confidence": 0.85, "confidence_label": "HIGH", "risk_signals": [ { "category": "LEGAL_COMPLEXITY", "severity": "LOW", "rationale": "Simple by-right path with no overlays", "evidence": ["Single district, no special permits"], "confidence": 0.9 } ], "approval_difficulty": 10, "approval_difficulty_label": "EASY", "dead_end": false, "effective_confidence": 0.85, "decision_summary": "Good candidate" }
Tip: You can also append ?interpret=true to the standard /api/v1/zoning/{id} endpoint to get zoning data with interpretation included in a single call.

5 Screening Workflow JWT

The screening workflow lets you create a project, add parcels, run automated GO/REVIEW/NO_GO classification, and review ranked results. This is a 5-step pipeline.

Authentication: Project endpoints require a JWT Bearer token. Include Authorization: Bearer <your_jwt_token> in all requests below. Get your token by logging in via /api/v1/auth/magic-link.

Step A — Create a Project

POST /api/v1/projects
JWT = "your_jwt_token_here" AUTH = {"Authorization": f"Bearer {JWT}"} # Create a screening project with custom thresholds resp = requests.post(f"{BASE}/projects", headers=AUTH, json={ "name": "Brooklyn Residential Sites", "intended_use": "residential", # immutable after creation "assumptions": {"min_units": 10}, "screening_profile": { # optional — custom GO/NO-GO thresholds "no_go_min_friction": 70, # stricter: flag as NO_GO at friction 70 (default: 80) "go_min_confidence": 0.8, # stricter: require 80% confidence for GO (default: 0.7) } }) project = resp.json() PROJECT_ID = project["id"] print(f"Project created: #{PROJECT_ID} - {project['name']}")
const JWT = "your_jwt_token_here"; const AUTH = { "Authorization": `Bearer ${JWT}` }; const resp = await fetch(`${BASE}/projects`, { method: "POST", headers: { ...AUTH, "Content-Type": "application/json" }, body: JSON.stringify({ name: "Brooklyn Residential Sites", intended_use: "residential", assumptions: { min_units: 10 }, screening_profile: { // optional — custom thresholds no_go_min_friction: 70, go_min_confidence: 0.8 } }) }); const project = await resp.json(); const PROJECT_ID = project.id; console.log(`Project created: #${PROJECT_ID} - ${project.name}`);
curl -X POST "http://localhost:8000/api/v1/projects" \ -H "Authorization: Bearer your_jwt_token_here" \ -H "Content-Type: application/json" \ -d '{ "name": "Brooklyn Residential Sites", "intended_use": "residential", "assumptions": {"min_units": 10}, "screening_profile": { "no_go_min_friction": 70, "go_min_confidence": 0.8 } }'
Screening Profile: The optional screening_profile field customizes GO/NO-GO classification thresholds for this project. All parameters are bounded (e.g., no_go_min_friction range: 60–95, go_min_confidence range: 0.5–0.9). Both intended_use and screening_profile are frozen at creation and cannot be changed. Omit to use defaults that match standard risk tolerance.

Step B — Add Parcels

POST /api/v1/projects/{id}/parcels
resp = requests.post( f"{BASE}/projects/{PROJECT_ID}/parcels", headers=AUTH, json={ "parcels": [ {"parcel_id": "1008720030", "city": "nyc"}, {"parcel_id": "1008720031", "city": "nyc"}, {"parcel_id": "1008720032", "city": "nyc"}, {"parcel_id": "5149-021-001", "city": "la"} ] }, ) print(f"Added {resp.json()['total']} parcels to project")
const resp = await fetch(`${BASE}/projects/${PROJECT_ID}/parcels`, { method: "POST", headers: { ...AUTH, "Content-Type": "application/json" }, body: JSON.stringify({ parcels: [ { parcel_id: "1008720030", city: "nyc" }, { parcel_id: "1008720031", city: "nyc" }, { parcel_id: "1008720032", city: "nyc" }, { parcel_id: "5149-021-001", city: "la" } ] }) }); const data = await resp.json(); console.log(`Added ${data.total} parcels to project`);
curl -X POST "http://localhost:8000/api/v1/projects/1/parcels" \ -H "Authorization: Bearer your_jwt_token_here" \ -H "Content-Type: application/json" \ -d '{ "parcels": [ {"parcel_id": "1008720030", "city": "nyc"}, {"parcel_id": "1008720031", "city": "nyc"}, {"parcel_id": "1008720032", "city": "nyc"}, {"parcel_id": "5149-021-001", "city": "la"} ] }'

Step C — Run Screening

Triggers interpretation, risk analysis, and GO/REVIEW/NO_GO classification on every parcel. Results are ranked by composite score.

POST /api/v1/projects/{id}/screen
resp = requests.post( f"{BASE}/projects/{PROJECT_ID}/screen", headers=AUTH, ) screen = resp.json() print(f"Screen run #{screen['screen_run_id']} complete") print(f"GO: {screen['summary']['go_count']} " f"REVIEW: {screen['summary']['review_count']} " f"NO_GO: {screen['summary']['no_go_count']}") for r in screen["results"]: print(f" {r['parcel_id']} | {r['classification']:6} | " f"score={r['composite_score']:.1f} | {r['decision_summary']}")
const resp = await fetch(`${BASE}/projects/${PROJECT_ID}/screen`, { method: "POST", headers: AUTH }); const screen = await resp.json(); console.log(`Screen run #${screen.screen_run_id} complete`); console.log(`GO: ${screen.summary.go_count} ` + `REVIEW: ${screen.summary.review_count} ` + `NO_GO: ${screen.summary.no_go_count}`); for (const r of screen.results) { console.log(` ${r.parcel_id} | ${r.classification} | ` + `score=${r.composite_score.toFixed(1)} | ${r.decision_summary}`); }
curl -X POST "http://localhost:8000/api/v1/projects/1/screen" \ -H "Authorization: Bearer your_jwt_token_here" | python -m json.tool
Example Screening Response — 200 OK
{ "screen_run_id": 1, "project_id": 1, "started_at": "2026-03-04T10:00:00", "completed_at": "2026-03-04T10:00:03", "total_parcels": 4, "results": [ { "parcel_id": "1008720030", "city": "nyc", "composite_score": 82.5, "classification": "GO", "decision_summary": "Good candidate", "dead_end": false, "approval_difficulty": 10, "effective_confidence": 0.85 }, { "parcel_id": "5149-021-001", "city": "la", "composite_score": 45.2, "classification": "REVIEW", "decision_summary": "Viable but risky", "dead_end": false, "approval_difficulty": 38, "effective_confidence": 0.62 } ], "summary": { "go_count": 2, "review_count": 1, "no_go_count": 1, "top_score": 82.5, "bottom_score": 0.0 } }

Step D — Poll Status (large projects)

For projects with more than 20 parcels, screening runs asynchronously. Poll the status endpoint until completion.

GET /api/v1/projects/{id}/screen/{screen_run_id}/status
import time screen_run_id = screen["screen_run_id"] while True: status_resp = requests.get( f"{BASE}/projects/{PROJECT_ID}/screen/{screen_run_id}/status", headers=AUTH, ) st = status_resp.json() print(f"Status: {st['status']} ({st.get('processed', 0)}/{st.get('total', '?')})") if st["status"] in ("COMPLETED", "FAILED"): break time.sleep(2)
const screenRunId = screen.screen_run_id; async function pollStatus() { while (true) { const resp = await fetch( `${BASE}/projects/${PROJECT_ID}/screen/${screenRunId}/status`, { headers: AUTH } ); const st = await resp.json(); console.log(`Status: ${st.status} (${st.processed ?? 0}/${st.total ?? "?"})`); if (st.status === "COMPLETED" || st.status === "FAILED") break; await new Promise(r => setTimeout(r, 2000)); } } await pollStatus();
# Replace 1 with your screen_run_id curl -s "http://localhost:8000/api/v1/projects/1/screen/1/status" \ -H "Authorization: Bearer your_jwt_token_here" | python -m json.tool

Step E — Override Classifications (optional)

Override the automated GO/REVIEW/NO_GO classification if you have local knowledge. Each override is tracked in the audit trail.

POST /api/v1/projects/{id}/resolve
resp = requests.post( f"{BASE}/projects/{PROJECT_ID}/resolve", headers=AUTH, json={ "decisions": [ { "parcel_id": "5149-021-001", "classification": "GO", "reason": "market_knowledge" } ] }, ) print(resp.json()) # {"resolved_count": 1, "overrides": [...]}
const resp = await fetch(`${BASE}/projects/${PROJECT_ID}/resolve`, { method: "POST", headers: { ...AUTH, "Content-Type": "application/json" }, body: JSON.stringify({ decisions: [{ parcel_id: "5149-021-001", classification: "GO", reason: "market_knowledge" }] }) }); console.log(await resp.json()); // { resolved_count: 1, overrides: [...] }
curl -X POST "http://localhost:8000/api/v1/projects/1/resolve" \ -H "Authorization: Bearer your_jwt_token_here" \ -H "Content-Type: application/json" \ -d '{ "decisions": [{ "parcel_id": "5149-021-001", "classification": "GO", "reason": "market_knowledge" }] }'

6 Export Results JWT

Download screening results in JSON, CSV, Markdown, or PDF format. Each export references its source ScreenRun for full reproducibility.

GET /api/v1/projects/{id}/export?format=csv
# Export as CSV resp = requests.get( f"{BASE}/projects/{PROJECT_ID}/export", params={"format": "csv"}, headers=AUTH, ) # Save to file with open("screening_results.csv", "wb") as f: f.write(resp.content) print("Exported to screening_results.csv") # Other formats: json, md, pdf resp_json = requests.get( f"{BASE}/projects/{PROJECT_ID}/export", params={"format": "json"}, headers=AUTH, ) print(resp_json.json())
// Export as CSV const resp = await fetch( `${BASE}/projects/${PROJECT_ID}/export?format=csv`, { headers: AUTH } ); const blob = await resp.blob(); // In a browser: trigger download const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "screening_results.csv"; a.click(); // Export as JSON const jsonResp = await fetch( `${BASE}/projects/${PROJECT_ID}/export?format=json`, { headers: AUTH } ); console.log(await jsonResp.json());
# CSV export curl -s "http://localhost:8000/api/v1/projects/1/export?format=csv" \ -H "Authorization: Bearer your_jwt_token_here" \ -o screening_results.csv # JSON export curl -s "http://localhost:8000/api/v1/projects/1/export?format=json" \ -H "Authorization: Bearer your_jwt_token_here" | python -m json.tool # Markdown export curl -s "http://localhost:8000/api/v1/projects/1/export?format=md" \ -H "Authorization: Bearer your_jwt_token_here" # PDF export curl -s "http://localhost:8000/api/v1/projects/1/export?format=pdf" \ -H "Authorization: Bearer your_jwt_token_here" \ -o screening_report.pdf

Available formats: json, csv, md (Markdown), pdf.

7 Cross-City Comparison JWT

Compare 2-10 parcels across different cities. Returns normalized zoning data, entitlement calculations (GFA, units, height), and per-field deltas so you can evaluate sites side-by-side.

POST /api/v1/compare
resp = requests.post( f"{BASE}/compare", headers=AUTH, json={ "parcels": [ {"city": "nyc", "district": "R6A", "lot_area": 10000}, {"city": "la", "district": "R3", "lot_area": 10000}, {"city": "chi", "district": "RT4", "lot_area": 10000} ], "include_raw": False }, ) comp = resp.json() print(f"Compared {comp['summary']['parcels_compared']} parcels") print(f"Highest GFA: {comp['summary']['highest_gfa_parcel']}") for delta in comp["field_comparison"]: print(f" {delta['field']}: {delta['min_value']} - {delta['max_value']} " f"(delta: {delta['delta']})")
const resp = await fetch(`${BASE}/compare`, { method: "POST", headers: { ...AUTH, "Content-Type": "application/json" }, body: JSON.stringify({ parcels: [ { city: "nyc", district: "R6A", lot_area: 10000 }, { city: "la", district: "R3", lot_area: 10000 }, { city: "chi", district: "RT4", lot_area: 10000 } ], include_raw: false }) }); const comp = await resp.json(); console.log(`Compared ${comp.summary.parcels_compared} parcels`); console.log(`Highest GFA: ${comp.summary.highest_gfa_parcel}`); for (const delta of comp.field_comparison) { console.log(` ${delta.field}: ${delta.min_value} - ${delta.max_value} ` + `(delta: ${delta.delta})`); }
curl -X POST "http://localhost:8000/api/v1/compare" \ -H "Authorization: Bearer your_jwt_token_here" \ -H "Content-Type: application/json" \ -d '{ "parcels": [ {"city": "nyc", "district": "R6A", "lot_area": 10000}, {"city": "la", "district": "R3", "lot_area": 10000}, {"city": "chi", "district": "RT4", "lot_area": 10000} ], "include_raw": false }'
Example Response — 200 OK
{ "comparison_confidence": 0.72, "summary": { "parcels_compared": 3, "comparison_confidence": 0.72, "highest_gfa_parcel": "NYC R6A", "highest_gfa": 40000.0 }, "parcels": [ { "city": "NYC", "district": "R6A", "lot_area": 10000.0, "overall_confidence": 0.85, "entitlement": { "total_gfa": 40000.0, "base_gfa": 40000.0, "bonus_gfa": 0.0, "max_height_ft": 70, "estimated_units": 40 } } ], "field_comparison": [ { "field": "FAR", "min_value": 2.0, "max_value": 4.0, "min_parcel": "LA R3", "max_parcel": "NYC R6A", "delta": 2.0 }, { "field": "Max Height (ft)", "min_value": 45.0, "max_value": 70.0, "min_parcel": "LA R3", "max_parcel": "NYC R6A", "delta": 25.0 } ], "errors": [] }

8 Rate Limits

Rate limits are enforced per API key based on your subscription tier. Limits apply to billable endpoints (zoning, interpret, search). Project management endpoints (create, screen, export) are limited by your session, not your API key.

Tier Per Hour Per Day Parcels/Month
Explorer502005
Professional1,00010,000500
Team5,00050,0002,000
Developer20,000200,00010,000
EnterpriseCustomCustomUnlimited

When you exceed a limit, the API returns 429 Too Many Requests with a Retry-After header. Rate limit headers are included in every response:

Rate Limit Response Headers
X-RateLimit-Limit: 5000 X-RateLimit-Remaining: 4823 X-RateLimit-Reset: 1709532000

9 Error Codes

All errors return a JSON body with a detail field describing the issue.

Code Meaning Common Cause
400Bad RequestMissing required field, no parcels in project
401UnauthorizedMissing or invalid API key / JWT token
403ForbiddenAPI key valid but insufficient tier
404Not FoundParcel or project ID doesn't exist
422Validation ErrorInvalid field value, immutable field change attempt
429Rate LimitedToo many requests for your tier
500Internal ErrorServer error — report to api@zonelens.dev
Example Error Response — 422
{ "detail": "intended_use is immutable and cannot be changed after creation" }
Example Error Response — 429
{ "detail": "Rate limit exceeded. Upgrade your tier or wait until the next window." }

Next Steps

Supported cities: NYC, Los Angeles, Chicago, San Francisco, Miami, Phoenix, San Diego, Philadelphia.

Need help? Email api@zonelens.dev or open an issue on GitHub.