Authentication
Every request must include a Bearer token in theAuthorizationheader. Tokens always start withpp_live_followed by 32 lowercase characters.
Generate a key on /account/api-keys. The full key shows once on creation; only the SHA-256 hash is stored server-side. There is no way to recover a lost key — generate a new one.
Sandbox keys and paid keys are functionally identical. They differ only in monthly quota and per-minute rate limit. Code that works against a sandbox key works unchanged against a Developer or Business key.
Endpoints
POST/v1/researchRun permit research for one address + job. One call = one search.GET/v1/usageCurrent quota and rate limit state.Planned
Request shape
POST a JSON body to /v1/research. All fields are top-level — no nested envelope.
Example
curl https://api.permitpull.ai/v1/research \-H "Authorization: Bearer <YOUR_API_KEY>" \-H "Content-Type: application/json" \-d '{"address": "123 Main St, Springfield, IL 62701","job_type": "roof replacement","occupancy_type": "residential","user_type": "contractor"}'
Fields
| Field | Type | Description |
|---|---|---|
addressRequired | string | Full street address. Google Places-style format works best — e.g. "123 Main St, Springfield, IL 62701" (placeholder shown; supply a real US address). |
job_typeRequired | string | Free-text description of the job — "roof replacement", "kitchen remodel", "ADU construction". Validated server-side; ambiguous values may return a 400. |
occupancy_typeRequired | string | One of: "residential", "commercial", "mixed_use", "industrial". |
user_type | string | One of: "contractor", "homeowner". Drives report tone and section emphasis. Defaults to "contractor". |
Response shape
Successful responses return HTTP 200 with the full report body. The metadata block at the end of every response carries the caller’s current quota and rate limit state — track these to avoid 429s.
Full response
{"id": "00000000-0000-0000-0000-000000000000","jurisdiction": {"name": "Sangamon County, IL","code_edition": "2021 IRC","phone": "+1-555-867-5309","website": "https://www.example-county.gov/codes/"},"permit_required": true,"permits": [{"type": "Building Permit","issuer": "Sangamon County Building Safety","fee": "$108.00","fee_source": "ResidentialPermitFees.pdf"}],"fees": {"total_estimated": "$108.00","breakdown": [{ "label": "Plan review", "amount": "$33.00" },{ "label": "Permit", "amount": "$75.00" }]},"forms": [{"name": "Residential Building Permit Application","url": "https://www.example-county.gov/forms/RBPA.pdf","format": "PDF"}],"timeline": "3-5 business days for issuance","inspections": [{ "phase": "Pre-construction", "trigger": "Materials on site" },{ "phase": "Final", "trigger": "Project complete" }],"code_flags": [{"label": "Wind zone","note": "Wind zone II — fastener schedule may differ"}],"metadata": {"tier": "developer","searches_remaining": 287,"rate_limit_remaining": 9,"rate_limit_reset_seconds": 42}}
Top-level fields
| Field | Type | Description |
|---|---|---|
idRequired | uuid | Unique identifier for this research result. Persistent — use it to reference the report later. |
jurisdictionRequired | object | Confirmed jurisdiction for the address. Includes name, building code edition, phone, website. |
permit_requiredRequired | boolean | Whether the job requires a permit at all. False is a valid result for some minor work. |
permitsRequired | array<object> | List of required permits with fees and issuing authority. |
feesRequired | object | Total estimated fees plus a per-line breakdown. |
formsRequired | array<object> | Required application forms with direct URLs to government PDFs. |
timelineRequired | string | Published or estimated processing time. |
inspectionsRequired | array<object> | Required inspection phases and their triggers. |
code_flagsRequired | array<object> | Job-specific or jurisdiction-specific code considerations (wind zone, hazard zone, hardscape limits, etc.). |
metadataRequired | object | Quota and rate limit state for this caller. See "Rate limits" + "Quotas". |
Errors
All errors return a JSON body with an error object containing a machine-readable code, a human-readable message, and (where applicable) hint fields like retry_after_seconds.
Error response
{"error": {"code": "rate_limit_exceeded","message": "Rate limit reached. Retry in 42 seconds.","retry_after_seconds": 42}}
Status codes
| Status | Code | Description |
|---|---|---|
| 400 | invalid_request | Missing or malformed required field. Response body specifies which. |
| 401 | invalid_key | Missing, malformed, or non-existent API key. Check the Authorization header is `Bearer pp_live_…`. |
| 402 | payment_required | Subscription lapsed or trial exhausted. Response body suggests an upgrade path. |
| 403 | key_revoked | API key has been revoked. Generate a new key. |
| 422 | unprocessable_address | Address could not be resolved to a U.S. jurisdiction. Confirm street + city + state + ZIP. |
| 429 | rate_limit_exceeded | Too many requests. Honor the `Retry-After` header and `retry_after_seconds` field. |
| 429 | quota_exceeded | Monthly cap reached. Upgrade tier or buy a credit pack. |
| 500 | internal_error | Unexpected server-side failure. Safe to retry once after a short backoff. |
| 503 | upstream_unavailable | Government source temporarily unreachable. Retry with exponential backoff. |
Rate limits
Per-minute limits scale with tier:
- Sandbox: 10 req/min (shared with Developer).
- Developer: 10 req/min.
- Business: 60 req/min.
Exceeding the limit returns HTTP 429 with a Retry-After response header (seconds until the next allowed request) and a retry_after_seconds field in the error body. Honor either signal — they’re identical.
Quotas
Monthly limits scale with tier:
- Sandbox: 10 lifetime searches (no monthly reset — once you’ve used 10, the key returns 402 until you upgrade).
- Developer: 300 searches per calendar month.
- Business: 1,000 searches per calendar month.
Hard stop at the cap. There is no overage. Reaching the limit returns HTTP 429 with code quota_exceeded. Upgrade tier, buy a credit pack, or wait for the next month.
Track remaining quota via the metadata.searches_remaining field on every successful response. The GET /v1/usage endpoint will offer the same signal without consuming a search quota when shipped Planned.