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
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.
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
/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"
}
}
/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.
/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.
/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"
}
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.
/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.
/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}/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.
/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.
/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/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.
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 |
|---|---|
| reliable | At least 80% of sampled records contain the field. |
| partial | More than 0% but less than 80% contain it. |
| missing | A sample exists, but none contain the field. |
| unknown | No 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
}
}]
}
/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. 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"
}
]
}
/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"
}
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.
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.
webhook-signature; legacy SignedOff headers remain supportedEvent 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 |
| operation_completed | An owner-scoped durable operation reaches succeeded or failed |
| criteria_match | A saved discovery query first sees a permit after its baseline |
| permit_created | The 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.
- Webhook delivery is at-least-once. A receiver can see the same
delivery_idmore 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_idanddelivery_id. A manual replay keeps the originalevent_idandtimestampbut gets a freshdelivery_id. - Use
GET /api/v1/eventsas the authoritative reconciliation sequence. It is stable and oldest-first by ingestion, not necessarily byoccurred_at, webhook arrival, or delivery-history order. - One source observation can emit multiple event types. Treat each distinct
event_idindependently.
| Header | Value |
|---|---|
| Content-Type | application/json |
| webhook-id | Stable delivery UUID; unchanged across retries and equal to the payload's delivery_id |
| webhook-timestamp | Unix timestamp for this delivery attempt |
| webhook-signature | Standard Webhooks signature in v1,<base64-HMAC> format |
| X-SignedOff-Signature | Legacy HMAC-SHA256 hex digest of the raw body; retained for existing receivers |
| X-SignedOff-Event | The 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 ...
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_<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
}
/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/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"
/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."
}
/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"
/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, 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_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.
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.
/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. |
/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. |
/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.
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.
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.
/api/v1/permits/search
Search recent LA-County permits by geography, parcel portfolio, or permit criteria. Live query over LADBS + EPIC-LA; requires the Permit Discovery add-on.
Query Parameters
| Name | Type | Description |
|---|---|---|
| zip | string | 5-digit ZIP code. On EPIC-LA there's no dedicated ZIP field, so this is a best-effort substring match against the address — treat it as literally-ish, not an exact filter. |
| lat | float | Latitude for a radius search. Requires lon and radius_mi. |
| lon | float | Longitude for a radius search. Requires lat and radius_mi. |
| radius_mi | float | Search radius in miles around lat/lon. Omitting lat or lon while set returns 400 invalid_radius. |
| permit_type | string | Building | Electrical | Mechanical | Plumbing. On EPIC-LA this matches against the portal's own work-class vocabulary (substring, not exact) — some result rows may still come back with permit_type: null where the source portal's vocabulary doesn't map cleanly to SignedOff's trade categories. |
| date_from | date | ISO date (YYYY-MM-DD). Filters on issue date. Must be ≤ date_to when both are set. |
| date_to | date | ISO date (YYYY-MM-DD). |
| value_min | integer | Minimum permit valuation, in dollars. Must be ≤ value_max when both are set. |
| value_max | integer | Maximum permit valuation, in dollars. |
| status | string | Portal status string, exact match (e.g. Issued). Status vocabulary is portal-specific and not normalized in v1. |
| city | string | Currently accepts Los Angeles, City of Los Angeles, or LA City (case-insensitive) and selects LADBS. Other cities return 400 unsupported_city. |
| county | string | Currently accepts Los Angeles County, County of Los Angeles, or LA County (case-insensitive) and selects EPIC-LA's unincorporated-county records. Other counties return 400 unsupported_county. |
| parcel | string[] | A 10-digit LA APN/AIN. Repeat the parameter up to 25 times for a bounded property portfolio. Spaces and hyphens are removed, duplicates collapse, and source matching is exact. |
| contractor | string | Reserved but unsupported by the current source feeds. Supplying it returns 400 unsupported_filter before portal I/O or discovery usage is recorded. |
| jurisdiction | string | ladbs | epicla. Omit to search both backends and merge results newest-first. |
| limit | integer | Results per page, 1–50. Default 25. |
| cursor | string | Opaque pagination cursor from a previous response's pagination.next_cursor. Bound to the exact filter set that produced it — reusing it with different filters returns 400 invalid_cursor. |
Example Request
curl -s -H "X-API-Key: YOUR_API_KEY" \
"https://signedoff.io/api/v1/permits/search?zip=90064&value_min=5000&limit=10"
Example Response
{
"results": [
{
"permit_number": "26016-90000-16366",
"jurisdiction": "ladbs",
"address": "10960 WELLWORTH AVE",
"zip_code": "90064",
"permit_type": "Bldg-Addition",
"status": "Issued",
"issue_date": "2026-05-20",
"valuation": 7500,
"latitude": 34.04772,
"longitude": -118.42122,
"apn": "4315002022"
}
],
"result_count": 1,
"pagination": { "limit": 10, "next_cursor": null, "has_more": false },
"query": { "zip": "90064", "value_min": 5000 },
"partial": false,
"warnings": []
}
permit_type is null on some rows — the source portal's own permit-type vocabulary doesn't always map to SignedOff's trade categories. Don't assume it's always populated when filtering or displaying results client-side.
Pagination
pagination.next_cursor is an opaque, base64 token — pass it back as ?cursor= to get the next page with the same filters. It encodes each backend's own offset plus a fingerprint of the filter set, so a cursor minted for one query can't be replayed against a different one. When jurisdiction is omitted, LADBS and EPIC-LA are paginated independently and merged newest-first on every page.
Partial results
If one backend fails while the other succeeds, the response still returns 200 with whatever results the healthy backend found, partial: true, and a human-readable entry per failed backend in warnings. If every targeted backend fails, the endpoint returns 503 jurisdiction_unavailable instead of an empty 200.
Error responses
| Status | Error key | Description |
|---|---|---|
| 400 | filter_required | No supported active filter was supplied. |
| 400 | unsupported_filter | contractor was supplied. Current source feeds do not expose contractor identity, so the filter is rejected rather than silently ignored. |
| 400 | unsupported_city / unsupported_county / conflicting_geography / conflicting_jurisdiction / invalid_parcel | The requested geography is outside current discovery coverage, conflicts with another scope selector, or the parcel portfolio is malformed or exceeds 25 unique inputs. |
| 400 | invalid_jurisdiction / invalid_radius / invalid_value_range / invalid_date_range / invalid_cursor | A parameter combination failed validation — see the parameter table above for the specific rule. |
| 402 | discovery_addon_required | Your plan needs the Permit Discovery add-on enabled. Response includes an upgrade_url. |
| 403 | sandbox_not_supported | Discovery requires a live sk_live_ key. Sandbox keys cannot search. |
| 429 | discovery_burst_exceeded | Too many searches per minute. Response includes retry_after_seconds. |
| 429 | discovery_quota_exceeded | Monthly discovery allotment reached and overage isn't enabled for your key. |
| 503 | jurisdiction_unavailable | Every targeted backend failed. Response includes per-backend warnings. |
/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.
- 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_matchcanonical event. Itsdataincludessaved_search_id, name, criteria, a thin match snapshot, andmatched_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=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: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.
- Deterministic demo permits — the same response every call
- No quota accounting and no live scrapes
- Responses carry
X-Sandbox-Mode: true
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))
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'sretry_aftervalue, 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.