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
Import the full API as a ready-made collection — generated live from our OpenAPI schema, so it never drifts from this reference. In Postman: Import → Link and paste the URL.
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": [
{
"permit_number": "24044-20000-03620",
"jurisdiction": "ladbs",
"jurisdiction_display": "LADBS (City of Los Angeles)",
"status": "Permit Finaled on 1/13/2026",
"status_phase": "closed",
"source": "cached",
"last_synced_at": "2026-05-06T08:00:00Z",
"data_freshness": { "age_seconds": 7200, "cached": true, "refresh_available": true }
}
],
"errors": [
{
"permit_number": "UNKNOWN-123",
"error": "not_found",
"message": "Permit number not found in any supported jurisdiction",
"jurisdiction_hint": null
}
]
}
Partial failures: Batch requests always return HTTP 200. If 23 of 25 permits resolve and 2 fail, you’ll receive 23 entries in results and 2 in errors. Always check both arrays. Permits are resolved concurrently with a 45s timeout each; permits that exceed the timeout return as "error": "timeout" in the errors array.
/api/v1/permits/{permit_number}/inspections
Returns inspection history with a computed pass rate, plus pending or scheduled inspections. Sourced from the latest portal scrape.
Example Request
curl -s -H "X-API-Key: YOUR_API_KEY" \
"https://signedoff.io/api/v1/permits/25044-30000-03525/inspections"
Example Response
{
"permit_number": "25044-30000-03525",
"jurisdiction": "ladbs",
"inspections": [
{ "type": "Foundation", "date": "2026-02-04", "result": "Pass", "inspector": "J. Vargas" },
{ "type": "Framing", "date": "2026-03-12", "result": "Fail", "inspector": "J. Vargas" }
],
"pending": [
{ "type": "Final", "date": "2026-05-22" }
],
"pass_rate": 0.5
}
pass_rate is computed across completed inspections only — pending entries don't count. Returns null when no completed inspections are recorded.
/api/v1/permits/{permit_number}/history
Returns a unified status-change timeline. Merges every transition reported by the portal with SignedOff's own auto-sync diffs — the source field tells you which is which.
Example Request
curl -s -H "X-API-Key: YOUR_API_KEY" \
"https://signedoff.io/api/v1/permits/25044-30000-03525/history"
Example Response
{
"permit_number": "25044-30000-03525",
"jurisdiction": "ladbs",
"timeline": [
{ "status": "Issued", "portal_status": "Issued", "changed_at": "2026-01-15", "source": "portal" },
{ "status": "Inspections in Progress", "portal_status": "Inspections in Progress", "changed_at": "2026-02-04T08:00:00Z", "source": "sync_log" }
]
}
Sort by changed_at for strict chronological order — portal entries may use date-only strings while sync_log entries use full ISO 8601 timestamps.
/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/{slug}/analytics
Returns proprietary processing-time data for a jurisdiction: median days to issuance, sample size, and a permit-type breakdown. Useful for benchmarking, project estimation, and ROI calculations.
Example Request
curl -s -H "X-API-Key: YOUR_API_KEY" \
"https://signedoff.io/api/v1/jurisdictions/ladbs/analytics"
Example Response
{
"jurisdiction": "ladbs",
"display_name": "LADBS (City of Los Angeles)",
"median_processing_days": 47,
"sample_size": 312,
"permit_count": 1847,
"date_range": {
"earliest": "2024-01-15T00:00:00",
"latest": "2026-05-08T00:00:00"
},
"permits_by_type": {
"Building": 1840,
"Electrical": 740,
"Plumbing": 523,
"Mechanical": 201
}
}
median_processing_days is null when the sample size is too small to compute a stable median.
/api/v1/jurisdictions/requests
No auth required
Public list of jurisdictions developers have requested but SignedOff doesn't yet support, ranked by request count. Useful for: (a) checking if your city is already on the roadmap before submitting your own request, (b) embedding a "most-requested cities" card into your own developer dashboard.
Example Response
{
"requests": [
{
"jurisdiction_name": "City of Phoenix",
"state_code": "AZ",
"request_count": 12,
"unique_keys_count": 7,
"status": "open"
},
{
"jurisdiction_name": "City of Austin",
"state_code": "TX",
"request_count": 8,
"unique_keys_count": 5,
"status": "investigating"
}
]
}
/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"
}
Webhooks
Subscribe to permit events instead of polling. SignedOff POSTs a signed JSON payload to your URL whenever a matching event fires. Each webhook is scoped to the API key that registered it; the HMAC secret returned at creation is shown once and is your shared secret for verifying inbound payloads.
X-SignedOff-Signature headerEvent types
| Event | Fires when |
|---|---|
| status_change | Permit's portal_status differs from the previous scrape |
| inspection_complete | A new inspection result lands (pass / fail / etc.) |
| permit_approved | Status transitions to Issued / Approved / Final |
| permit_denied | Status transitions to Denied / Withdrawn / Expired |
| corrections_required | Portal flags the permit as awaiting corrections |
Receiving deliveries
When a permit event fires, SignedOff POSTs a signed JSON body to your registered URL. Every delivery carries three headers:
| Header | Value |
|---|---|
| Content-Type | application/json |
| X-SignedOff-Signature | HMAC-SHA256 hex digest of the raw request body, keyed by your whsec_ secret |
| X-SignedOff-Event | The event type, e.g. status_change |
Sample delivery payload
{
"event": "status_change",
"permit_number": "25044-30000-03525",
"jurisdiction": "ladbs",
"jurisdiction_display": "LADBS (City of Los Angeles)",
"data": {
"old_status": "Plan Check - In Progress",
"new_status": "Permit Issued",
"old_portal_status": "Plan Check - In Progress",
"new_portal_status": "Permit Issued",
"changed_at": "2026-05-15T08:00:00Z"
},
"timestamp": "2026-05-15T08:00:30Z"
}
Verifying the HMAC signature
Always verify X-SignedOff-Signature before processing a delivery. Compute HMAC-SHA256 over the raw request body bytes (before any JSON parsing) using your whsec_ secret, then compare using a constant-time equality check.
import hashlib, hmac
def verify_signature(raw_body: bytes, secret: str, header_sig: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header_sig)
# FastAPI example
@app.post("/webhooks/signedoff")
async def receive_webhook(request: Request):
raw_body = await request.body()
sig = request.headers.get("X-SignedOff-Signature", "")
if not verify_signature(raw_body, WEBHOOK_SECRET, sig):
raise HTTPException(status_code=401, detail="Invalid signature")
event = json.loads(raw_body)
# handle event ...
const crypto = require("crypto");
function verifySignature(rawBody, secret, headerSig) {
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody) // Buffer — do NOT JSON.parse first
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(headerSig)
);
}
// Express example
app.post("/webhooks/signedoff", express.raw({ type: "*/*" }), (req, res) => {
const sig = req.headers["x-signedoff-signature"] ?? "";
if (!verifySignature(req.body, process.env.WEBHOOK_SECRET, sig)) {
return res.status(401).json({ error: "Invalid signature" });
}
const event = JSON.parse(req.body);
// handle event ...
res.sendStatus(200);
});
/api/v1/webhooks
Register a webhook. The secret in the response is shown only on creation — store it securely; it's required to verify HMAC signatures on inbound deliveries.
Request Body
curl -X POST -H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
"https://signedoff.io/api/v1/webhooks" \
-d '{
"url": "https://your-app.example.com/webhooks/signedoff",
"events": ["status_change", "permit_approved"],
"jurisdiction_filter": ["ladbs"]
}'
Example Response (201)
{
"webhook_id": "f3c0e0e7-2c70-4f4f-9d8f-aaaaaaaaaaaa",
"url": "https://your-app.example.com/webhooks/signedoff",
"events": ["status_change", "permit_approved"],
"jurisdiction_filter": ["ladbs"],
"is_active": true,
"secret": "whsec_<64-hex-chars>",
"warning": "Store the secret securely. It will not be shown again.",
"created_at": "2026-05-08T12:00:00Z",
"last_triggered_at": null,
"consecutive_failures": 0
}
/api/v1/webhooks
List all webhooks registered under your API key. Secrets are not returned — if you've lost one, use rotate-secret.
Example Response
{
"webhooks": [
{
"webhook_id": "f3c0e0e7-2c70-4f4f-9d8f-aaaaaaaaaaaa",
"url": "https://your-app.example.com/webhooks/signedoff",
"events": ["status_change"],
"is_active": true,
"created_at": "2026-05-08T12:00:00Z",
"last_triggered_at": "2026-05-08T13:42:11Z",
"consecutive_failures": 0
}
]
}
/api/v1/webhooks/{webhook_id}
Soft-deactivate a webhook. Only the API key that registered the webhook can delete it. Returns HTTP 200 with a confirmation body on success.
Example Request
curl -X DELETE -H "X-API-Key: YOUR_API_KEY" \
"https://signedoff.io/api/v1/webhooks/f3c0e0e7-2c70-4f4f-9d8f-aaaaaaaaaaaa"
Example Response (200)
{
"status": "deactivated",
"webhook_id": "f3c0e0e7-2c70-4f4f-9d8f-aaaaaaaaaaaa"
}
/api/v1/webhooks/{webhook_id}
Update a webhook without recreating it. All fields are optional — send only what changes:
url (re-validated like creation),
events,
jurisdiction_filter, or
is_active. Setting is_active: true re-enables an auto-disabled webhook and resets its failure counter (fix your receiver first).
Example Request
curl -X PATCH -H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
"https://signedoff.io/api/v1/webhooks/f3c0e0e7-2c70-4f4f-9d8f-aaaaaaaaaaaa" \
-d '{"events": ["status_change", "inspection_complete"], "is_active": true}'
/api/v1/webhooks/{webhook_id}/deliveries
Delivery history for a webhook, newest first — per-event status (pending / delivered / dead), attempt count, the exact payload sent, and the last error if delivery failed. Your first stop when events aren't arriving. ?limit= 1–100, default 20.
Example Response
{
"webhook_id": "f3c0e0e7-2c70-4f4f-9d8f-aaaaaaaaaaaa",
"deliveries": [
{
"id": "0b6c9a4e-1111-4222-8333-bbbbbbbbbbbb",
"event_type": "status_change",
"status": "dead",
"attempts": 5,
"payload": { /* the exact signed body that was POSTed */ },
"last_error": "HTTP 500",
"created_at": "2026-06-09T08:01:12Z",
"last_attempt_at": "2026-06-09T12:01:12Z",
"next_attempt_at": null
}
]
}
/api/v1/webhooks/{webhook_id}/rotate-secret
Generates a new whsec_ signing secret and returns it once. Deliveries sign with the new secret immediately, so update your receiver's stored secret first, then rotate.
Example Response (200)
{
"webhook_id": "f3c0e0e7-2c70-4f4f-9d8f-aaaaaaaaaaaa",
"secret": "whsec_<64-hex-chars>",
"warning": "Store the secret securely. It will not be shown again."
}
/api/v1/webhooks/{webhook_id}/test
Fires a reachability ping at the registered URL using a synthetic event: "test" payload. Use this to confirm your endpoint is accessible and that signature verification is wired up. For a full canonical-shape delivery (the same body your receiver will see in production), use /simulate instead.
Example Response — success
{
"success": true,
"status_code": 200,
"error": null
}
Example Response — failure (non-2xx or transport error)
{
"success": false,
"status_code": 500,
"error": "Internal Server Error"
}
On a transport-level failure (DNS error, connection refused, timeout), status_code is null and error describes the exception.
/api/v1/webhooks/{webhook_id}/simulate
POSTs a full canonical-shape payload to your registered URL — identical body and headers to a live production delivery — so you can verify your receiver's parser before any real event fires. Unlike /test (which sends event: "test" to check reachability), /simulate sends an actual event payload your handler must process. The request also includes the extra header X-SignedOff-Test: true so receivers can distinguish simulator calls from live events.
Example Request
curl -X POST -H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
"https://signedoff.io/api/v1/webhooks/f3c0e0e7-2c70-4f4f-9d8f-aaaaaaaaaaaa/simulate" \
-d '{"event":"status_change"}'
Request Body (optional)
Omit the body to simulate a status_change event. Pass an event field to choose a different type.
{
"event": "permit_approved"
}
Valid event values: status_change, inspection_complete, permit_approved, permit_denied, corrections_required.
Example Response — success
{
"ok": true,
"delivery": {
"status_code": 200,
"body_preview": "OK"
}
}
Example Response — transport failure
{
"ok": true,
"delivery": {
"status_code": 0,
"error": "ConnectError: [Errno 111] Connection refused"
}
}
A transport failure (DNS error, connection refused, timeout) returns status_code: 0 and an error string. The outer ok: true indicates the /simulate endpoint itself succeeded — only delivery.status_code reflects whether your receiver responded.
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()
Same pattern in JavaScript / TypeScript:
const 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",
};
async function lookupPermit(permitNumber, city) {
const slug = JURISDICTIONS[city];
const params = slug ? `?jurisdiction=${slug}` : "";
const resp = await fetch(
`https://api.signedoff.io/api/v1/permits/${permitNumber}/status${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.
AI Assistants (MCP)
SignedOff is a remote Model Context Protocol server.
Add it to Claude, Cursor, or any MCP-capable agent and it gets three tools —
get_permit_status,
batch_permit_status, and
list_jurisdictions —
so "what's the status of my permit?" works inside the assistant. MCP calls go through the same
auth, rate limits, and usage metering as the REST API. Without a key, tools serve the demo dataset.
Claude Code / Claude Desktop
claude mcp add --transport http signedoff "https://signedoff.io/mcp" \
--header "Authorization: Bearer YOUR_API_KEY"
Claude API (MCP connector)
{
"mcp_servers": [{
"type": "url",
"url": "https://signedoff.io/mcp",
"name": "signedoff",
"authorization_token": "YOUR_API_KEY"
}]
}
Drop the Authorization header to try it anonymously against the demo dataset (same limits as the playground). Setup guides for Claude Desktop, Cursor, and Continue live on the MCP server page.
Prefer no code? The Zapier integration pushes permit status changes into Slack, Google Sheets, and 6,000+ apps.
Rate Limits
Rate limits are tracked per API key on a monthly billing cycle. A per-minute burst limit also applies on every plan to keep traffic smooth — it's sized well above normal usage, so steady integrations never hit it.
| Plan | Monthly Limit | Burst Limit | force_refresh |
|---|---|---|---|
| Free | 200 | 3/min | No |
| Developer | 5,000 | 60/min | Yes (10x credit) |
| Pro | 25,000 | 120/min | Yes (10x credit) |
| Enterprise | Custom | 600/min | Yes (10x credit) |
Exceeding the burst limit returns 429 burst_limit_exceeded
with a retry_after_seconds field —
back off for that long and retry. Burst-limited calls don't count against your monthly quota.
For high-volume lookups, use batch-status
(up to 25 permits per request).
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
Anonymous demo responses report the demo's rolling 24-hour cap in these headers and add
X-Demo-Mode: true.
Sandbox (sk_test_…) usage is unmetered, so sandbox responses carry
X-Sandbox-Mode: true instead of quota headers.
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 per minute for your plan — wait retry_after_seconds and retry. See Burst Limit per plan. |
| 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.
Security
What the API guarantees about your data and your keys.
- All API traffic is HTTPS only — TLS is terminated at the Railway edge.
- API keys are encrypted at rest in Supabase Postgres.
- Per-key rate limits prevent abuse — see Rate Limits.
- The API is read-only. It cannot submit applications, modify permits, or alter government records in any way.
- Data is sourced from public government permit portals. The same information is available on the city websites we mirror.
Security questions or report a concern: support@signedoff.io.