Base https://permity.valyaai.us/v1 Auth X-API-Key: pk_live_…
POST /v1/permits/lookup
| Field | ||
|---|---|---|
address | required | Free-form. Normalised server-side. |
property_type | optional | A filter, not a declaration — the detected type is always returned. |
from / to | optional | Date window. Defaults to 1970-01-01 through today. |
geo_id | optional | Resolve an ambiguous lookup by passing back a candidate’s id. |
on_repeat | optional | "run" (the default) or "prompt". 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. |
confirm | optional | The 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. |
curl
curl -X POST https://permity.valyaai.us/v1/permits/lookup \
-H "X-API-Key: $PERMITY_KEY" \
-H "Content-Type: application/json" \
-d '{"address":"123 Main St, Denver, CO 80202"}'JavaScript
const res = await fetch("https://permity.valyaai.us/v1/permits/lookup", {
method: "POST",
headers: {
"X-API-Key": process.env.PERMITY_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ address: "123 Main St, Denver, CO 80202" }),
});
if (res.status === 402) throw new Error("Subscription lapsed");
if (res.status === 429) throw new Error("Monthly quota exhausted");
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,
// because "no permit" and "no coverage here" mean opposite things.
console.log(roof.status, roof.year, roof.year_source);
}The four trade statuses
This is the part worth reading twice. Collapsing these to a null year loses the distinction that matters most.
found | A permit and a year. |
none_found | No permit, and this jurisdiction reports well. Real signal: probably no permitted work. |
found_undated | A permit exists, no usable date. Work happened; when is unknown. |
no_coverage | The jurisdiction barely reports. Absence here is not evidence. |
Status codes
200 | A successful lookup — including none_found. A property with no roofing permit is a real answer, not an error. |
400 | Malformed request. |
401 | Bad or revoked key. |
402 | Subscription lapsed. Distinct from 401 on purpose — billing, not credentials. |
422 | Valid JSON, unusable values. |
429 | Quota exhausted. Retry-After set. |
502 | Permit data unavailable. Retry. (The error code is still upstream_unavailable.) |
Zapier
There is deliberately no Permity Zapier app — a published integration means partner review and breakage whenever the response shape moves. Webhooks by Zapier → Custom Request reaches the same API with no dependency on anyone else’s release cycle.
Method POST
URL https://permity.valyaai.us/v1/permits/lookup
Headers X-API-Key | pk_live_…
Content-Type | application/json
Data {"address": "{{address_from_previous_step}}"}Zapier flattens nested JSON, so trades.roofing.year appears as Trades Roofing Year. Add a Filter on Trades Roofing 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.