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 permit response; paid plans can bypass the cache with force_refresh. When a permit number matches multiple jurisdictions, the API returns HTTP 300 with disambiguation candidates. Every response includes an X-Request-ID for support and tracing. 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

Official SDKs

The Python and TypeScript packages are generated from this API's committed compatibility contract, include every documented endpoint and model, and configure X-API-Key authentication once at client creation.

Python

pip install signedoff-api
from signedoff import create_client from signedoff.api.api_v_1 import get_permit_status client = create_client("YOUR_API_KEY") permit = get_permit_status.sync( "25044-30000-03525", client=client, jurisdiction="ladbs", )

Python 3.10+ · sync and async methods · typed response models

TypeScript

npm install @signedoff/api
import { createSignedOffClient } from "@signedoff/api"; const signedoff = createSignedOffClient({ apiKey: "YOUR_API_KEY" }); const { data, error } = await signedoff.GET( "/api/v1/permits/{permit_id}", { params: { path: { permit_id } } }, );

Node.js 18+, browsers and edge runtimes · strict path, parameter, body and response types

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)
Prefer header string Set to respond-async with force_refresh=true to request a durable operation. Explicit browser-backed cache misses return a durable operation automatically.

Example Response

{ "permit_number": "24044-20000-03620", "permit_id": "1fa42597-f462-56b2-a0b7-9fab807cf945", "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, "fetched_at": "2026-05-06T08:00:00Z", "last_successful_fetch_at": "2026-05-06T08:00:00Z", "last_attempted_fetch_at": "2026-05-06T08:00:00Z", "is_stale": false, "stale_reason": null, "next_refresh_at": "2026-05-06T12:00:00Z" } }
POST /api/v1/permit-resolutions

Normalizes a permit number plus jurisdiction into an immutable canonical UUID. Resolution performs no portal scrape. If resource_available is false, call the returned status_url to populate the tenant-neutral resource.

curl -X POST -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"permit_number":"ABC-123","jurisdiction":"ladbs"}' \ "https://signedoff.io/api/v1/permit-resolutions"
{ "permit_id": "be30a966-448c-5e21-a9b5-5143b4c71f22", "permit_number": "ABC-123", "jurisdiction": "ladbs", "confidence": "explicit", "resource_available": true, "resource_url": "/api/v1/permits/be30a966-448c-5e21-a9b5-5143b4c71f22" }

Omit jurisdiction only for distinctive permit formats. Ambiguous numbers return HTTP 300 with ranked candidate jurisdictions and the canonical ID each choice would produce.

GET /api/v1/permits/{permit_id}

Retrieves the latest successful tenant-neutral representation by stable UUID. Important values carry their source system, source URL, observation time, normalization version, confidence, availability, and a retained raw portal value when one exists.

curl -s -H "X-API-Key: YOUR_API_KEY" \ "https://signedoff.io/api/v1/permits/be30a966-448c-5e21-a9b5-5143b4c71f22"
{ "permit_id": "be30a966-448c-5e21-a9b5-5143b4c71f22", "permit_number": "ABC-123", "jurisdiction": "ladbs", "status": { "value": "In Review", "raw_value": "Plan Check - Corrections Issued", "provenance": { "source_system": "ladbs", "observed_at": "2026-07-28T12:00:00Z", "normalization_version": "status-v1", "confidence": "normalized", "availability": "available" } }, "conditions": { "count": 1, "href": "/api/v1/permits/ABC-123/conditions?jurisdiction=ladbs" } }

not_observed means the latest successful representation lacks that field; it is not an assertion that the agency has no value. Legacy parsed dates may have raw_value: null because the original portal date string was not retained.

GET /api/v1/operations/{operation_id}

Slow live refreshes can run as durable asynchronous operations. Explicit-jurisdiction cache misses for browser-backed portals do so automatically; paid callers can also request this contract for any force refresh with Prefer: respond-async. This avoids holding your HTTP connection open against a municipal portal and lets SignedOff recover the work after a deploy or worker restart.

1. Request an asynchronous refresh

curl -i \ -H "X-API-Key: YOUR_API_KEY" \ -H "Prefer: respond-async" \ "https://signedoff.io/api/v1/permits/25044-30000-03525/status?force_refresh=true&jurisdiction=ladbs"

This requires a paid live API key and a resolvable jurisdiction. SignedOff responds with 202 Accepted, a Location polling URL, Preference-Applied: respond-async, and Retry-After: 2.

An ordinary explicit-jurisdiction lookup can also return 202 Accepted when a browser-backed portal misses the fresh cache. In that server-selected case, follow Location and Retry-After; Preference-Applied is omitted because the client did not request it. The lookup retains its normal one-credit weight.

{ "operation_id": "50859c7b-bf9b-44c1-9a52-d6b6758f5dac", "operation_type": "permit_refresh", "status": "pending", "poll_url": "/api/v1/operations/50859c7b-bf9b-44c1-9a52-d6b6758f5dac", "created_at": "2026-07-20T19:40:52Z", "started_at": null, "completed_at": null, "result": null, "error": null }

2. Poll with the same API key

curl -s \ -H "X-API-Key: YOUR_API_KEY" \ "https://signedoff.io/api/v1/operations/50859c7b-bf9b-44c1-9a52-d6b6758f5dac"

Polling is owner-scoped and does not consume lookup credits. Continue while the status is pending or running. A terminal operation is either succeeded with the normal permit response in result, or failed with a stable code, message, and retryable value in error.

{ "operation_id": "50859c7b-bf9b-44c1-9a52-d6b6758f5dac", "operation_type": "permit_refresh", "status": "succeeded", "result": { "permit_number": "25044-30000-03525", "jurisdiction": "ladbs", "status": "Issued", "portal_status": "Issued on 1/22/2025", "source": "live_scrape" }, "error": null }

3. Receive a completion webhook

Register an owner-scoped webhook with operation_completed to receive one canonical event when the operation reaches either succeeded or failed. Its data contains the operation ID, type, terminal status, polling URL, completion time, and the same stable result or typed error returned by polling. If enqueueing is interrupted by a deploy, the durable operation worker recovers it without emitting a second source event.

{ "event_id": "b83c9f51-a279-4e1f-a8bc-44f531bc19ac", "delivery_id": "d290f1ee-6c54-4b01-90e6-d701748f0851", "event": "operation_completed", "permit_number": "25044-30000-03525", "jurisdiction": "ladbs", "jurisdiction_display": "LADBS (City of Los Angeles)", "data": { "operation_id": "50859c7b-bf9b-44c1-9a52-d6b6758f5dac", "operation_type": "permit_refresh", "status": "succeeded", "poll_url": "/api/v1/operations/50859c7b-bf9b-44c1-9a52-d6b6758f5dac", "result": {"status": "Issued"}, "error": null, "completed_at": "2026-07-20T19:40:55Z" }, "timestamp": "2026-07-20T19:40:55Z" }
Credit settlement

The refresh reserves 10 lookup credits when accepted. A successful refresh or a portal-confirmed missing permit consumes them. Retryable upstream failures, timeouts, and unexpected operation failures release the reservation automatically. Poll requests are not billed.

POST /api/v1/permits/batch-status

Look up status for multiple permits in one request. Maximum 25 permits per request. Browser-backed cache misses appear in operations; poll each supplied URL with the same API key.

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, "status_code": 404, "retryable": false } ], "operations": [] }

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. Each error includes status_code and retryable; timeouts use upstream_timeout, while confirmed missing permits use not_found.

Always check operations too. Each entry is a standard permit_refresh operation; poll its poll_url until it succeeds or fails. The original batch still bills exactly one lookup credit per submitted permit, and polling is free.

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}/conditions

Returns source-published holds, notices, correction requirements, and conditions of approval from the latest successful portal representation. Detail varies by jurisdiction: Accela sources may publish itemized conditions, while Tyler-derived portals generally expose only active-hold signals.

Example Request

curl -s -H "X-API-Key: YOUR_API_KEY" \ "https://signedoff.io/api/v1/permits/25044-30000-03525/conditions?jurisdiction=aca:sbc"

Example Response

{ "permit_number": "25044-30000-03525", "jurisdiction": "aca:sbc", "observed": true, "summary": { "total": 2, "required": 1, "met": 1 }, "groups": [{ "name": "Building Permit Requirements", "outstanding": 1, "complete": 1 }], "items": [{ "name": "Revise structural plans", "status": "Required", "stage": "Plan Review" }] }

Run a status lookup first to create or refresh the tenant-neutral permit representation. observed=false means that representation contained no condition detail. It does not prove the agency has no conditions; consult the jurisdiction coverage profile and official portal before treating absence as clearance.

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

Returns source-published status and workflow history. Timeline entries preserve the review discipline, actor, and comment when published; is_correction identifies correction states. The separate workflow array retains named pending and completed source activities without relabeling administrative steps as inspections.

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": "Corrections Required", "changed_at": "2026-01-15", "discipline": "Structural", "is_correction": true } ], "workflow": [ { "name": "Application intake", "status": "Complete", "completed_at": "2026-01-10" } ] }

Source dates may be date-only strings. Treat absent actor, comment, or discipline fields as “not published,” not as empty official values.

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/coverage No auth required

Returns API capability availability, field-level fill rates, and continuous data-release observations for every supported jurisdiction. Fill rates use successful tenant-neutral API lookups from a rolling 90-day window and never expose permit or customer identifiers.

No sample is not zero coverage. A null fill rate with an unknown tier means SignedOff has no successful API observation for that jurisdiction in the window. Observed fill rates describe the sample; they are not availability guarantees.

Coverage tiers

Tier Meaning
reliableAt least 80% of sampled records contain the field.
partialMore than 0% but less than 80% contain it.
missingA sample exists, but none contain the field.
unknownNo sample exists in the observation window.

Example Response (unobserved jurisdiction)

{ "sample_window_days": 90, "jurisdictions": [{ "slug": "aca:glendale", "capabilities": { "status_lookup": "available", "inspections": "partial", "workflow_history": "partial", "discovery": "unavailable", "conditions": "unavailable" }, "field_coverage": [{ "field": "status", "tier": "unknown", "fill_rate": null, "records_present": 0, "sample_size": 0 }], "data_release": { "mode": "continuous", "sample_size": 0, "observed_from": null, "observed_through": null } }] }
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. unique_keys_count is the number of authenticated API keys that requested the lookup jurisdiction; anonymous website requests contribute to request_count but not that authenticated-key count.

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. An authenticated API key contributes at most one vote for a jurisdiction: repeating the request with the same key returns the current aggregate without increasing either count. Use an Idempotency-Key for transport-safe retries as usual.

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" }
Delivery retries

Return any 2xx response after safely accepting an event. Failed deliveries are retried up to 8 total attempts over roughly two days with jitter. A valid Retry-After header is honored. HTTP 410 permanently disables the webhook; other repeated failures become dead deliveries. SignedOff revalidates the destination before every attempt to prevent redirects or DNS changes from reaching private networks.

Webhooks

Subscribe to permit and asynchronous-operation 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.

Which permits trigger events

SignedOff emits events for permits it actively monitors. A permit becomes monitored once it is tracked in a SignedOff account (added to a project / dashboard); its status is then re-checked on SignedOff's regular sync, and any change fans out to every active webhook whose events and optional jurisdiction_filter match. Looking a permit up through the API (e.g. GET /permits/{number}/status) returns its current status but does not by itself enroll it in monitoring — an API-only lookup will not, on its own, generate webhook events. To receive events for a permit, make sure it is tracked. API integrators: enroll a permit with POST /api/v1/permits/{number}/watch to receive events without using the dashboard — see Watching permits.

operation_completed does not require a watched permit. It is emitted only for a durable operation created by the same API key that owns the webhook.

criteria_match does not require an individual permit watch. It is emitted to the API key that owns a saved monitored discovery query when a permit is first seen after that query's baseline.

permit_created is independent of saved queries. A shared daily LADBS and EPIC-LA discovery monitor emits it when a permit identity is first observed by SignedOff after that source’s no-event baseline. It requires a paid live key with the Permit Discovery add-on and an explicit webhook subscription, but does not consume saved-query slots or API credits. The three-day overlapping source window catches ordinary late publication; a partial or capped source read emits nothing and retries later. This is an observation event, not proof of the government’s legal filing time, and the initial baseline never produces a historical backfill.

Limits
Active webhooks per key: Free 1 · Developer 5 · Pro 10 · Enterprise 25
Signature
Standard Webhooks HMAC-SHA256 via webhook-signature; legacy SignedOff headers remain supported

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
operation_completedAn owner-scoped durable operation reaches succeeded or failed
criteria_matchA saved discovery query first sees a permit after its baseline
permit_createdThe shared LADBS or EPIC-LA monitor first observes a permit identity after its source baseline

Receiving deliveries

When a subscribed event fires, SignedOff POSTs a signed JSON body to your registered URL. New integrations should use the Standard Webhooks headers; the legacy SignedOff headers are sent alongside them for compatibility.

Delivery history and the canonical events feed are available for 30 days. Use event_id to identify the source event across subscriptions or manual replays, and delivery_id / webhook-id to deduplicate automatic retries of one delivery.

Delivery and ordering contract
  • Webhook delivery is at-least-once. A receiver can see the same delivery_id more than once, so acknowledge only after durable, idempotent processing.
  • Each webhook subscription has at most one in-flight attempt at a time, but event arrival order is not guaranteed: a newer event can overtake an older failed event waiting for retry backoff, and a manual replay can arrive later still.
  • Automatic retries keep both event_id and delivery_id. A manual replay keeps the original event_id and timestamp but gets a fresh delivery_id.
  • Use GET /api/v1/events as the authoritative reconciliation sequence. It is stable and oldest-first by ingestion, not necessarily by occurred_at, webhook arrival, or delivery-history order.
  • One source observation can emit multiple event types. Treat each distinct event_id independently.
Header Value
Content-Typeapplication/json
webhook-idStable delivery UUID; unchanged across retries and equal to the payload's delivery_id
webhook-timestampUnix timestamp for this delivery attempt
webhook-signatureStandard Webhooks signature in v1,<base64-HMAC> format
X-SignedOff-SignatureLegacy HMAC-SHA256 hex digest of the raw body; retained for existing receivers
X-SignedOff-EventThe event type, e.g. status_change

Sample delivery payload

{ "event_id": "b83c9f51-a279-4e1f-a8bc-44f531bc19ac", "delivery_id": "d290f1ee-6c54-4b01-90e6-d701748f0851", "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 signature

Prefer a Standard Webhooks SDK and pass it the webhook-id, webhook-timestamp, and webhook-signature headers plus the raw request body bytes. For a manual implementation, base64-decode the part of your secret after whsec_, then compute HMAC-SHA256 over webhook-id.webhook-timestamp.raw_body and compare its base64 value to the v1 signature using constant time. Reject stale timestamps to limit replay attacks. The examples below show the legacy verifier for existing integrations.

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_<base64-key>", "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 } ] }
GET /api/v1/events

Read your API key's canonical event feed in stable, oldest-first ingestion order. Use this sequence to reconcile duplicated or out-of-order webhook arrivals. occurred_at is when SignedOff observed the source event; created_at is when it was durably appended, and feed order follows ingestion rather than timestamp sorting. Results are retained for 30 days. Pass each response's opaque, API-key-bound next_cursor back as ?cursor= to continue or poll; an empty poll echoes the checkpoint. Once the cursor's event expires, the cursor is invalid. Manual replays do not create duplicate feed events.

curl -H "X-API-Key: YOUR_API_KEY" \ "https://signedoff.io/api/v1/events?limit=50&cursor=uDyfUaJ5Th-ovET1MbwZrA"
POST /api/v1/webhooks/{webhook_id}/rotate-secret

Generates a new whsec_ signing secret and returns it once. For 24 hours, webhook-signature contains valid v1 signatures from both the new and previous secrets, allowing zero-downtime rotation. Configure the new secret during that window and then remove the old one.

Example Response (200)

{ "webhook_id": "f3c0e0e7-2c70-4f4f-9d8f-aaaaaaaaaaaa", "secret": "whsec_<base64-key>", "previous_secret_valid_until": "2026-05-09T12:00:00Z", "warning": "Store the secret securely. It will not be shown again." }
POST /api/v1/webhooks/{webhook_id}/deliveries/{delivery_id}/replay

Queue a fresh attempt for a retained delivered or dead delivery. The webhook must be active. The replay uses its current URL and signing secret, keeps the original event_id and event timestamp, and receives a new delivery_id. Send an Idempotency-Key so a client retry cannot enqueue the replay twice.

curl -X POST -H "X-API-Key: YOUR_API_KEY" \ -H "Idempotency-Key: replay-0b6c9a4e" \ "https://signedoff.io/api/v1/webhooks/f3c0e0e7-2c70-4f4f-9d8f-aaaaaaaaaaaa/deliveries/0b6c9a4e-1111-4222-8333-bbbbbbbbbbbb/replay"
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, operation_completed, criteria_match, permit_created.

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.

Watching permits

Enroll specific permits for daily monitoring without adding them to a dashboard project. SignedOff checks each watched permit once per day and delivers status_change, permit_approved, and corrections_required events to your registered webhooks — using the same HMAC signature and delivery_id as all other webhook deliveries. You only receive events for permits you watch.

Plan caps (PLAN_WATCH_LIMITS)

Free: 1 watch · Developer: 10 · Pro: 50 · Enterprise: 250. Downgrading a plan never cancels existing watches — it only blocks new enrollments above the new limit.

Terminal auto-free

When a watched permit reaches a terminal state (expired / denied / cancelled), its final event fires and the watch is marked completed. Completed watches stop counting against your cap; you can re-watch any time.

POST /api/v1/permits/{permit_number}/watch

Enroll a permit for daily monitoring. Returns 201 if newly enrolled, 200 if already watching. Optional ?jurisdiction=slug disambiguates Accela permits (same slugs as the status endpoint). Requires a live sk_live_ key — sandbox keys are not supported.

Example Request

curl -X POST -H "X-API-Key: YOUR_API_KEY" \ "https://signedoff.io/api/v1/permits/BLDG-2026-000391/watch?jurisdiction=aca:sbc"

Example Response (201)

{ "permit_number": "BLDG-2026-000391", "jurisdiction": "aca:sbc", "jurisdiction_display": "San Bernardino County", "status": "Plan Check - In Progress", "status_phase": "pending", "watching": true, "created_at": "2026-06-22T09:00:00Z" }

A warning field appears in the response when the key has no active webhook registered — you'll be watching but no deliveries will be sent until you subscribe a webhook.

Error responses

Status Error key Description
400 watch_limit_reached You have reached your plan's watch cap. Response includes plan_limit and upgrade_hint.
400 invalid_jurisdiction The ?jurisdiction= slug is not recognised.
300 multiple_jurisdictions Permit format matches multiple jurisdictions. Response includes a candidates array — re-submit with ?jurisdiction=slug.
404 jurisdiction_not_supported No supported jurisdiction detected for this permit number.
403 sandbox_not_supported Watches require a live sk_live_ key. Sandbox keys cannot enroll watches.
DELETE /api/v1/permits/{permit_number}/watch

Stop watching a permit. Returns 200 on success, 404 if the watch does not exist under your key.

Example Request

curl -X DELETE -H "X-API-Key: YOUR_API_KEY" \ "https://signedoff.io/api/v1/permits/BLDG-2026-000391/watch"

Example Response (200)

{ "status": "unwatched", "permit_number": "BLDG-2026-000391" }

Error responses

Status Error key Description
404 watch_not_found No active watch for this permit exists under your API key.
GET /api/v1/permits/watches

List all active watches under your API key, with your plan cap and remaining slots.

Example Request

curl -s -H "X-API-Key: YOUR_API_KEY" \ "https://signedoff.io/api/v1/permits/watches"

Example Response

{ "watches": [ { "permit_number": "BLDG-2026-000391", "jurisdiction": "aca:sbc", "status": "Plan Check - In Progress", "created_at": "2026-06-22T09:00:00Z", "last_event_at": null } ], "total": 1, "plan_limit": 10, "remaining": 9 }

Permit discovery (search) NewBeta — live

Find permits by criteria instead of by permit number — city or county scope, a parcel portfolio, ZIP, a lat/lon radius, permit type, date range, valuation, or status. Discovery runs a live query against the source portals (no warehouse), so results reflect what the jurisdiction has on file right now. v1 is a scoped LA-County wedge: LADBS (City of Los Angeles) and EPIC-LA (LA County unincorporated). Omit geography and jurisdiction to search both and get a merged, newest-first result set. A plain-language overview lives on the permit search API page.

Requires the Permit Discovery add-on

Free keys get a flag-independent taste (5 ad hoc searches/month) but no saved monitoring. Developer and Pro plans need the add-on enabled (discovery_enabled on your key) — included searches/month: Developer 400 · Pro 1,500 · Enterprise 10,000; active saved-query caps: Developer 3 · Pro 10 · Enterprise 50. Add-on pricing (provisional): +$39/mo (Developer) · +$99/mo (Pro) — currently included free during beta. Manage it on the Billing & add-ons page. Sandbox (sk_test_…) keys are not supported — discovery always hits live portals, so there's no deterministic sandbox dataset for it.

At least one active filter is required

Supply at least one of city, county, parcel, zip, lat+lon+radius_mi, date_from/date_to, permit_type, value_min/value_max, or status, or the request is rejected with 400 filter_required. city=Los Angeles selects LADBS; county=Los Angeles County selects EPIC-LA's unincorporated records. Repeat parcel up to 25 times to search a property portfolio in one bounded request.

Discover → enrich: search results are intentionally thin — no owner, contractor, or inspection data. Current LADBS and EPIC-LA discovery feeds do not expose contractor identity, so contractor returns 400 unsupported_filter rather than being silently ignored or served from a frozen historical dataset. Once you have a permit_number and jurisdiction from a search hit, follow up with GET /permits/{permit_number}/status (pass ?jurisdiction= from the search result) to get the full detail record.

POST GET PATCH DELETE /api/v1/saved-searches

Save the same supported discovery filters for daily monitoring. Create and update require a paid live key with the Permit Discovery add-on. List, get, and delete remain available if the add-on is later disabled so you can inspect or remove retained definitions.

Baseline-first, first-seen semantics
  • The first successful daily evaluation stores up to the 100 newest matching permit identities as a baseline and emits no historical events.
  • Later first-seen identities emit one owner-scoped criteria_match canonical event. Its data includes saved_search_id, name, criteria, a thin match snapshot, and matched_at.
  • A partial or failed live-source evaluation records the error and emits no matches; the next daily run retries. Municipal source availability can delay detection.
  • Changing criteria clears first-seen identities and makes the next successful evaluation a fresh baseline. Pausing preserves the current baseline; deleting removes the definition and identities.
  • CRUD and scheduled evaluations consume no lookup or discovery credits. Limits are active-query caps, and webhook delivery remains at-least-once; reconcile through GET /api/v1/events.
curl -s -X POST \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: westside-electrical-v1" \ -d '{"name":"Westside electrical permits","criteria":{"zip":"90064","permit_type":"Electrical"}}' \ "https://signedoff.io/api/v1/saved-searches"

Use GET /api/v1/saved-searches to list definitions, GET /api/v1/saved-searches/{saved_search_id} for evaluation state, PATCH with {"status":"paused"} or replacement criteria, and DELETE to remove it. POST supports the optional Idempotency-Key header for safe retries.

Jurisdictions & Disambiguation

SignedOff covers 41+ 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:sbc", "display_name": "San Bernardino County", "confidence": "strong", "reason": "permit number matches this jurisdiction's format"}, {"slug": "aca:ont", "display_name": "City of Ontario", "confidence": "weak", "reason": "format compatible; not distinctive"}, {"slug": "aca:cosp", "display_name": "City of South Pasadena", "confidence": "weak", "reason": "format compatible; not distinctive"}, ... ], "example": "/api/v1/permits/BLDG-2026-000391/status?jurisdiction=aca:sbc" }

Candidates are ranked strongest-match first: confidence is strong (permit number format is distinctive to that jurisdiction), weak (format is compatible but shared with other cities), or none (format doesn't typically match — still listed, never dropped). Within a confidence tier, pass ?near=lat,lon (e.g. ?near=34.05,-118.24) to break ties by distance to your location. ZIP code lookup for near isn't supported yet — use decimal lat/lon. An invalid or malformed near value is ignored silently and ranking falls back to confidence-only ordering.

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://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://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 twelve tools for permit search, status and batch lookup, jurisdiction coverage, canonical identity resolution, inspections, conditions, history, and watch management. This supports discovery-to-monitoring workflows inside the assistant. MCP calls go through the same auth, rate limits, and usage metering as the REST API. Without a key, supported read tools serve the demo dataset; watches require a live key.

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.

Testing & Sandbox

Every account gets a sandbox key alongside its live key. Sandbox keys are prefixed sk_test_ and return a stable, curated demo dataset, so you can build and run integration tests without burning quota or hitting live city portals. Grab yours from the developer dashboard.

What you get
  • Deterministic demo permits — the same response every call
  • No quota accounting and no live scrapes
  • Responses carry X-Sandbox-Mode: true
Coverage

Sandbox covers GET /permits/{number}/status and POST /permits/batch-status. Other endpoints (inspections, history) return 404 for a sandbox key — use a live key for those.

curl -s -H "X-API-Key: sk_test_YOUR_SANDBOX_KEY" "https://signedoff.io/api/v1/permits/25044-30000-03525/status"

The response shape is identical to live data, so code written against a sandbox key works unchanged against your live key — just swap the key.

Retries & integration patterns

A retry is a new API request. Use bounded exponential backoff with full jitter, honor Retry-After as the minimum delay, and stop after a maximum of four attempts including the first call. As practical starting values, use a 5-second connection timeout, a 30-second response timeout, an initial 500 ms backoff, and an 8-second backoff cap. Send a fresh valid X-Request-ID for every attempt and record the echoed response value for support.

Outcome Recommended action
Network error, 408, 500, 502, 503, 504 Retry an ordinary read with bounded exponential backoff and full jitter. Honor Retry-After when present.
429 with Retry-After Wait at least the supplied delta-seconds value, then retry. Add jitter above that floor when many workers may retry together.
429 monthly quota without Retry-After Do not retry monthly quota responses in a tight loop. Wait for X-RateLimit-Reset, enable an allowed overage, or change plans. Check GET /api/v1/usage before resuming.
409 idempotency_key_processing Wait for the response body's retry_after seconds, then resend the same Idempotency-Key and byte-identical request body.
300 or other 4xx Do not retry unchanged. Follow disambiguation candidates or correct authentication, parameters, permissions, or the request body first.
200 discovery response with partial=true Keep the returned results, inspect warnings, and retry the affected search later if needed. Do not treat a partial response as an empty result or blindly replay its cursor.

Ordinary reads

Status, operation polling, inspections, history, usage, jurisdiction, webhook-list, and events-feed reads are safe to retry for state correctness. A retry can still consume another credit when the first response reached SignedOff but was lost in transit, so keep attempts bounded.

Writes with idempotency

Send one unique key per logical action and reuse that key with the byte-identical body when retrying POST /api/v1/webhooks, POST /api/v1/jurisdictions/request, or POST /api/v1/webhooks/{webhook_id}/deliveries/{delivery_id}/replay. Successful responses remain replayable for 24 hours.

Billable or effectful requests

Do not automatically retry an ambiguous timeout from force_refresh=true, async operation creation, batch status, or discovery. The server may already have accepted and billed the first request. For a received 202 Accepted, persist its Location and poll that operation; do not submit another force refresh.

Webhooks and reconciliation

Verify the signature, deduplicate automatic attempts by delivery_id, commit work durably, then return 2xx quickly. Use event_id plus the oldest-first events feed as the authoritative reconciliation path when deliveries are delayed or arrive out of order.

Python: bounded retries for an ordinary read

import random import time import uuid import requests RETRYABLE = {408, 500, 502, 503, 504} def get_with_retry(url, api_key): for attempt in range(4): response = None try: response = requests.get( url, headers={ "X-API-Key": api_key, "X-Request-ID": f"client_{uuid.uuid4().hex}", }, timeout=(5, 30), ) retry_after = response.headers.get("Retry-After") should_retry = response.status_code in RETRYABLE or ( response.status_code == 429 and retry_after is not None ) if not should_retry: response.raise_for_status() return response.json() except (requests.ConnectionError, requests.Timeout): if attempt == 3: raise if attempt == 3: response.raise_for_status() backoff_cap = min(8.0, 0.5 * (2 ** attempt)) jitter = random.uniform(0, backoff_cap) retry_after = float(response.headers["Retry-After"]) if ( response is not None and response.headers.get("Retry-After", "").isdigit() ) else 0 time.sleep(max(retry_after, jitter))
This helper is intentionally for ordinary reads. Do not wrap every HTTP method in a generic retry interceptor. For an idempotency-enabled POST, generate the key once outside the retry loop and resend the exact same serialized body. For an async refresh, retry only the polling URL after the initial 202 is received.

Idempotency

Safely retry a creating POST by sending an Idempotency-Key header (any unique string — a UUID is ideal). Honored on POST /api/v1/webhooks and POST /api/v1/jurisdictions/request, plus POST /api/v1/saved-searches, plus POST /api/v1/webhooks/{webhook_id}/deliveries/{delivery_id}/replay. Omit the header and the endpoint behaves exactly as before.

  • The first request runs normally; its successful (2xx) response is stored for 24 hours.
  • A repeat with the same key and the same body replays that stored response, with header Idempotent-Replayed: true — the side effect runs only once.
  • Same key but a different body returns 422 idempotency_key_reuse.
  • A repeat while the first is still in-flight returns 409 idempotency_key_processing — wait for the response body's retry_after value, then resend the same key and byte-identical body.
  • If the first request fails (non-2xx), nothing is stored — just retry.
curl -X POST -H "X-API-Key: YOUR_API_KEY" \ -H "Idempotency-Key: 5f3b9c2a-e14b-4c3d-a982-1234567890ab" \ -H "Content-Type: application/json" \ -d '{"url":"https://example.com/hook","events":["status_change"]}' \ "https://signedoff.io/api/v1/webhooks"

On a replayed response the Idempotent-Replayed: true header is set so your client can distinguish a fresh creation from a cached replay.

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.

Usage

Read the current UTC calendar-month lookup and discovery meters without consuming a credit. Each meter reports used, limit, remaining, percent_used, whether it is metered, and whether that capability is enabled. Sandbox keys return unmetered meters with null limits.

curl -H "X-API-Key: YOUR_API_KEY" "https://signedoff.io/api/v1/usage"
{ "plan": "developer", "is_sandbox": false, "period_start": "2026-07-01T00:00:00Z", "resets_at": "2026-08-01T00:00:00Z", "overage_enabled": false, "lookup": {"used": 168, "limit": 5000, "remaining": 4832, "percent_used": 3.36, "metered": true, "enabled": true}, "discovery": {"used": 4, "limit": 400, "remaining": 396, "percent_used": 1.0, "metered": true, "enabled": true} }

Error Codes

API errors use application/problem+json and the RFC 9457 fields below. The stable machine code is available as both code and the backward-compatible error alias. Send your own X-Request-ID (8–128 letters, numbers, dots, dashes, or underscores) or record the generated response header and matching request_id when contacting support.

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

{ "type": "https://signedoff.io/problems/rate_limit_exceeded", "title": "Too Many Requests", "status": 429, "detail": "Monthly API call limit reached. Resets on August 1.", "instance": "/api/v1/permits/24044-20000-03620/status", "code": "rate_limit_exceeded", "error": "rate_limit_exceeded", "request_id": "req_2a8f3b47a0b74ee2a69ca5ee31043f20" }

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 permit response includes a data_freshness field. In addition to age and cache flags, it distinguishes the most recent successful fetch from the most recent attempt. If a portal fails while cached data exists, SignedOff returns that last-known-good representation with source: "stale_cache", is_stale: true, and a machine-readable stale_reason instead of turning a portal outage into a false 404.

Free Plan (cache only)

"data_freshness": { "age_seconds": 7200, "cached": true, "refresh_available": false, "last_successful_fetch_at": "2026-07-20T08:00:00Z", "last_attempted_fetch_at": "2026-07-20T08:00:00Z", "is_stale": false, "stale_reason": null }

Paid Plan (refresh available)

"data_freshness": { "age_seconds": 7200, "cached": true, "refresh_available": true, "last_successful_fetch_at": "2026-07-20T08:00:00Z", "last_attempted_fetch_at": "2026-07-20T08:00:00Z", "is_stale": false, "stale_reason": null }

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.