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.
pk_sandbox_…, production keys pk_live_….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.
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.
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.
/resolveResolve 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.
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"}'{ "match": { "uri": "pg:parcel:03EDA726-317B-4695-BDF7-0B6E48ED5669",
"score": 1, "decision": "match" },
"candidates": [] }/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".
curl https://developer.permitgranted.ai/api/partner/v1/parcels/03EDA726-317B-4695-BDF7-0B6E48ED5669 \ -H "X-API-Key: $PG_KEY"
{ "@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"./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.
curl https://developer.permitgranted.ai/api/partner/v1/markets/34017006500 \ -H "X-API-Key: $PG_KEY"
{ "@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, ... } }/parcels/{id}/underwritingThe 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.
curl https://developer.permitgranted.ai/api/partner/v1/parcels/E47CE4A5-.../underwriting \ -H "X-API-Key: $PG_KEY"
{ "@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, ... } }/parcels/{id}/assemblageAssemblage-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.
curl https://developer.permitgranted.ai/api/partner/v1/parcels/7A1B5C21-.../assemblage \ -H "X-API-Key: $PG_KEY"
{ "@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 } ] }/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.
curl https://developer.permitgranted.ai/api/partner/v1/entities/pg:market:34017006500 \ -H "X-API-Key: $PG_KEY"
// identical to GET /markets/34017006500
/rankingsA 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).
curl "https://developer.permitgranted.ai/api/partner/v1/rankings?ahj=jersey-city-nj&limit=20" \ -H "X-API-Key: $PG_KEY"
{ "markets": [ { "@id": "pg:market:34017006500", "@type": "Market",
"signal": { "class": "heating", "excess_pct": 48.15, ... } },
... ],
"count": 20, "next_cursor": null }/markets/{id}/historyA 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.
curl https://developer.permitgranted.ai/api/partner/v1/markets/34017006500/history \ -H "X-API-Key: $PG_KEY"
{ "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 } ] }/comparablesDeed 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.
curl "https://developer.permitgranted.ai/api/partner/v1/comparables?parcel_id=03EDA726-..." \ -H "X-API-Key: $PG_KEY"
{ "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 }, ... ] }/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.
curl https://developer.permitgranted.ai/api/partner/v1/ahj/jersey-city-nj \ -H "X-API-Key: $PG_KEY"
{ "@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 } }/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.
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"}'{ "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…" }/permitsThe 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=.
curl "https://developer.permitgranted.ai/api/partner/v1/permits?parcel_id=03EDA726-..." \ -H "X-API-Key: $PG_KEY"
{ "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": "..." } } ] }/entities/{uri}/edgesTraverse 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}.
curl https://developer.permitgranted.ai/api/partner/v1/entities/pg:parcel:03EDA726-.../edges \ -H "X-API-Key: $PG_KEY"
{ "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 } ] }/portfolio/screenBulk 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.
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-..."]}'{ "@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" } ] }/parcels/{id}/geometryThe 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).
curl https://developer.permitgranted.ai/api/partner/v1/parcels/DB7876D8-.../geometry \ -H "X-API-Key: $PG_KEY"
{ "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" } }/markets/{id}/geometryA 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.
curl https://developer.permitgranted.ai/api/partner/v1/markets/34017000101/geometry \ -H "X-API-Key: $PG_KEY"
{ "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" } }/tiles/parcels/{z}/{x}/{y}.mvtFirst-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.
# 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# 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
/environmental/orderOrder 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.
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"}}'{ "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" } ] }/webhooksRegister 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.
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"{ "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." }You can render our data on a map today, with no extra endpoints. Two joins carry it:
/parcels/{id} returns external_ids.lat/lon — a map-ready centroid. Drop a marker or cluster.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:
# 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 live — parcel footprints and tract boundaries with the signal inlined (a one-response choropleth). See the endpoints above.
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.
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.
/changesComing soonChange 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.
/webhooksComing soonGRAPH 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.