API Reference

Build on the property graph.

Every endpoint is one authenticated HTTP call. Resolve your records to canonical entities; read the underwriting, the market signal over time, the deed comps, how a jurisdiction behaves, and a parcel's permit history; then put it on a map. Request lines are exact; response JSON is field-for-field what the API returns.

Quickstart

  1. 1. Get a key. Create one in the console (or ask us during early access). Dev keys look like pk_sandbox_…, production keys pk_live_….
  2. 2. Authenticate. Send the key as X-API-Key (or Authorization: Bearer — both work):
export PG_KEY="pk_sandbox_..."
curl "https://developer.permitgranted.ai/api/partner/v1/rankings?ahj=jersey-city-nj&limit=5" \
  -H "X-API-Key: $PG_KEY"

Base URL: https://developer.permitgranted.ai/api/partner/v1. Rate limits + monthly quota are per plan.

Authentication & errors

Missing/invalid keys return 401. A call outside your plan's endpoints or geographies returns 403 with the exact missing entitlement (e.g. {"dimension":"geography","requested":"us-nj","allowed":["us-nj-sample"]}) — so your code can react precisely. Exceeding quota returns 429 with Retry-After. Owner names and personal data resolve through per-jurisdiction privacy rules — an uncleared jurisdiction returns data with those fields withheld, never a violation.

Keys & environments

There is one real dataset — no synthetic sandbox. Every key returns real production data, scoped to your plan's geographies (Explore starts with a New Jersey sample; stepping past it returns a 403 naming the exact entitlement to add). The environment is about intent, not a different dataset:

  • pk_sandbox_… — development keys. For building and evaluating: real data, your sample scope, safe to experiment with.
  • pk_live_… — production keys. Your real traffic, full entitled scope.

Secrets are shown once, at creation — we store only a fingerprint, so copy or download the key then. Lost one? Rotate it in the console. Going to production is widening your entitlement (scope + quota), not switching datasets.

Endpoints — live

POST/resolve

Resolve your identifier — a parcel_id, block+lot+ahj, tract geoid, or exact situs address — to a canonical entity URI.

This is the front door: your systems know addresses and tax keys; the graph speaks canonical URIs (pg:parcel:{uuid}, pg:market:{geoid}). Resolve once, store the URI, and every other endpoint takes it. Exact keys match deterministically; an address that misses falls back to fuzzy matching (house-number-gated) within your granted cities — a typo returns decision:"fuzzy_match" with a score, an ambiguous address returns match:null + ranked candidates (it never guesses). Prefer block+lot+ahj or parcel_id for guaranteed hits; state-wide keys need an ahj for fuzzy.

POST /resolve
curl -X POST https://developer.permitgranted.ai/api/partner/v1/resolve \
  -H "X-API-Key: $PG_KEY" \
  -H "Content-Type: application/json" \
  -d '{"ahj":"jersey-city-nj","block":"7902","lot":"16"}'
response
{ "match": { "uri": "pg:parcel:03EDA726-317B-4695-BDF7-0B6E48ED5669",
              "score": 1, "decision": "match" },
  "candidates": [] }
GET/parcels/{id}

The parcel dossier: identity, geography (county/AHJ/census tract), a centroid, and the underwriting verdict with its confidence.

One call answers "should I look harder at this parcel?" — verdict.pencils is the engine's buy-right read (does a project pencil under current zoning + market inputs) and confidence tells you how much data stands behind it. Responses are JSON-LD: @id is the canonical URI you can store and re-fetch; external_ids carries the joins back to YOUR keys (tract geoid, normalized address, lat/lon) — the lat/lon is a map-ready centroid. Every parcel also carries an assessment block (MOD-IV property class, year built, land/improvement/total assessed) and an abatement flag — for a tax-abated new-build the assessed total is $0 by design, paired with the abatement label so $0 reads "abated", not "worthless".

GET /parcels/{id}
curl https://developer.permitgranted.ai/api/partner/v1/parcels/03EDA726-317B-4695-BDF7-0B6E48ED5669 \
  -H "X-API-Key: $PG_KEY"
response
{ "@context": ".../context/v1.jsonld",
  "@id": "pg:parcel:03EDA726-...", "@type": "Parcel",
  "external_ids": { "census_tract_geoid": "34017001900",
                    "address_normalized": "719 NEWARK AVE.",
                    "lat": 40.73494, "lon": -74.061621 },
  "geography": { "country": "US", "admin1": "NJ", "admin2": "Hudson",
                 "ahj": "jersey-city-nj", "tract": "34017001900" },
  "assessment": { "property_class": "...", "year_built": "...",
                  "land_value": "...", "improvement_value": "...", "total_value": "..." },
  "abatement": null,
  "verdict": { "pencils": false, "confidence": 0.82 } }

# the assessment block (MOD-IV tax roll) rides every parcel; abatement is null unless abated.
# a real abated new-build capture (44 GAUTIER AVE.):
#   "assessment": { "property_class": "15F", "year_built": 2024, "total_value": 0 },
#   "abatement":  { "abated_newbuild": true,
#                   "label": "Recently built · tax-abated (PILOT)" }
# $0 assessed + an abatement label reads "abated", never "worthless".
GET/markets/{id}

A census tract's market signal — the same "heating" read published in the NJ Wave Report, machine-readable.

signal.class (heating / warming / neutral / cooling) is price-excess vs the statewide tide over a trailing 3-year window — one canonical definition everywhere, thresholds pinned by tests. excess_pct is %/yr above the state; volume_led=true with price_moved=false is the EARLIEST read (activity before price); confidence reflects sale-count depth. The id after pg:market: IS the Census tract GEOID — join it to public tract geometry to map it.

GET /markets/{id}
curl https://developer.permitgranted.ai/api/partner/v1/markets/34017006500 \
  -H "X-API-Key: $PG_KEY"
response
{ "@id": "pg:market:34017006500", "@type": "Market",
  "geography": { "country": "US", "admin1": "NJ" },
  "signal": { "class": "heating", "excess_pct": 48.15,
              "volume_led": false, "price_moved": true,
              "confidence": "med", "as_of": 2026 },
  "forward": { "forward_score": 100, ... } }
GET/parcels/{id}/underwriting

The ADVANCED underwriting view: the full 5-strategy table with dollar metrics, plus the input assumptions. Build and Platform plans.

Where /parcels answers "does it pencil?", this answers "how, by which strategy, and on what assumptions" — per strategy (ground-up, value-add, buy-hold, flip, land-bank): pencils, developer spread, yield-on-cost, TDC, residual land value, the for-sale vs rental exit read, and a plain-English headline. assumptions carries the inputs (cost/SF, cap rate, rent, product type, approval odds) so you can trust-but-verify. The methodology itself — weights, thresholds, calibration tables — is never exposed.

GET /parcels/{id}/underwriting
curl https://developer.permitgranted.ai/api/partner/v1/parcels/E47CE4A5-.../underwriting \
  -H "X-API-Key: $PG_KEY"
response
{ "@type": "ParcelUnderwriting", "strategy_version": "strat@v2-...",
  "confidence": 1,
  "strategies": { "ground_up": { "pencils": true,
      "headline": "For-sale exit: 41.3% developer margin @ $863/SF ...",
      "metric": { "dev_spread_bps": 82, "yield_on_cost_pct": 6.6,
                  "tdc": 101027932, "residual_land_value": 25009187,
                  "for_sale_margin_pct": 41.3, "pencil_exit": "for_sale" } },
    "buy_hold": { "available": false, "note": "no existing building to rent" }, ... },
  "assumptions": { "cost_per_sf": 350, "cap_rate": 0.055,
                   "product_type": "podium_midrise", "approval_odds_pct": 78.6, ... } }
GET/parcels/{id}/assemblage

Assemblage-cluster membership: adjacent parcels under a common owner relation — the combined-lot origination signal. Platform plan.

Deal origination: parcels that could be bought TOGETHER. Each cluster carries the member parcel URIs, combined area, the owner-RELATION TYPE (e.g. shared_mailing), absentee + tenure signals, and a confidence. Owner identities are never in the payload — relation structure only, with identity resolution governed by per-jurisdiction privacy rules.

GET /parcels/{id}/assemblage
curl https://developer.permitgranted.ai/api/partner/v1/parcels/7A1B5C21-.../assemblage \
  -H "X-API-Key: $PG_KEY"
response
{ "@type": "ParcelAssemblage", "cluster_count": 1,
  "clusters": [ { "members": [ "pg:parcel:7A1B5C21-...", "pg:parcel:E401FF75-...",
                               "pg:parcel:F33834AA-..." ],
      "member_count": 3, "combined_area_m2": 5109,
      "owner_relation": "shared_mailing", "absentee": true,
      "min_tenure_years": 40, "confidence": 0.9 } ] }
GET/entities/{uri}

Generic fetch by canonical URI — dispatches to the right view (parcel or market) from the URI type.

When your pipeline stores heterogeneous URIs from /resolve, this saves you routing logic: pg:parcel:… returns the parcel dossier, pg:market:… the market view. Same payloads as the typed endpoints.

GET /entities/{uri}
curl https://developer.permitgranted.ai/api/partner/v1/entities/pg:market:34017006500 \
  -H "X-API-Key: $PG_KEY"
response
// identical to GET /markets/34017006500
GET/rankings

A jurisdiction's tracts ranked by market signal — the movers list.

The "where should I even look?" call. Pass ?ahj= (e.g. jersey-city-nj) and get its tracts ordered by signal strength, each in the same Market shape as /markets/{id}. Because each carries its tract GEOID and signal.class, this is the one call you need to render a heating choropleth for a whole jurisdiction. Statewide cross-AHJ rankings land with the baked ranking table (roadmap).

GET /rankings
curl "https://developer.permitgranted.ai/api/partner/v1/rankings?ahj=jersey-city-nj&limit=20" \
  -H "X-API-Key: $PG_KEY"
response
{ "markets": [ { "@id": "pg:market:34017006500", "@type": "Market",
      "signal": { "class": "heating", "excess_pct": 48.15, ... } },
    ... ],
  "count": 20, "next_cursor": null }
GET/markets/{id}/history

A tract's signal over time — per-year, computed no-hindsight (as-of each year). The be-early view.

The wave as a time series: for each year the signal_class + excess_pct + score are computed as-of that year only (no lookahead), so you can see WHEN a tract started heating — the whole thesis behind the Wave Report, as data. This is what a replay / time-scrubber runs on.

GET /markets/{id}/history
curl https://developer.permitgranted.ai/api/partner/v1/markets/34017006500/history \
  -H "X-API-Key: $PG_KEY"
response
{ "subject": "pg:market:34017006500", "ahj": "jersey-city-nj", "count": 12,
  "series": [
    { "year": 2015, "signal_class": "neutral", "excess_pct": 3.1,  "score": 12 },
    { "year": 2021, "signal_class": "warming", "excess_pct": 22.4, "score": 61 },
    { "year": 2026, "signal_class": "heating", "excess_pct": 48.15,"score": 100 } ] }
GET/comparables

Deed comparables + a current-year market-value read, for a parcel or a tract. Owner names stay dark. Build and Platform plans.

The valuation evidence: recent arms-length town deeds (winsorized — min price, ratio bounds, class match, 3-year window) → a market read with n + confidence + basis, plus a comps_sample so you can show your work. Pass ?parcel_id= or ?tract=. Names never cross the wire.

GET /comparables
curl "https://developer.permitgranted.ai/api/partner/v1/comparables?parcel_id=03EDA726-..." \
  -H "X-API-Key: $PG_KEY"
response
{ "subject": "pg:parcel:03EDA726-...",
  "valuation": { "market": 720000, "ratio": 1.32, "n": 41,
                 "as_of": 2025, "basis": "deed_ratio", "confidence": "med" },
  "comps_sample": [
    { "sale_price": 690000, "sale_year": 2025, "assessed_total": 512000,
      "property_class": "2", "ratio": 1.348 }, ... ] }
GET/ahj/{slug}

How a jurisdiction actually behaves: approval odds, board friction, admin review days (overall + by trade), permit activity, and operational readiness.

The "how does this town really work" layer — the moat, as data. approval_rate_pct and admin_review_days arrive with n + confidence; review_days_by_trade breaks it down (electrical vs plumbing vs …); readiness flags tell you how deep our record goes there. Also resolvable as pg:ahj:{slug} via /entities.

GET /ahj/{slug}
curl https://developer.permitgranted.ai/api/partner/v1/ahj/jersey-city-nj \
  -H "X-API-Key: $PG_KEY"
response
{ "@id": "pg:ahj:jersey-city-nj", "@type": "AHJ",
  "geography": { "country": "US", "admin1": "NJ", "ahj": "jersey-city-nj" },
  "behavior": { "approval_rate_pct": 79, "board_friction_pct": 14,
    "admin_review_days": 32, "permit_activity": 4210,
    "review_days_by_trade": [ { "trade": "electrical", "review_days": 21 } ],
    "confidence": "high" },
  "readiness": { "overall_pct": 92, "verdict_ready": true, "base_zoning": true } }
POST/readiness

"What a complete application needs here" — the requirement drivers that most often apply in a jurisdiction, each with a plain-English prep step, from its own review record.

The embed-it-in-your-intake call. Pass { jurisdiction } and get the checklist an applicant should clear BEFORE filing here — each item with its prevalence (how often it applies), the responsible department, and a common-English "prepare" step. Requirements-framed and advisory (a person always makes the filing decision); every item traces to the public record. based_on_records + thin tell you how deep our record is for that town. Send an optional package/self_report to reserve the scored tier — it returns scored:null today with a note (scoring widens beyond residential occupancy next). Names never cross the wire.

POST /readiness
curl -X POST https://developer.permitgranted.ai/api/partner/v1/readiness \
  -H "X-API-Key: $PG_KEY" \
  -H "Content-Type: application/json" \
  -d '{"jurisdiction":"jersey-city-nj"}'
response
{ "subject": "pg:ahj:jersey-city-nj",
  "jurisdiction": "jersey-city-nj", "tier": "checklist",
  "based_on_records": 3620, "thin": false,
  "checklist": [
    { "item": "Parking compliance", "category_key": "parking_compliance",
      "department": "Zoning", "prevalence_pct": 30,
      "prepare": "Add a parking checklist to intake that names the local rule up front…" },
    { "item": "Stormwater & grading", "prevalence_pct": 17, "prepare": "Ask for the grading and drainage plan…" } ],
  "disclaimer": "A readiness guide from this jurisdiction's own record, requirements-framed. Advisory…" }
GET/permits

The permit cases on a parcel — application, timeline, and revision history — from 2.46M cases.

The track record: every permit filed on the lot, each with its status, submitted→issued timeline, total_review_days, revision_count/first_pass, and the professional actors (architect/contractor — public filing record, not owner PII). Pass ?parcel_id=.

GET /permits
curl "https://developer.permitgranted.ai/api/partner/v1/permits?parcel_id=03EDA726-..." \
  -H "X-API-Key: $PG_KEY"
response
{ "subject": "pg:parcel:03EDA726-...", "count": 3,
  "permits": [ { "@type": "PermitCase",
    "project": { "type": "alteration", "construction_cost": 85000, "square_feet": 1200 },
    "timeline": { "status": "issued", "submitted_at": "2024-03-01",
                  "issued_at": "2024-04-12", "total_review_days": 42 },
    "revisions": { "count": 1, "first_pass": false },
    "actors": { "architect": "...", "contractor": "..." } } ] }
GET/entities/{uri}/edges

Traverse the graph from a node: the typed relationships around a parcel — its permit cases, the pros who filed them, outcomes, amendments — as nodes + edges for graph-sync.

The "relate this to my graph" call. From a parcel it walks 1 hop (its PermitCases) + a 2nd hop off those (PREPARED_BY architect, REVIEWED_BY, HAS_OUTCOME, AMENDMENT_OF…) across 900K edges. nodes carry @id/@type/label; edges carry from/edge_type/to/confidence/observed_count. Person-like nodes (Owner) are names-dark (anonymization:"pseudonymous", no label). Also available inline as ?include=edges on /parcels/{id}.

GET /entities/{uri}/edges
curl https://developer.permitgranted.ai/api/partner/v1/entities/pg:parcel:03EDA726-.../edges \
  -H "X-API-Key: $PG_KEY"
response
{ "root": "pg:parcel:03EDA726-...", "resolved": true,
  "node_count": 6, "edge_count": 9,
  "nodes": [ { "@id": "pg:permitcase:...", "@type": "PermitCase", "label": "ALT-2024-..." },
             { "@id": "pg:owner:...", "@type": "Owner", "anonymization": "pseudonymous" } ],
  "edges": [ { "from": "pg:parcel:03EDA726-...", "edge_type": "LOCATED_IN",
               "to": "pg:permitcase:...", "confidence": 0.98, "observed_count": 1 } ] }
POST/portfolio/screen

Bulk book screening: POST a whole acquisition book (addresses, pg: URIs, parcel_ids, or block+lot+ahj) and get per-row verdicts + market timing + an instant-triage summary. Explore 25 / Build 500 / Platform 2,500 rows.

The merge-back call for a partner's pipeline: every row echoes your ref, out-of-coverage rows return no_match (never an existence leak), and the summary answers "which rows of MY book do I chase first" in one request — pencils count, moving tracts, top opportunities by residual. Addresses resolve the same way /resolve does: an exact miss inside your grant falls back to house-number-gated fuzzy matching — a matched row carries match_method:"fuzzy" + a score, and an address that could be several parcels (e.g. condos sharing a street line) comes back status:"ambiguous" with ranked candidates rather than a guess. Metered per valid row (×2 with the underwriting attach); a rejected or failed row meters nothing. include:"underwriting" attaches the best-strategy row (Build+); book_too_large names your cap.

POST /portfolio/screen
curl -X POST https://developer.permitgranted.ai/api/partner/v1/portfolio/screen \
  -H "X-API-Key: $PG_KEY" -H "Content-Type: application/json" \
  -d '{"items":["305 grove st.",
              {"ref":"row-tonnele","address":"702 TONNELE AVENUE"},
              "pg:parcel:B55649FF-..."]}'
response
{ "@type": "PortfolioScreen", "requested": 7, "valid": 6, "metered": 6,
  "summary": { "matched": 5, "pencils": 4,
    "best_strategy_mix": { "ground_up": 2, "land_bank": 2 },
    "moving_tracts": [ { "uri": "pg:market:34017005300", "class": "heating", "excess_pct": 29.05 } ],
    "top_opportunities": [ { "ref": "row-tonnele", "best_strategy": "land_bank",
        "residual_land_value": 22545155, "market_class": "heating" } ],
    "coverage_note": "Rows outside your plan's geographies (us-nj-jersey-city) return no_match." },
  "results": [
    { "index": 0, "input": "305 grove st.", "status": "matched",
      "match_method": "fuzzy", "score": 0.939,
      "parcel": { "@id": "pg:parcel:03EDA726-...", "verdict": { "pencils": false } } },
    { "index": 2, "ref": "row-tonnele", "status": "matched", "match_method": "exact",
      "parcel": { "@id": "pg:parcel:6137DF72-...", "verdict": { "pencils": true,
        "best_strategy": "land_bank", "residual_land_value": 22545155 } },
      "market": { "signal": { "class": "heating", "excess_pct": 14.31 } } },
    { "index": 3, "input": "1 shore ln", "status": "ambiguous",
      "candidates": [ { "uri": "pg:parcel:AA...", "score": 0.97 },
                      { "uri": "pg:parcel:BB...", "score": 0.97 } ] },
    { "index": 4, "ref": "row-hoboken", "status": "no_match" },
    { "index": 5, "status": "invalid", "reason": "unresolvable_input" } ] }
GET/parcels/{id}/geometry

The exact lot footprint as a GeoJSON Feature (WGS84) — the polygon, not just the centroid.

When a marker isn't enough: render the precise boundary, compute area, or clip against a district. A Feature with bbox + Polygon geometry + properties (@id, ahj, tract, address). Parcels without a loaded polygon (some states are centroids-only today) return 404 not_available, pointing you at the centroid on /parcels/{id}. Coverage today: Jersey City + Hoboken (~74k footprints).

GET /parcels/{id}/geometry
curl https://developer.permitgranted.ai/api/partner/v1/parcels/DB7876D8-.../geometry \
  -H "X-API-Key: $PG_KEY"
response
{ "type": "Feature", "id": "pg:parcel:DB7876D8-...",
  "bbox": [-74.0438274, 40.7189480, -74.0433891, 40.7192448],
  "geometry": { "type": "Polygon", "coordinates": [ ... ] },
  "properties": { "@id": "pg:parcel:DB7876D8-...", "ahj": "jersey-city-nj",
                  "tract": "34017006400", "address": "305 GROVE ST.", "crs": "EPSG:4326" } }
GET/markets/{id}/geometry

A census tract boundary as GeoJSON, with the market signal + forward inlined as properties — the one-response choropleth.

Skip the TIGER/Line join: get the tract polygon AND its signal/forward in one Feature, so you can fill a heating choropleth from a single call. Boundaries warm on demand into our store (per-state automatic). A JC-scoped key asking a Hoboken tract gets a 403 naming the geography — a boundary never leaks.

GET /markets/{id}/geometry
curl https://developer.permitgranted.ai/api/partner/v1/markets/34017000101/geometry \
  -H "X-API-Key: $PG_KEY"
response
{ "type": "Feature", "id": "pg:market:34017000101",
  "bbox": [ ... ], "geometry": { "type": "Polygon", "coordinates": [ ... ] },
  "properties": { "@id": "pg:market:34017000101", "ahj": "jersey-city-nj",
    "signal":  { "class": "heating", "excess_pct": 14.31, "confidence": "med", "as_of": 2026 },
    "forward": { "forward_score": 98, "projected_excess_pct": 35.66, "horizon_years": 2,
                 "permit_led": true, "confidence": "high" }, "crs": "EPSG:4326" } }
GET/tiles/parcels/{z}/{x}/{y}.mvt

First-party Mapbox Vector Tiles for the parcel layer — render our exact GPU map as a native vector layer, no tile server to host.

Point a MapLibre or Mapbox GL style at this template and style parcels client-side by the props we ship. Parcels resolve at z14+ (below that, fill tracts from /rankings). Only your entitled AHJs contribute features — a tile outside your grant returns an empty 204 and the viewport still renders from the basemap, so a boundary never leaks. Tiles are usage-logged but cost 0: panning the map never debits your monthly quota (rate limits are the volume guard). Each feature carries the same props the dossier reads — including the MOD-IV facts (property class, year built, assessed total) and the abated-newbuild flag.

GET /tiles/parcels/{z}/{x}/{y}.mvt
# point a GL source at the XYZ template:
#   "tiles": ["https://developer.permitgranted.ai/api/partner/v1/tiles/parcels/{z}/{x}/{y}.mvt?api_key=$PG_KEY"], "minzoom": 14
curl "https://developer.permitgranted.ai/api/partner/v1/tiles/parcels/15/9645/12318.mvt" \
  -H "X-API-Key: $PG_KEY" -o tile.mvt
response
# 200 OK · Content-Type: application/vnd.mapbox-vector-tile
# a Jersey-City tile on a JC-entitled key → ~378 KB of parcel features
# the SAME tile on an out-of-grant key      → 204 No Content (empty, not an error)
#
# source-layer: "parcels"   feature properties (per parcel):
#   pid  parcel id (promoteId)   s   off-market score 0-100   v   verdict / pencil
#   up   upside band 0-3         a   acquirable 0/1           ht  by-right height band
#   fl   flood band              hi  historic 0/1             rd  redevelopment 0/1
#   ab   abated new-build 0/1    el  exemption label          yb  year built
#   pc   MOD-IV property class   tv  assessed total
POST/environmental/order

Order an NJ environmental screen for a parcel from inside your title production system, and get the same frozen artifact the agency surface serves.

Built for Qualia / SoftPro 360 / ResWare. Send your own order_id and the call is idempotent — a marketplace retry returns the original screen rather than running a second one and charging you twice. Add "mode":"async" for a 202 with the result pushed to a signed webhook instead of holding the connection open. ⚠ It deliberately does NOT draft Schedule B exception text and never returns "APPROVED": the book and page come from a county land-records search we do not yet run, and which endorsement applies is an underwriting decision that is not ours. Every field we cannot source is PRESENT and explicitly empty with a `requires` reason — an absent field reads as "nothing found", an empty one reads as "not searched", and that distinction is the product.

POST /environmental/order
curl -X POST https://developer.permitgranted.ai/api/partner/v1/environmental/order \
  -H "X-API-Key: $PG_KEY" -H "Content-Type: application/json" \
  -d '{"order_id":"QUALIA-88213","external_file_number":"NJ-2026-4417",
       "property":{"address_line1":"140 Woodward St","city":"Jersey City"}}'
response
{ "order_id": "841F02B9-E62E-4754-B75E-8BF7D08C5DB9", "status": "COMPLETED",
  "result_sha256": "34f480fc65791ab6...",
  "endorsement_guidance": {
      "indicated_form": "8.1", "njltirb_form": "NJRB 5-94",
      "status": "indicated",              // never "approved"
      "fee": 25, "fee_currency": "USD", "fee_is_ours": false,
      "fee_basis": "NJLTIRB Manual of Rates and Charges, eff 2019-09-01, sec 10.6" },
  "schedule_b_triggers": [
    { "sequence": 1, "category": "deed_notice",
      "standard_exception": "NJDEP Environmental Deed Notice",
      "text": null,                       // ALWAYS null today
      "requires": "...book, page or instrument number and date, which come from
                   the county land records search this screen does not perform." } ],
  "findings_summary": { "band": "findings_present",
      "headline": "7 listed NJDEP cases within 2,000 ft",
      "listed_cases": 7, "nearest_ft": 730 },
  "not_searched": [ "county_environmental_liens", "federal_court_liens",
                    "unregulated_heating_oil_tanks", "site_specific_investigation" ],
  "billing": { "amount_cents": 9500, "billable": true,
      "settlement_line_item": { "section": "1100 Title Charges",
          "treatment": "pass_through",
          "treatment_basis": "NJLTIRB ... sec 5.3" } },
  "documents": [ { "document_type": "ENVIRONMENTAL_SCREEN",
                   "url": "/api/partner/v1/environmental/841F02B9-....pdf" } ] }
POST/webhooks

Register a signed webhook endpoint, then fire a real test delivery at it — without talking to us first.

The outbound half. POST /webhooks returns a signing_secret ONCE (https endpoints only, enforced at the database). POST /webhooks/{id}/test delivers a real signed sample to your URL synchronously and reports what your endpoint returned, so you can verify the signature before building anything else. The signature is t=<unix>,v1=<HMAC-SHA256 over `${t}.${rawBody}`> — Stripe-shaped, so your team has implemented it before, and the timestamp sits INSIDE the MAC so a delivery you captured is not a permanent replay token. Deliveries retry 6 times over ~4h then fail terminally; a retry carries the SAME event_id, so dedupe on it — our retry must not become your duplicate order. GET /webhooks/deliveries shows every attempt with its response code and error.

POST /webhooks
curl -X POST https://developer.permitgranted.ai/api/partner/v1/webhooks \
  -H "X-API-Key: $PG_KEY" -H "Content-Type: application/json" \
  -d '{"callback_url":"https://your-host/hooks/permitgranted","events":"*"}'

# then prove the wiring, synchronously:
curl -X POST https://developer.permitgranted.ai/api/partner/v1/webhooks/{callback_id}/test -H "X-API-Key: $PG_KEY"
response
{ "ok": true, "callback_id": "...", "signing_secret": "whsec_...",
  "note": "Store this secret now. It is not retrievable - rotate to get a new one." }

# /test answers with what YOUR endpoint did:
{ "ok": true, "delivered": true, "response_status": 200,
  "message": "Your endpoint accepted the test delivery and returned 2xx." }

Maps & geometry

You can render our data on a map today, with no extra endpoints. Two joins carry it:

  • Parcels are points. Every /parcels/{id} returns external_ids.lat/lon — a map-ready centroid. Drop a marker or cluster.
  • Markets are Census tracts. The id after pg:market: is the tract GEOID (e.g. 34017006500). Join it to public TIGER/Line tract geometry and fill by signal.class.

So one /rankings call gives you a whole jurisdiction's tracts, each with a GEOID and a signal class — enough to paint the same heating choropleth we ship:

map-it.sh
# 1) every ranked tract carries its GEOID + signal class
curl "https://developer.permitgranted.ai/api/partner/v1/rankings?ahj=jersey-city-nj&limit=200" -H "X-API-Key: $PG_KEY"
#    → markets[].: { "@id": "pg:market:34017006500", signal: { class: "heating" } }

# 2) GEOID = the tract id → join to public tract geometry, fill by signal.class
#    heating → crimson · warming → gold · cooling → blue

# parcels give you points directly:
curl "https://developer.permitgranted.ai/api/partner/v1/parcels/03EDA726-..." -H "X-API-Key: $PG_KEY"
#    → external_ids: { lat: 40.73494, lon: -74.061621 }

Want the exact polygons instead of the public-geometry join? First-party GeoJSON is now liveparcel footprints and tract boundaries with the signal inlined (a one-response choropleth). See the endpoints above.

Vector tiles — live

The streaming upgrade is live: styled first-party vector tiles, so you render our exact GPU map without hosting a tile server. Point a MapLibre or Mapbox source at the tiles endpoint and style parcels client-side by the props we ship (score, verdict, upside, flood/historic, height, and the MOD-IV facts). Only your entitled AHJs contribute features; out-of-grant tiles come back empty (204), so the viewport still renders and a boundary never leaks.

Map tiles don't count against your quota. Tiles are usage-logged but metered at cost 0 — panning and zooming never debit your monthly rows. Rate limits are the volume guard, so an embedded map is free to feel instant.

Graph & sync — roadmap

Traversal, evidence, and keeping your copy current. Designed and specced (the OpenAPI document covers them); not yet callable. Ask for early access and we'll prioritize with you.

GET/changesComing soon

Change feed since a cursor — keep your copy of the graph in sync.

Instead of re-pulling, poll the diff: entities/properties changed since your last cursor. The nightly bake is the write cadence.

POST/webhooksComing soon

GRAPH events on the existing webhook mechanism — e.g. a tract turning heating.

The delivery infrastructure is live and documented above; this is the additional event family. Register once and receive graph signals on the same signed, retrying channel as environmental.completed.

API Docs — PermitGranted Developer Platform · PermitGranted