Try it live — no key needed
Run every endpoint against demo permits in the playground, no signup. Or explore the full reference with your key →
Try endpoints live
No code · New

Send permit status changes to Slack, Sheets & 6,000+ apps

Connect SignedOff in Zapier — no client to write.

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

Prefer Postman?

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.

Download Postman collection

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": [ { "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.

GET /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.

GET /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.

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" } }
GET /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.

Why this matters: No competitor exposes processing-time medians for these jurisdictions — this is data SignedOff has accumulated through continuous portal monitoring since 2024.

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.

GET /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" } ] }
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" }

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.

Limits
Active webhooks per key: Free 1 · Developer 5 · Pro 10 · Enterprise 25
Signature
HMAC-SHA256 in the X-SignedOff-Signature header

Event types

Event Fires when
status_changePermit's portal_status differs from the previous scrape
inspection_completeA new inspection result lands (pass / fail / etc.)
permit_approvedStatus transitions to Issued / Approved / Final
permit_deniedStatus transitions to Denied / Withdrawn / Expired
corrections_requiredPortal 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-Typeapplication/json
X-SignedOff-SignatureHMAC-SHA256 hex digest of the raw request body, keyed by your whsec_ secret
X-SignedOff-EventThe 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 ...
POST /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 }
GET /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 } ] }
DELETE /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" }
PATCH /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}'
GET /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 } ] }
POST /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." }
POST /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.

POST /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=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()

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.