Permity

API

Base    https://permity.valyaai.us/v1
Auth    X-API-Key: pk_live_…

POST /v1/permits/lookup

Field
addressrequiredFree-form. Normalised server-side.
property_typeoptionalA filter, not a declaration — the detected type is always returned.
from / tooptionalDate window. Defaults to 1970-01-01 through today.
geo_idoptionalResolve an ambiguous lookup by passing back a candidate’s id.
on_repeatoptional"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.
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.

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.

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.

Status codes

200A successful lookup — including none_found. A property with no roofing permit is a real answer, not an error.
400Malformed request.
401Bad or revoked key.
402Subscription lapsed. Distinct from 401 on purpose — billing, not credentials.
422Valid JSON, unusable values.
429Quota exhausted. Retry-After set.
502Permit 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.