Quickstart

Make your first permit lookup in under 60 seconds. You'll need a sandbox key — grab one free at /signup.

  1. Get a sandbox key

    Sign up at permitreport.ai/signup. Your key is shown immediately on the dashboard — no email confirmation gate.

  2. Run your first lookup

    Paste your key into the curl command below and run it:

    curl -G "https://permitreport.ai/api/lookup" \
      -H "Authorization: Bearer YOUR_API_KEY" \
      --data-urlencode "address=123 Main St" \
      --data-urlencode "metro=chicago"
    const params = new URLSearchParams({
      address: "123 Main St",
      metro:   "chicago",
    });
    const res = await fetch(
      `https://permitreport.ai/api/lookup?${params}`,
      { headers: { "Authorization": "Bearer YOUR_API_KEY" } }
    );
    const data = await res.json();
    console.log(data.permits);
    import requests
    
    resp = requests.get(
        "https://permitreport.ai/api/lookup",
        headers={"Authorization": "Bearer YOUR_API_KEY"},
        params={"address": "123 Main St", "metro": "chicago"},
    )
    data = resp.json()
    print(data["permits"])
    require "net/http"
    require "uri"
    require "json"
    
    uri = URI("https://permitreport.ai/api/lookup")
    uri.query = URI.encode_www_form(
      address: "123 Main St",
      metro:   "chicago"
    )
    req = Net::HTTP::Get.new(uri)
    req["Authorization"] = "Bearer YOUR_API_KEY"
    
    res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
    data = JSON.parse(res.body)
    puts data["permits"].inspect
  3. Read the response

    You'll get back a JSON object with a permits array. Each permit has a permitNumber, issueDate, status, workType, estimatedCost, and more. See the endpoint reference for the full schema.

See a real examplepermitreport.ai/sample-report shows live permit data for 11 Wall St, NYC with annotated field callouts.

Authentication

Every API request requires a bearer token in the Authorization header.

Authorization: Bearer YOUR_API_KEY

Sandbox vs production

Keys are environment-scoped. Sandbox keys (prefix pr_sandbox_) hit real government data sources but are rate-limited and not billed. Use them for integration and testing. Production keys are issued after account review — email hello@permitreport.ai.

Your sandbox key (sign in to see yours)
pr_sandbox_demo_key_replace_me
Sandbox keys are rate-limited to 10 req/min. Requests are logged but not billed.

Key rotation

Rotate keys at any time from the dashboard — the old key is revoked immediately. Rotated keys return 403 Forbidden with {"error":"API key revoked"}.

Need a production key? Email hello@permitreport.ai with your company name and intended volume. We review and respond within one business day.

GET /api/lookup

Returns permit history for a single address. Queries the jurisdiction's open-data source in real time and caches results for 24 hours. See a live example →

GET /api/lookup Address permit history

Query parameters

ParameterTypeRequiredDescription
address string required Full street address including house number and street name.
Example: 1600 Pennsylvania Ave NW
metro string optional Metro key to target a specific jurisdiction. Defaults to chicago if omitted. See Coverage for all valid keys.

Code samples

curl -G "https://permitreport.ai/api/lookup" \
  -H "Authorization: Bearer pr_sandbox_demo_key_replace_me" \
  --data-urlencode "address=1600 Pennsylvania Ave NW" \
  --data-urlencode "metro=washington-dc"
const params = new URLSearchParams({
  address: "1600 Pennsylvania Ave NW",
  metro:   "washington-dc",
});
const res = await fetch(
  `https://permitreport.ai/api/lookup?${params}`,
  { headers: { "Authorization": "Bearer pr_sandbox_demo_key_replace_me" } }
);
const data = await res.json();
console.log(data.permits);
import requests

resp = requests.get(
    "https://permitreport.ai/api/lookup",
    headers={"Authorization": "Bearer pr_sandbox_demo_key_replace_me"},
    params={
        "address": "1600 Pennsylvania Ave NW",
        "metro":   "washington-dc",
    },
)
data = resp.json()
print(data["permits"])
require "net/http"
require "uri"
require "json"

uri = URI("https://permitreport.ai/api/lookup")
uri.query = URI.encode_www_form(
  address: "1600 Pennsylvania Ave NW",
  metro:   "washington-dc"
)
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer pr_sandbox_demo_key_replace_me"

res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
data = JSON.parse(res.body)
puts data["permits"].inspect

Example response

{
  "address":    "1600 Pennsylvania Ave NW",
  "metro":      "washington-dc",
  "metroLabel": "Washington, DC",
  "count":      3,
  "fromCache":  false,
  "permits": [
    {
      "permitNumber":  "B2024-00132",
      "issueDate":     "2024-03-15",
      "workType":      "Building",
      "estimatedCost": 125000,
      "contractor":    "Capitol Contractors LLC",
      "status":        "Finaled",
      "description":   "Interior renovation, kitchen and bathrooms",
      "address":       "1600 PENNSYLVANIA AVE NW",
      "sourceLink":    "https://dcra.dc.gov/service/building-permits"
    }
  ]
}

Response schema

FieldTypeDescription
addressstringThe address queried (echoed back)
metrostringMetro key used for the query
metroLabelstringHuman-readable metro name
countnumberTotal permits returned (capped at 50)
fromCachebooleanTrue if served from 24h cache; false if freshly fetched
permits[].permitNumberstring|nullPermit number as issued by the jurisdiction
permits[].issueDatestring|nullISO 8601 issue date (YYYY-MM-DD)
permits[].workTypestring|nullPermit category: Building, Electrical, Plumbing, Mechanical, etc.
permits[].estimatedCostnumber|nullDeclared valuation in USD, where reported
permits[].contractorstring|nullContractor of record where available
permits[].statusstring|nullPermit status: Finaled, Open, Expired, Active, Issued, etc.
permits[].descriptionstring|nullScope-of-work description from the permit application
permits[].addressstring|nullNormalized address as recorded in the source data
permits[].sourceLinkstringURL to the original record on the government portal

Bulk Lookup (CSV → Zip)

Pro and Enterprise customers can run permit lookups across an entire address portfolio in one batch. Upload a CSV, get back a zip with per-address JSON files and a combined results.csv.

POST /bulk/submit Start a CSV batch lookup job (login required)

Accepts CSV text via textarea or file upload. Each row should include an address; city and state are optional but improve metro routing accuracy.

Accepted column headersDescription
address / addr / street / full_addressStreet address — at least one of these is required
city / municipality / townCity name — optional but recommended
state / st / state_codeTwo-letter state abbreviation — optional but recommended
GET /bulk/:jobId/status Poll job progress (JSON)

Returns {"status":"processing","completedRows":42,"totalRows":100}. Poll until status === "done" or "failed".

GET /bulk/:jobId/download Download results zip (login required)

Returns a zip archive. Contains permits/{n}_{address}.json per address plus results.csv with columns: address, metro, total_permits, open_permits, last_permit_date, total_declared_value, status.

Open Bulk Lookup →

Plan limits per job: Sandbox = 10 rows · Starter = 100 rows · Pro = 1,000 rows · Enterprise = 10,000 rows. Each row counts as one API call against your monthly quota.

GET /order/:id

Poll the status of a research order by its short ID (PR-XXXXXX). Once delivered, the response includes a download URL for the assembled PDF report.

GET /order/:short_id Order status + delivery URL

The :short_id is the PR-XXXXXX identifier returned when an order is created. Use the public tracking page at /track/:token for a customer-facing status page (no auth required with a valid HMAC token).

Code samples

curl "https://permitreport.ai/order/PR-00001A" \
  -H "Authorization: Bearer pr_sandbox_demo_key_replace_me"
const res = await fetch(
  "https://permitreport.ai/order/PR-00001A",
  { headers: { "Authorization": "Bearer pr_sandbox_demo_key_replace_me" } }
);
const order = await res.json();
// Poll until order.customerStatus === "delivered"
if (order.customerStatus === "delivered") {
  console.log(order.deliveredPdfUrl);
}
resp = requests.get(
    "https://permitreport.ai/order/PR-00001A",
    headers={"Authorization": "Bearer pr_sandbox_demo_key_replace_me"},
)
order = resp.json()
if order["customerStatus"] == "delivered":
    print(order["deliveredPdfUrl"])
req = Net::HTTP::Get.new(URI("https://permitreport.ai/order/PR-00001A"))
req["Authorization"] = "Bearer pr_sandbox_demo_key_replace_me"
# … same Net::HTTP boilerplate as above …
order = JSON.parse(res.body)
puts order["deliveredPdfUrl"] if order["customerStatus"] == "delivered"

Example response

{
  "shortId":         "PR-00001A",
  "metro":           "chicago",
  "address":         "123 Main St, Chicago, IL",
  "tier":            "premium",
  "customerStatus":  "delivered",
  "statusUpdatedAt": "2026-06-21T14:30:00Z",
  "deliveredPdfUrl": "https://cdn.permitreport.ai/reports/PR-00001A.pdf"
}

The customerStatus lifecycle:

StatusMeaning
receivedOrder intake confirmed, queued for research
researchingPermit records are being retrieved and assembled
deliveredReport complete — deliveredPdfUrl is populated (Premium tier)

Rate Limits & Quotas

Two limits apply: a per-minute rate limit and a monthly quota. Both are enforced per API key.

Per-minute rate limits

PlanRequests / minuteNotes
Sandbox10Free, no billing
Starter60Monthly quota applies
Pro200Monthly quota applies
EnterpriseCustomContact us for volume SLA

Monthly quotas

PlanMonthly lookupsBulk rows / job
Sandbox10010
Starter1,000100
Pro10,0001,000
EnterpriseUnlimited10,000

View pricing → for plan details and upgrade options.

What happens at the limit

Rate-limited requests receive 429 Too Many Requests with a Retry-After header. Use exponential backoff with jitter — the example below retries up to 3 times:

async function lookupWithRetry(params, apiKey, maxRetries = 3) {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    const res = await fetch(
      `https://permitreport.ai/api/lookup?${new URLSearchParams(params)}`,
      { headers: { "Authorization": `Bearer ${apiKey}` } }
    );
    if (res.status !== 429) return res.json();
    const wait = parseInt(res.headers.get("Retry-After") || "5") * 1000;
    await new Promise(r => setTimeout(r, wait * (2 ** attempt) + Math.random() * 500));
  }
  throw new Error("Rate limit retries exhausted");
}

Quota exhaustion returns 429 with {"error":"Monthly quota exceeded"}. Upgrade your plan or contact us to add quota mid-cycle.

Error Codes

All error responses share the same envelope:

{
  "error": "Human-readable description",
  "docs":  "https://permitreport.ai/docs"
}
StatusMeaningHow to fix
200 Success Read the permits array. count: 0 means no records found (not an error).
400 Bad request — missing required field, malformed address, unknown metro key Check the error string. Verify address and metro are present and valid.
401 Missing Authorization header Add Authorization: Bearer YOUR_KEY to every request.
403 Invalid or revoked API key Rotate a new key from the dashboard or request one via email.
404 Order or resource not found Verify the PR-XXXXXX short ID is correct.
429 Rate limit or monthly quota exceeded Check the Retry-After header. Use exponential backoff. Upgrade plan if quota is exhausted.
502 Upstream government data source temporarily unavailable Retry after 30–60 seconds. Response body includes the metro that failed.
503 Service temporarily unavailable Retry after 15 seconds. If persistent, email hello@permitreport.ai.
Zero results is not an error. When no permits are found for an address, the API returns 200 with count: 0 and an empty permits array — your integration won't throw on missing records.

Coverage

The metro parameter accepts these keys. All metros query live government data unless noted.

* Atlanta and Houston return empty results — no public address-queryable API is available from those jurisdictions. Your integration won't break; you'll receive count: 0.

MetroData sourceSource API type
chicagoChicago Data Portal (Socrata ydr8-5enu)Socrata Open Data
nycNYC DOB (Socrata ipu4-2q9a)Socrata Open Data
laLA Building & Safety (LADBS)LADBS proprietary + Socrata
sfSF DataSF (Socrata i98e-djp9)Socrata Open Data
seattleSeattle Open DataSocrata Open Data
bostonBoston Open Data (Socrata 8u6b-8mc5)Socrata Open Data
austinAustin Open Data (Socrata 3syk-w9eu)Socrata Open Data
philadelphiaPhiladelphia Open Data (Socrata)Socrata Open Data
denverDenver Open Data (Socrata 2qa3-7ye9)Socrata Open Data
portlandPortland Maps ArcGIS BDS_Permit FeatureServer/22ArcGIS FeatureServer
miamiMiami-Dade RER ArcGIS FeatureServer/0ArcGIS FeatureServer
san-diegoSan Diego County (Socrata dyzh-7eat)Socrata Open Data
dallasDallas Open Data (Socrata e7gq-4sah)Socrata Open Data
phoenixPhoenix PDD ArcGIS FeatureServer/0ArcGIS FeatureServer
washington-dcDC Open Data ArcGIS DCRA MapServer/1ArcGIS MapServer
san-antonioCoSA ArcGIS BuildingPermits FeatureServer/0ArcGIS FeatureServer
New metros are added continuously. Request your city →

Webhooks Planned

Webhooks are on the roadmap for Enterprise customers. When released, they'll fire on order status transitions so you don't have to poll /order/:id.

Planned event types

EventFires when
order.receivedOrder intake confirmed and queued for research
order.researchingFulfillment pipeline has started retrieving permits
order.deliveredReport assembled and ready for download
bulk_job.doneA CSV batch job has completed all rows
bulk_job.failedA CSV batch job failed (partial results may exist)

Planned payload shape

{
  "event":     "order.delivered",
  "timestamp": "2026-06-21T14:30:00Z",
  "data": {
    "shortId":         "PR-00001A",
    "customerStatus":  "delivered",
    "deliveredPdfUrl": "https://cdn.permitreport.ai/reports/PR-00001A.pdf"
  }
}
Not live yet. If webhooks are a blocker for your integration, email hello@permitreport.ai — demand signals move the roadmap.

Changelog

Recent API additions, in reverse chronological order.

  • 2026-06-21 metroSan Antonio — live ArcGIS adapter added. Metro key: san-antonio. Source: CoSA DSD BuildingPermits FeatureServer/0.
  • 2026-06-21 newBulk Lookup (CSV)POST /bulk/submit + status polling + zip download. Pro/Enterprise feature. Up to 10,000 rows per job for Enterprise plans.
  • 2026-06-21 newSelf-serve API keys — sign up at /signup, get a sandbox key immediately. Dashboard at /dashboard for usage stats, key rotation, request log.
  • 2026-06-21 fixAustin adapter — migrated from deprecated dataset 3k5u-5s5v to 3syk-w9eu (Issued Construction Permits) with richer fields.
  • 2026-06-21 metroWashington DC, San Diego, Miami, Phoenix — live ArcGIS and Socrata adapters. Metro keys: washington-dc, san-diego, miami, phoenix.
  • 2026-05-15 metroPortland, Dallas, Denver, Philadelphia — live adapters added. Metro keys: portland, dallas, denver, philadelphia.
  • 2026-04-10 metroAustin, Boston, Seattle — Socrata adapters added. Metro keys: austin, boston, seattle.
  • 2026-03-01 metroChicago, NYC, LA, SF — initial launch with four metro adapters and 24h response caching.