Permity

Getting started

The Permity API answers one question well: what building permits are on record for an address, by trade, with the year of the most recent one. The concepts, authentication, response codes and a sample call are below, then the full reference.

API reference

The Permity API is REST over HTTPS. Requests and responses are JSON unless you ask for a PDF or a CSV, and every response carries a request_id (and an X-Request-Id header) to quote if you need help with a call.

Every request starts with this base URL:

https://permityusa.com/v1

Authenticating your request

Send your API key in a header named X-API-Key:

"X-API-Key": "pk_live_your_key_here"
The key above is a placeholder and will not work. Your own keys are on your API keys page, where you can create one. A key is shown once, when it is created, so keep it somewhere safe: it cannot be shown again, only replaced.

Tools that only know how to send Authorization: Bearer … can send the key that way instead. A key in a query string is never accepted, because URLs end up in logs. Every key on an account draws on the same monthly allowance, so a second key is a convenience, not more lookups.

Testing a key

To check a key without spending anything, call GET /v1/me. It answers 200 with the account the key belongs to and what it has left, or 401 when the key is wrong. It is free, never runs a lookup, and is the call to make from a settings screen’s “Test” button.

curl https://permityusa.com/v1/me -H "X-API-Key: pk_live_your_key_here"

{
  "account": { "name": "Example Roofing", "plan": "solo" },
  "key": { "name": "Settings" },
  "monthly_allowance": 50,
  "allowance_remaining": 42,
  "credits_remaining": 5,
  "lookups_remaining": 47,
  "terms": { "accepted": true, … },
  "meta": { "request_id": "req_…", "quota_remaining": 47, "charged": 0 }
}

lookups_remaining is the monthly allowance left plus credits, the same number as meta.quota_remaining on a lookup. A value that is not shaped like a key is answered 401 with “Malformed API key. Keys start with pk_live_.” A lookup checks the key before it reads the body, so a 401 is always about the key; and a 400 or 422 is refused before anything runs, so it never charges.

Zapier

Zapier

Permity has a private Zapier app, shared on request: ask for an invitation and we email you a link that adds it to your Zapier account. It signs in with an API key, returns the flat answer, and works in any CRM that connects to Zapier.

Triggers and actions

A lookup comes out of your monthly allowance first, and out of purchased credits only once the allowance is used. Opening a report you already have is free. Running it again is charged.

Examples

Each recipe is a Zap: the trigger, the Permity step, then what happens next. Only a Look Up step uses a lookup.

A new contact in your CRM: permit years on the contact, report attached

  1. Your CRM New Contact (or New Lead) is the trigger.
  2. Permity: Look Up Permit History with the contact’s address. Leave “If this address was looked up in the last 30 days” on Reuse that report.
  3. Permity: Get Report PDF with the Report link from step 2.
  4. Your CRM: Update Contact write Roof Year, HVAC Year, Electrical Year and Plumbing Year into fields such as “Roof permit year”, and attach the PDF file from step 3. A trade with no permit on record has no year: leave its field empty.

Cost: Step 2 uses 1 lookup from your monthly allowance (purchased credits only once the allowance is used), or nothing when it reuses a report from the last 30 days. Step 3 is free, because opening a report you already have is free.

A list of addresses finishes: the results in Slack

  1. Permity: Look Up a List of Addresses in any Zap, with the addresses one per line (from a spreadsheet, a form or a CRM search).
  2. Permity: Address List Finished is the trigger of a second Zap, on the same connection.
  3. Slack: Upload File post the Results spreadsheet file to a channel, with the list name and how many addresses were processed.

Address List Finished sees lists started with the same Permity connection.

Cost: Step 1 uses up to 1 lookup per address, from your monthly allowance first; addresses beyond the lookups left are skipped, not charged. The trigger and the Slack post cost nothing, and opening any report in the list afterwards is free.

A quote form is submitted: the report emailed to the agent

  1. Jotform, Typeform or Google Forms a new submission is the trigger.
  2. Permity: Look Up Permit History with the address from the form.
  3. Permity: Get Report PDF with the Report link from step 2.
  4. Gmail or Email by Zapier: Send Email to the agent, with the PDF attached and the four permit years in the body.

Cost: Step 2 uses 1 lookup from your monthly allowance (purchased credits only once the allowance is used), or nothing when it reuses a report from the last 30 days, so the same address sent twice in a month is looked up once. Step 3 is free, because opening a report you already have is free.

A Google Sheet of addresses: the years written back to each row

  1. Google Sheets: New Spreadsheet Row is the trigger.
  2. Permity: Look Up Permit History with the address column.
  3. Google Sheets: Update Spreadsheet Row the same row: Roof Year, HVAC Year, Electrical Year and Plumbing Year, each trade’s status beside its year, and the Report link.

Cost: Step 2 uses 1 lookup from your monthly allowance (purchased credits only once the allowance is used), or nothing when it reuses a report from the last 30 days. Opening a report from its link in the sheet is free.

Momentum AMP (NowCerts): the roof permit year written back to Momentum

  1. Webhooks by Zapier: Catch Hook is the trigger, with its URL set as a webhook in Momentum for the event you want. Or use Schedule by Zapier and a Webhooks step that asks Momentum’s API for prospects or policies added since the last run.
  2. Permity: Look Up Permit History with the property address from step 1.
  3. Filter by Zapier continue only if Roof Status is found.
  4. Webhooks by Zapier: Custom Request to Momentum’s API with your Momentum token, writing Roof Year into a custom field such as “Roof permit year” on that prospect or insured.

Momentum has no Zapier app yet, so both Momentum steps call its own API, which needs API access on your Momentum account. Ask and we help set it up.

Cost: Step 2 uses 1 lookup from your monthly allowance (purchased credits only once the allowance is used), or nothing when it reuses a report from the last 30 days. The Webhooks steps cost nothing on Permity, and opening the report later is free.

Without the app: Webhooks by Zapier

The app is not required. Webhooks by Zapier, Custom Request reaches the same API directly, for anyone with a key:

Method   POST
URL      https://permityusa.com/v1/permits/lookup
Headers  X-API-Key      | pk_live_…
         Content-Type   | application/json
Data     {"address": "{{address_from_previous_step}}"}

For a Zap, add ?format=flat to the URL and "on_repeat": "reuse" to the data. The flat answer maps field by field (Roof Status, Roof Year), and a property your account already has comes back for nothing instead of being charged again. Add a Filter on Roof Status = found before whatever comes next, so a property with no roofing permit does not fall through as if it had a roof from year zero.

Without format=flat, Zapier flattens the nested JSON itself, so trades.roofing.year appears as Trades Roofing Year and the filter goes on Trades Roofing Status.

Response codes

200 means the request worked, and that includes a property with no permits on record: that is a real answer, not an error. Anything else carries a JSON body with an error code you can branch on and a message a person can read.

200A successful lookup, including none_found.
202Accepted for async work (you sent a callback_url). The result is POSTed to you when ready.
400malformed_request: the body is not valid JSON.
401unauthorized: missing, wrong or revoked key. Checked before anything else, so it is never about the request body. See Testing a key.
402payment_required: the subscription is not active. Distinct from 401 on purpose: billing, not credentials.
403terms_not_accepted: nobody on the account has accepted the current terms yet, so lookups are paused and nothing is charged. accept_url is where a person on the account accepts them; retrying will not help until they do. Reading reports keeps working.
403names_not_enabled_for_key: the key asked for owner names on an export (names=1) but was not created with the owner-names option. No file was made and nothing was charged. keys_url is where a person on the account creates a key with the option, or turns it on for this one. Not yet answered: owner names on exports are not available yet.
422invalid_request: valid JSON, unusable values. issues names each field. Refused before the lookup runs, so nothing is charged. An address with no house number in its street part ("Arvada, CO") is one, unless a geo_id is sent.
429quota_exceeded: the monthly allowance is used up. Retry-After says when it resets. rate_limited on the free area search means too many outlines in a minute; nothing was used up, and Retry-After says when to draw again.
409request_in_progress: a request with the same Idempotency-Key is still running. Nothing was charged for this one. Retry with the same key in a few seconds to receive its answer.
502upstream_unavailable: permit data is unavailable right now. Retry.
503lookup_timeout: the lookup took too long and was stopped before any charge. Nothing was charged. Retry in a minute; the retry is faster.

When the terms change, responses to your key carry a Permity-Terms-Update header until someone on the account accepts the new version: the current version, where to accept it, the date after which lookups pause (once one is set) and the version the account last accepted, for example Permity-Terms-Update: 2026-10-02; accept=https://permityusa.com/terms/accept; by=2026-11-01; accepted=2026-09-28.

{
  "error": "invalid_request",
  "message": "Invalid request body.",
  "issues": [{ "field": "from", "message": "from must be YYYY-MM-DD" }],
  "meta": { "request_id": "req_7c1e0a9b2f4d4e3a8b6c5d21" }
}

Sample code

Here is a complete call that looks up one property. Paste your key into the box and the sample below is written with it, ready to copy.

Stays in this browser tab. Nothing on this page sends it anywhere, stores it or runs a lookup. Create or find a key on your API keys page.

// Node 18+ or any server. A key in a web page is a key anyone can read.
const res = await fetch("https://permityusa.com/v1/permits/lookup", {
  method: "POST",
  headers: {
    "X-API-Key": "pk_live_your_key_here",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ address: "123 Main St, Denver, CO 80202" }),
});

if (res.status === 402) throw new Error("Subscription is not active");
if (res.status === 429) throw new Error("Monthly allowance used up");

const data = await res.json();

if (data.status === "ambiguous") {
  // More than one property matched. Ask, or re-send with a candidate's geo_id.
  console.log(data.candidates);
} else if (data.status === "ok") {
  const roof = data.trades.roofing;
  // `year` is null when nothing was found. Check `status`, not truthiness:
  // "no permit" and "no coverage here" mean opposite things.
  console.log(roof.status, roof.year, roof.year_source);
}

POST /v1/permits/lookup

The trade summary and full permit history for one address.

Field
addressrequiredFree-form. Normalised server-side.
property_typeoptionalresidential, commercial, industrial or agricultural. A filter, not a declaration: the detected type is always returned.
from / tooptionalDate window, YYYY-MM-DD. Defaults to 1970-01-01 through today.
geo_idoptionalResolve an ambiguous lookup by passing back a candidate’s id.
callback_urloptionalRun asynchronously: answer 202 now and POST the result here when it is ready. See Async and retries.
on_repeatoptional"run" (the default), "prompt" or "reuse". With "prompt", a property your account has run in the last 30 days comes back as status: "already_run" instead of being run again: the prior answer, a link to the report you already have (free to open), whether anything has been published since, and what a re-run would cost. A prompt charges nothing; running it again always charges, however recently you ran it. No other value of this field can produce that status. With "reuse", for a caller that cannot answer a question (a Zap, a script): a property your account has run in the last 30 days comes back as the report you already have, charged nothing, with reused: true and previous.ran_at saying when it was run; anything else runs and is charged as usual. Not with callback_url.
confirmoptionalThe rerun.confirm token from an already_run response. Runs the lookup and charges for it. It is bound to your account and that property, and expires in 30 minutes; a stale one is answered with a fresh prompt rather than an error.

Send Accept: application/pdf to get the report document instead of the JSON, in the same call and for the same price. An address that does not resolve to one property has no document, so it answers with the JSON either way.

To keep both, the data and the document, from one lookup: take the JSON and then download report.url, the stored report of that same call. Opening it costs nothing more. If the download fails, run only that step again: the lookup is already paid, and running the whole example again is a second lookup.

curl -s -X POST https://permityusa.com/v1/permits/lookup \
  -H "X-API-Key: pk_live_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{"address":"123 Main St, Denver, CO 80202"}' -o lookup.json
URL=$(grep -o '"url": *"[^"]*/v1/reports/[^"]*"' lookup.json | cut -d'"' -f4)
if [ -n "$URL" ]; then curl -s "$URL" -o report.pdf; else cat lookup.json; fi

The flat answer

Add ?format=flat to the lookup URL, or to a report’s json_url, and the answer comes back as one level of named fields, every one present every time and null when unknown. It is built for tools that map fields, such as Zapier. It cannot be combined with on_repeat: "prompt" or callback_url, and it answers in JSON whatever the Accept header says.

statusok, ambiguous or address_not_found. Only ok is an answer; the others carry nulls, never zeros, and candidates for ambiguous.
roof_status, hvac_status, electrical_status, plumbing_statusfound, found no date, none found or not covered. Read the status before the year: a blank year means three different things, and only the status says which.
roof_year, roof_year_confidence, roof_permit_count (and the same for each trade)The newest year on record for the trade, how sure that year is (high, medium, low), and how many permits.
has_building_record, building_record_matchWhether the county assessor’s record of the building was found, and how: exact parcel or address.
year_built, square_feet, lot_square_feet, bedrooms, bathrooms, stories, construction_type, basement_square_feet, basement_finished_square_feet, roof_type, garage, heating, cooling, fireplaces, property_useThe building, as the record states it. Areas are square feet. property_use is the parcel's recorded use, in a national land-use classification ("Mobile/manufactured home", "Duplex", "Condominium unit").
roofing_material, roofing_material_label, roofing_material_impact_resistant, roofing_material_permit_number, roofing_material_permit_dateThe roof material named on the newest roofing permit that names one, and that permit’s number and date, the same as material_on_record in the full answer. What the permit says, not the roof today. All null when no roofing permit names a material. roof_type above is the building record’s own field, and the two are never mixed.
owner_nameThe owner of record, where the public record names one, as recorded and not verified. Null otherwise.
valuation_estimate, valuation_low, valuation_high, comparable_sales_value, comparable_sales_per_sqft, comparable_sales_count, assessor_market_value, assessor_market_year, assessed_value, assessed_yearWhat the property is worth, in whole dollars, null where not given: the estimate and its range, what comparable sales imply, the assessor’s market value and the assessed value tax is figured from, each with its year. An estimate of market value, not an appraisal. Market value is not the cost to rebuild, so it is not a guide to dwelling coverage.
permit_count, latest_permit_date, latest_permit_work, permitsEvery permit on record, newest first, each with date, work, description, status, permit_number, issued_by and value.
coverageOne sentence: whose records these are and how far they reach.
point_parcel_address, point_parcel_useOnly on address_not_found: the recorded parcel the address’s map point falls inside, and its recorded use. Not matched to the address asked about; nothing was looked up for it. Null otherwise.
address, geo_id, report_id, ran_at, reusedThe property, the report, when the answer was produced, and whether it is a report you already had.
report_url, pdf_url, report_link_expires_atThe signed report link (opens in a browser), the same report as a file download, and when both links stop working.
chargedLookups this call spent: 1, 0.5 for an address that is not one property, 0 for a report you already had.
request_id, quota_remaining, charged_toThe same as meta in the full answer: the id to quote if you need help with this call, the lookups your account has left after it (monthly allowance plus credits, as GET /v1/me counts them), and what paid for it (allowance, credit, or null when nothing was charged). Opened through a report’s json_url, quota_remaining is null: the link works without a key, so it does not carry the account’s balance.

Which roof field is which. Three families of field start with roof, from two different records. roof_year, roof_status, roof_year_confidence and roof_permit_count are the roofing trade, read from roofing permits, like hvac_year and the rest. roofing_material and the fields that start with it are the material a roofing permit names. roof_type is the building record’s own description of the roof, from the county assessor, and is never filled from a permit. The two records can differ and both be accurate.

POST /v1/properties/lookup

What the building is, and no permits: year built, floor area, lot, stories, bedrooms, bathrooms, exterior, roof, units, class, structure and basement, from the county assessor’s record of the parcel. It costs half a lookup when facts come back, from your monthly allowance first and from credits only once the allowance is used. An address we cannot place, one that matches more than one parcel, or one in a county we do not hold costs nothing.

Field
addressrequiredHouse number and street, then city and state; a ZIP helps. Read as typed: it is not corrected, so spell the town as the post office does.
apnoptionalThe parcel number, to pick one candidate of an ambiguous answer.
unitoptionalA suite or unit, when it is not in the address line.
on_repeatoptionalAs on /v1/permits/lookup: "run" (the default), "prompt" or "reuse", over the same 30 days. A prompt or a reuse charges nothing; running it again is charged.
confirmoptionalThe rerun.confirm token from an already_run answer.

status is ok, ambiguous (with candidates: an address, county and apn each; we never pick one), address_not_found or not_covered. matched_by says how the parcel was found (apn, unit, address or street), and as_of is when the county last edited its record, or null. A field the county does not publish is null. No owner or other name, no sale price, no tax and no mortgage or lien is ever in this answer.

An ok answer can also carry valuation when figures are available, in the same shape as on a permits lookup (see The response), at no extra cost. An estimate of market value, not an appraisal. Market value is not the cost to rebuild, so it is not a guide to dwelling coverage.

curl -s -X POST https://permityusa.com/v1/properties/lookup \
  -H "X-API-Key: pk_live_your_key_here" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: facts-8-honey-locust" \
  -d '{"address":"8 Honey Locust, Littleton, CO 80127"}'

{
  "status": "ok",
  "property": { "address": "8 HONEY LOCUST", "city": "Littleton", "state": "CO",
                "zip": "80127", "county": "Jefferson", "apn": "5931310004" },
  "facts": { "year_built": 1986, "living_area": 2111, "lot_area": null, "stories": null,
             "bedrooms": null, "bathrooms": null, "exterior": null, "roof_material": null,
             "units": null, "property_class": null, "structure_type": "2 Story",
             "property_use": "Single family residential",
             "basement_area": 587, "basement_finished": null },
  "matched_by": "address",
  "as_of": null,
  "meta": { "quota_remaining": 41, "charged": 0.5, "charged_to": "allowance" }
}

Your reports and your key

GET /v1/reports lists the reports your account already has, newest first, for nothing. Add ?address= to keep the ones for one address, or ?geo_id= for one property. The address is matched to the reports on file by house number, street, unit and ZIP (or town); it is not looked up, so it costs nothing, and a spelling a lookup would have corrected finds nothing. Each row’s json_url takes &format=flat.

The list pages: send a response’s next back as ?before= for the page after, until next is null. ?ids= takes up to 50 report ids and hands back fresh links for the ones still on file; the rest are listed in missing.

/v1/areas keeps the areas you draw around a block, with each house and the report it produced, so every device on the account and the web show the same list. GET /v1/areas/{id}?include=facts adds each house’s roof, HVAC, electrical and plumbing years, worked out from its report when you ask. Saving, listing and opening areas is free, and no names are stored in an area.

GET /v1/areas/{id}/export?format=<csv|kml|summary-pdf|doorknock-pdf|json>&houses=<id>,<id> is the area as a file, the same bytes the web and the app download. Free.

Owner names on an export are not available yet. When they are, add names=1 to the spreadsheet, map file or either PDF to include the owner of record for each house, as the county wrote it, where the house’s state allows it; the json file never carries names. The key must have been created with "This key may receive owner names" ticked on your API keys page, or had it turned on there: that tick is a person on your account accepting the names notice in our terms (section 5). A key without it is answered 403 names_not_enabled_for_key and no file is made. Each names export is recorded against the account and the key. Names are copied from county records as written and can be out of date; using a name to decide whether an individual is eligible for credit, insurance, employment or housing is not permitted, and contacting owners is yours to do within the law.

GET /v1/me says which account a key belongs to and what it has left: allowance_remaining, then credits_remaining, and lookups_remaining, their sum. Free, and the call to make to test a key.

The response

status is the first thing to read. It is ok (one property, answered), ambiguous (more than one property matched; candidates lists them, and you re-send with one’s geo_id) or address_not_found. Two more appear only when you ask for them: accepted for an async call and already_run for a repeat prompt.

An address_not_found may carry point_parcel: the recorded parcel the address’s map point falls inside, with its address, city, zip, apn and recorded property_use. It is information for you to weigh: it was not matched to the address you asked about, nothing was looked up for it, and the miss is charged as a miss.

The shape of an ok answer, trimmed. Values are illustrative, not a real property; hvac, electrical and plumbing have the same shape as roofing.

{
  "status": "ok",
  "query": { "address": "123 Main St, Denver, CO 80202" },
  "resolved": {
    "geo_id": "…",
    "address": "123 MAIN ST",
    "city": "DENVER", "state": "CO", "zip": "80202",
    "county": "Denver",
    "property_type": "single_family",
    "property_class": "residential",
    "year_built": 1962,
    "year_built_source": "property_record"
  },
  "coverage": {
    "jurisdiction": "Denver",
    "status": "full",
    "permits_through": "2026-09-01"
  },
  "trades": {
    "roofing": {
      "status": "found",
      "year": 2019,
      "year_source": "file_date",
      "year_confidence": "high",
      "permit_count": 2,
      "permits": [ … ],
      "material_on_record": {
        "material": "asphalt_shingle",
        "label": "Asphalt shingle",
        "impact_resistant": true,
        "permit_number": "…",
        "permit_date": "2019-06-11",
        "status": "…",
        "text": "Class 4 composition shingle"
      }
    },
    "hvac":       { "status": "none_found", "year": null, … },
    "electrical": { … },
    "plumbing":   { … }
  },
  "permits": [
    {
      "permit_id": "…",
      "date": "2019-06-11",
      "date_source": "file_date",
      "year": 2019,
      "tags": ["roofing"],
      "description": "Re-roof, tear off, Class 4 composition shingle",
      "type": "…", "subtype": null,
      "contractor": null,
      "status": "…",
      "jurisdiction": "Denver",
      "fees": null
    }
  ],
  "untagged_permit_count": 0,
  "report": {
    "report_id": "…",
    "url": "https://permityusa.com/v1/reports/…",
    "json_url": "https://permityusa.com/v1/reports/….json?…",
    "expires_at": "…"
  },
  "meta": {
    "request_id": "req_…",
    "quota_remaining": 412,
    "charged": 1,
    "charged_to": "allowance"
  }
}
year_sourceWhich date the year came from, in order of preference: file_date, issue_date, final_date, start_date. The application is when the work happened; a final can land years later.
year_confidencehigh, medium or low. Absent when year is null.
inferredPresent only when there is a real basis for a guess (a new-construction permit, or the year built), with its strength. Never copied into year.
before_rebuildPresent only when the address was rebuilt (a demolition permit paired with new-construction permits) and this trade has permits dated before the demolition. Those permits belong to the building that was removed, so they are counted here (permit_count, newest_year, boundary_date) and set none of status, year or permit_count above. They stay in permits.
material_on_recordRoofing only. The roof material named on the newest roofing permit that names one: material (one of asphalt_shingle, wood_shake, metal, concrete_tile, clay_tile, tile, slate, synthetic, tpo, epdm, pvc, modified_bitumen, built_up, roll_roofing, coating), its label, the permit_number, permit_date and status of that permit so you can check it, and the text it was read from. It is what the permit says, not an inspection of the roof today. impact_resistant is true only when the permit says Class 4, impact resistant or IR, and null otherwise. When a permit names the roof coming off and the one going on, the one going on is reported. Expired, void and other lapsed permits never count. null when no roofing permit names a material, which is not evidence of anything. It is separate from the building record’s roof, and never filled from it.
untagged_permit_countPermits no trade could be read from. One of them could be the roof, so it is reported rather than hidden.
contractorThe contractor named on the permit, where the record names one, as recorded and not verified. Null otherwise.
reportA signed link to the report built from this answer, PDF or .json. It opens without a key, so it can be forwarded, and it expires.
coverage.permits_throughThe most recent date this jurisdiction’s records reach. A permit filed after it cannot be here yet.
valuationWhat the property is worth, by four measures, in whole dollars: estimate (value, low, high), comparable_sales (value, per_sqft, count), assessor_market (value, year), assessed (value, year; the figure tax is figured from, not a market value) and as_of, the date of the estimate. Present only when a figure is available, with null for any it does not give; kept with the report, so its link returns the figures it was filed with. An estimate of market value, not an appraisal. Market value is not the cost to rebuild, so it is not a guide to dwelling coverage.

Renamed and removed fields

On 2026-10-12 the fields that described how an answer was put together, rather than what is on record, stopped being sent. Read these instead: meta.authorities, coverage.authorities, trades.*.authorities, trades.*.records_read_in_part (true when the trade rests on an authority whose records we hold only in part), jurisdiction.records, jurisdiction.records_url, jurisdiction.records_access, and on each permit authority_id and record_url. The Deprecation, Sunset and Permity-Deprecated-Fields headers that announced them are no longer sent.

Ids. A geo_id starts with pg_ and a permit_id with pp_. Both are stable: the same property or permit has the same id on every call. Send a geo_id back exactly as you received it. An id in the earlier form is still accepted until 2027-01-10. permit_number is the number the issuing authority prints, or null when none is known; quote it to the city.

The four trade statuses

This is the part worth reading twice. Collapsing these to a null year loses the distinction that matters most.

foundA permit and a year.
none_foundNo permit, and this jurisdiction reports well. Real signal: probably no permitted work.
found_undatedA permit exists, no usable date. Work happened; when is unknown.
no_coverageThe jurisdiction barely reports. Absence here is not evidence.

A permit on record says work was permitted. No permit on record does not say no work was done, and no_coverage says nothing either way.

What a call costs

Each lookup is charged to your monthly allowance first, and to purchased credits only once the allowance is used. An address that is not found, or that matches more than one property, costs half a lookup. Building facts only costs half a lookup when facts come back and nothing otherwise. Running a property again is charged like the first time; opening a report you already have, through its report link or with on_repeat: "reuse", is free. Every call is checked against your balance before it runs, so with nothing left a reuse is refused too; finding the report through GET /v1/reports is not.

meta.charged says what the call cost (1, 0.5 or 0), meta.charged_to says what paid for it (allowance, credit or null), and meta.quota_remaining is the number that decides whether your next call runs. Address suggestions, the houses in an area, job status and reports cost nothing.

Async and retries

Add a callback_url and the lookup answers 202 at once with a job_id and a status_url. When the result is ready it is POSTed to your URL with an X-Permity-Signature: sha256=… header, an HMAC of the raw body under your webhook secret (on your API keys page). A caller that cannot receive a POST can poll GET /v1/jobs/{id} instead.

Send an Idempotency-Key header and a retry of the same request returns the original answer instead of running (and charging) again. A failure is never stored against the key, so retrying after a 502 or 429 runs for real.

All endpoints

POST/v1/permits/lookupLook up one property

The whole trade summary and permit history for an address. Add geo_id to resolve an ambiguous result, from/to to narrow the window.

curl -X POST https://permityusa.com/v1/permits/lookup \
  -H 'X-API-Key: pk_live_your_key_here' \
  -H "Content-Type: application/json" \
  -d '{"address":"123 Main St, Denver, CO 80202"}'
POST/v1/properties/lookupBuilding facts only

What the building is (year built, floor area, lot, stories, rooms, exterior, roof, basement) and no permits. Uses half a lookup when facts come back, from your monthly allowance first; nothing otherwise. Add apn to pick one parcel of an ambiguous result.

curl -X POST https://permityusa.com/v1/properties/lookup \
  -H 'X-API-Key: pk_live_your_key_here' \
  -H "Content-Type: application/json" \
  -d '{"address":"123 Main St, Denver, CO 80202"}'
POST/v1/permits/lookupGet the PDF directly

Same call, same quota, same stored report — Accept decides whether you get the data or the document. Saves a round trip when all you want is the file.

curl -X POST https://permityusa.com/v1/permits/lookup \
  -H 'X-API-Key: pk_live_your_key_here' \
  -H "Accept: application/pdf" \
  -H "Content-Type: application/json" \
  -d '{"address":"123 Main St, Denver, CO 80202"}' \
  -o report.pdf
POST/v1/permits/lookupLook up and save the PDF

One lookup gives you both: the data in lookup.json and the report saved as report.pdf. Opening the report link costs nothing more. If the second step fails, run only it again: the lookup is already paid.

curl -s -X POST https://permityusa.com/v1/permits/lookup \
  -H 'X-API-Key: pk_live_your_key_here' \
  -H "Content-Type: application/json" \
  -d '{"address":"123 Main St, Denver, CO 80202"}' -o lookup.json
URL=$(grep -o '"url": *"[^"]*/v1/reports/[^"]*"' lookup.json | cut -d'"' -f4)
if [ -n "$URL" ]; then curl -s "$URL" -o report.pdf; else cat lookup.json; fi
POST/v1/permits/lookupRun it asynchronously

Returns 202 with a job_id immediately and POSTs the finished result to your callback_url, signed. Use it when a caller cannot wait, or when the chain might be slow.

curl -X POST https://permityusa.com/v1/permits/lookup \
  -H 'X-API-Key: pk_live_your_key_here' \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: renewal-1001" \
  -d '{"address":"123 Main St, Denver, CO 80202","callback_url":"https://hooks.example.com/permity"}'
GET/v1/jobs/{id}Check an async job

Status, the result once it is ready, and every webhook delivery attempt with its response time. Reading a job costs no quota.

curl https://permityusa.com/v1/jobs/job_9f2c \
  -H 'X-API-Key: pk_live_your_key_here'
POST/v1/permits/streetCompare to the streetDeprecated

How many properties on the same block pulled a permit for each trade, and in which years. The answer to what a permit record cannot see.

Stops answering on 30 September 2027. Until then it works exactly as it does today, and every response carries Deprecation and Sunset headers saying so. Nothing on the API replaces it. If you depend on this, say so before the sunset date and it can be reconsidered.

curl -X POST https://permityusa.com/v1/permits/street \
  -H 'X-API-Key: pk_live_your_key_here' \
  -H "Content-Type: application/json" \
  -d '{"address":"123 Main St, Denver, CO 80202"}'
POST/v1/batchesRun a whole list

Post a CSV. Only the address column is required; every other column comes back untouched and in its original order.

curl -X POST 'https://permityusa.com/v1/batches?name=Q3%20renewals' \
  -H 'X-API-Key: pk_live_your_key_here' \
  -H "Content-Type: text/csv" \
  --data-binary @book.csv
GET/v1/batches/{id}.csvDownload batch results

Your columns, then four trade years each with its status beside it — because a blank year means two opposite things and the CSV has to say which.

curl 'https://permityusa.com/v1/batches/BATCH_ID.csv' \
  -H 'X-API-Key: pk_live_your_key_here' -o results.csv
GET/v1/reports/{id}Fetch a report again

The PDF, or add .json for the data it was rendered from. Signed and expiring, so it opens without a key — which is what makes it forwardable.

curl 'https://permityusa.com/v1/reports/REPORT_ID?expires=…&signature=…' \
  -o report.pdf
POST/v1/permits/lookupLook up for an automation

on_repeat "reuse" hands back the report your account already has for a property looked up in the last 30 days, for nothing, and runs (one lookup) otherwise. format=flat answers in one level of named fields, ready to map.

curl -X POST 'https://permityusa.com/v1/permits/lookup?format=flat' \
  -H 'X-API-Key: pk_live_your_key_here' \
  -H "Content-Type: application/json" \
  -d '{"address":"123 Main St, Denver, CO 80202","on_repeat":"reuse"}'
GET/v1/reportsFind reports for an address

The reports your account already has for an address (or a geo_id), newest first. Free: it matches the reports on file and never runs a lookup, so a spelling a lookup would have corrected finds nothing.

curl --get 'https://permityusa.com/v1/reports' \
  -H 'X-API-Key: pk_live_your_key_here' \
  --data-urlencode 'address=123 Main St, Denver, CO 80202'
GET/v1/meCheck your key

The account this key belongs to and the lookups it has left: monthly allowance first, then credits. Free, and the way to test a key: 200 means it works, 401 that it does not.

curl 'https://permityusa.com/v1/me' \
  -H 'X-API-Key: pk_live_your_key_here'
POST/v1/addresses/in-areaFind the houses in an area

The house addresses inside an outline you draw, one row per building, in walking order, each with this account's report when it already has one. Free: it never runs a lookup. An outline is 3 to 200 [longitude, latitude] points and at most 600 m across; coverage says whether addresses could be listed here ("live"), not here ("none"), or not right now ("unavailable").

curl -X POST https://permityusa.com/v1/addresses/in-area \
  -H 'X-API-Key: pk_live_your_key_here' \
  -H "Content-Type: application/json" \
  -d '{"polygon":[[-105.2755,40.0138],[-105.2715,40.0138],[-105.2715,40.0158],[-105.2755,40.0158]]}'
POST/v1/areasSave an area

Keeps an area on your account so every device and the web show it: the outline, and each house with its result and report id. Send your own client_id and sending it again returns the saved area rather than a second one. PATCH /v1/areas/{id} renames it or merges a later pass by house id; DELETE removes it and never a report. Free. No names are stored.

curl -X POST https://permityusa.com/v1/areas \
  -H 'X-API-Key: pk_live_your_key_here' \
  -H "Content-Type: application/json" \
  -d '{"client_id":"6f1c2e9a-area-0001","outline":[[-105.2755,40.0138],[-105.2715,40.0138],[-105.2715,40.0158],[-105.2755,40.0158]],"houses":[{"id":"1630 17th st boulder co 80302","address":"1630 17TH ST","city":"Boulder","state":"CO","zip":"80302","lat":40.0159,"lng":-105.2722,"result":"done","report_id":"REPORT_ID"}]}'
GET/v1/areas/{id}Open an area with its roof years

One saved area. With include=facts each house carries fresh report links and its roof, HVAC, electrical and plumbing years with what each year is based on, worked out from its report when you ask. Where no roof permit is on record the roof year is the year built, and says so. A report past its retention shows as expired. GET /v1/areas lists your areas, newest first. Free.

curl 'https://permityusa.com/v1/areas/AREA_ID?include=facts' \
  -H 'X-API-Key: pk_live_your_key_here'
GET/v1/reportsReopen reports by id

Up to 50 of your reports by id, with fresh links; ids no longer on file come back in missing. Every list of reports also pages: send its next value as before to get the page after. Free.

curl --get 'https://permityusa.com/v1/reports' \
  -H 'X-API-Key: pk_live_your_key_here' \
  --data-urlencode 'ids=REPORT_ID_1,REPORT_ID_2'
GET/v1/addresses/autocompleteSuggest addresses

Normalised suggestions as someone types. Costs no lookup quota — suggestions are keystrokes, not lookups.

curl 'https://permityusa.com/v1/addresses/autocomplete?q=2054+E+167th' \
  -H 'X-API-Key: pk_live_your_key_here'