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
import requests
resp = requests.get(
"https://signedoff.io/api/v1/permits/24044-20000-03620/status",
headers={"X-API-Key": "YOUR_API_KEY"}
)
print(resp.json())
const resp = await fetch(
"https://signedoff.io/api/v1/permits/24044-20000-03620/status",
{ headers: { "X-API-Key": "YOUR_API_KEY" } }
);
const data = await resp.json();
console.log(data);
Windows PowerShell users: use curl.exe instead of curl
Endpoints
/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
}
/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
}
]
}
/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
}
/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"
}
}
/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=slugto 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