REST API v1

API Documentation

Everything you need to integrate permit data into your application. Base URL: https://signedoff.io/api/v1

The SignedOff API uses API key authentication via the X-API-Key header and returns JSON responses. It supports single permit lookups and batch requests of up to 25 permits. Permit data is cached for 4 hours, with a data_freshness object in every response; Pro and Enterprise plans can bypass the cache with force_refresh. When a permit number matches multiple jurisdictions, the API returns HTTP 300 with disambiguation candidates. The API provides read-only access to permit status data — it does not submit permit applications or process payments.

Authentication

Include your API key in the X-API-Key header with every request.

X-API-Key: YOUR_API_KEY

Don't have a key yet? Get your API key — it takes seconds.

Quick Start

Make your first API call in under a minute. Choose your language:

curl -s -H "X-API-Key: YOUR_API_KEY" \ "https://signedoff.io/api/v1/permits/24044-20000-03620/status" \ | python -m json.tool

Windows PowerShell users: use curl.exe instead of curl

Endpoints

GET /api/v1/permits/{permit_number}/status

Look up the current status of a building permit. Returns cached data if available, or triggers a live scrape.

Parameters

Name In Type Description
permit_number path string The permit number to look up
jurisdiction query string Jurisdiction slug (e.g. aca:sbc). Required for Accela cities that share permit formats. See Jurisdictions.
force_refresh query boolean Force a live re-scrape (paid plans only, costs 10x credits)

Example Response

"permit_number": "24044-20000-03620", "jurisdiction": "ladbs", "jurisdiction_display": "LADBS (City of Los Angeles)", "status": "Permit Finaled on 1/13/2026", "portal_status": "Permit Finaled on 1/13/2026", "status_phase": "closed", "permit_type": "Mechanical", "address": "16310 W RAYMER ST 91406", "work_description": "General HVAC with fume hoods.", "source": "cached", "last_synced_at": "2026-05-06T08:00:00Z", "data_freshness": { "age_seconds": 7200, "cached": true, "refresh_available": true }
POST /api/v1/permits/batch-status

Look up status for multiple permits in one request. Maximum 25 permits per request.

Request Body

{ "permit_numbers": ["24044-20000-03620", "B202508083"] }

Example Response

{ "results": [/* array of permit status objects */], "errors": [ { "permit_number": "UNKNOWN-123", "error": "not_found", "jurisdiction_hint": null } ] }
GET /api/v1/jurisdictions No auth required

Returns a list of all supported jurisdictions with platform, state, and active permit counts.

Example Response

{ "jurisdictions": [ { "slug": "ladbs", "display_name": "LADBS (City of Los Angeles)", "platform": "ladbs", "state": "CA", "active_permits_count": 142 } ], "total": 12 }
GET /api/v1/jurisdictions/{slug}/stats

Returns permit count and date range for a specific jurisdiction.

Parameters

Name In Description
slug path Jurisdiction slug (e.g. ladbs, aca:glendale)

Example Response

{ "slug": "ladbs", "display_name": "LADBS (City of Los Angeles)", "active_permits_count": 142, "date_range": { "earliest": "2025-11-01T00:00:00", "latest": "2026-05-06T08:00:00" } }
POST /api/v1/jurisdictions/request

Request support for a new jurisdiction. Existing requests are upvoted automatically.

Request Body

{ "jurisdiction_name": "City of Phoenix", "state": "AZ" }

Example Response

{ "jurisdiction_name": "City of Phoenix", "state_code": "AZ", "request_count": 3, "unique_keys_count": 2, "status": "pending", "estimated_timeline": "3-4 weeks" }

Jurisdictions & Disambiguation

SignedOff covers 17+ jurisdictions across multiple states. Some permit number formats are unique to a single city (like LADBS's 24044-20000-03620), so the API auto-detects the jurisdiction. Others — particularly Accela-powered cities — share similar formats across multiple jurisdictions.

When is ?jurisdiction= needed?

  • LADBS, EPIC-LA, Pasadena, San Diego, Denver, Cleveland — auto-detected, no parameter needed
  • Accela cities (Ontario, South Pasadena, San Bernardino County, Sacramento, Charlotte, Fort Lauderdale, Anaheim, etc.) — pass ?jurisdiction=slug to specify which city

How it works

If you call the API without ?jurisdiction= and the permit number matches multiple cities, the API returns HTTP 300 with a list of candidates:

// HTTP 300 Multiple Choices { "error": "multiple_jurisdictions", "candidates": [ {"slug": "aca:ont", "display_name": "City of Ontario"}, {"slug": "aca:cosp", "display_name": "City of South Pasadena"}, {"slug": "aca:sbc", "display_name": "San Bernardino County"}, ... ], "example": "/api/v1/permits/BLDG-2026-000391/status?jurisdiction=aca:ont" }

Re-submit with the correct slug and the lookup succeeds:

curl -s -H "X-API-Key: YOUR_API_KEY" \ "https://signedoff.io/api/v1/permits/BLDG-2026-000391/status?jurisdiction=aca:sbc" \ | python -m json.tool

Recommended integration pattern

Most API consumers already know which cities their permits are in. Map your cities to jurisdiction slugs once, and every lookup is a single call:

JURISDICTIONS = { "San Bernardino": "aca:sbc", "Ontario": "aca:ont", "South Pasadena": "aca:cosp", "San Diego": "aca:sandiego", "Sacramento": "aca:sacramento", "Charlotte": "aca:charlotte", "Fort Lauderdale": "aca:ftl", "Anaheim": "aca:anaheim", } def lookup_permit(permit_number, city): slug = JURISDICTIONS.get(city) params = {"jurisdiction": slug} if slug else {} resp = requests.get( f"https://api.signedoff.io/api/v1/permits/{permit_number}/status", params=params, headers={"X-API-Key": API_KEY}, ) return resp.json()

Get the full list of supported jurisdiction slugs:

curl https://signedoff.io/api/v1/jurisdictions # no auth required

Once a permit is looked up with a jurisdiction, the result is cached — future lookups for the same permit number resolve instantly without needing the ?jurisdiction= parameter.

Rate Limits

Rate limits are tracked per API key on a monthly billing cycle.

Plan Monthly Limit Burst Limit force_refresh
Free 200 3/min No
Developer 5,000 None Yes (10x credit)
Pro 25,000 None Yes (10x credit)
Enterprise Custom None Yes (10x credit)

Response Headers

Every API response includes rate limit headers so you can track your usage:

X-RateLimit-Limit: 5000 X-RateLimit-Remaining: 4832 X-RateLimit-Reset: 2026-06-01T00:00:00Z

Error Codes

All errors return a JSON object with an error key and a human-readable message.

Status Error Key Description
401 invalid_api_key API key is missing, invalid, or deactivated
403 force_refresh_not_available Free plan cannot use force_refresh. Upgrade to a paid plan.
404 permit_not_found Permit number not found in the detected jurisdiction
404 jurisdiction_not_supported Could not detect a supported jurisdiction for this permit number
429 rate_limit_exceeded Monthly API call limit reached
429 burst_limit_exceeded Too many requests in a short period (Free plan: 3/min)
503 jurisdiction_unavailable Jurisdiction portal is temporarily down. Retry after the specified delay.

Example Error Response

{ "error": "rate_limit_exceeded", "message": "Monthly API call limit reached. Resets on June 1." }

Cache Behavior

Permit data is cached for 4 hours after each scrape. Within that window, API calls return cached data instantly without hitting the jurisdiction portal.

The data_freshness Object

Every response includes a data_freshness field so you always know how current the data is:

Free Plan (cache only)

"data_freshness": { "age_seconds": 7200, "cached": true, "refresh_available": false }

Paid Plan (refresh available)

"data_freshness": { "age_seconds": 7200, "cached": true, "refresh_available": true }

force_refresh costs 10x credits. A single force_refresh call deducts 10 calls from your monthly quota. Use it only when you need real-time data and the cache is stale. Max 10 force_refresh calls per hour.

Interactive Explorer

Try the API endpoints live using FastAPI's built-in documentation interface.

Try endpoints live