PlanNexus provides a RESTful API for accessing planning application data from councils across the UK.
The API is versioned at /v1 and treated as a stable contract — changes are additive, and any breaking change is announced ahead of time. See the changelog & change policy.
All API requests require an API key passed via the X-API-Key header.
curl https://api.plannexus.io/v1/applications \
-H"X-API-Key: pn_live_your_key_here" \
-G -d"q=extension" -d"postcode=SW1"Rate limits are communicated via response headers:
| Tier | Rate | Monthly | Price |
|---|---|---|---|
| Free | 10/min | 1,000 | £0 |
| Starter | 60/min | 50,000 | £99/mo |
| Pro | 300/min | 150,000 | £299/mo |
| Enterprise | Custom | Unlimited | Custom |
/v1/applicationsSearch and filter planning applications with full-text search, postcode filtering, and faceted results. Free and Alerts Pro API keys search a patch — a council (up to 5), postcode districts (up to 10), or a point with a radius up to 25 km — and see applications 7 days after PlanNexus first records them (meta.notice and the X-PlanNexus-Data-Delay header say so). Live, nationwide data is Starter, or the 14-day Starter trial.
Parameters
qstringFull-text search querypostcodestringPostcode prefix. A whole district (SW1, E8) matches that district only, including its lettered sub-districts (SW1A, E1W) — never SW10–SW20. A bare area (SW) or a longer prefix (SW1A 1) matches everything that begins with it.statusstringFilter by status (comma-separated)application_typestringFilter by typeauthority_iduuidFilter by authoritydate_received_fromdateReceived on or after (YYYY-MM-DD)date_received_todateReceived on or beforedecisionstringapproved | approved_with_conditions | refused | withdrawn … — decided applications onlydate_decided_fromdateDecided on or after — with decision= and a constraint filter this is a precedent query (rendered at plannexus.io/precedent)date_decided_todateDecided on or beforein_conservation_areaboolSite carries the constraint. Also has_listed_building, in_article_4, has_tpo, in_green_belt, in_flood_risk_zone, in_aonb, has_scheduled_monument, in_world_heritage_site, has_sssisortstringdate_received (default) | decision_date | date_validatedorderstringasc or desc (default: desc)pageintPage number (default: 1)per_pageintResults per page (default: 25, max: 100)changed_sincedatetimeData Feed plan. Incremental sync: only records that materially changed since this timestamp (new applications, status moves, decisions, date corrections). Use the response's server_time as the next changed_since — unlike date_received_from, this catches changes to old applications too.saved_search_iduuidScope results to one of your saved searches — the exact filter set your alerts use (whole-word terms, postcodes, authorities, statuses, types), applied server-side. Compose with changed_since for an incremental feed of just your matches, contact enrichment included./v1/applications/nearbyFind planning applications near a geographic point.
Parameters
latfloatLatitude (-90 to 90)lngfloatLongitude (-180 to 180)radiusintRadius in metres (default: 1000, max: 50000)/v1/applications/{id}Get full details for a single planning application — including planning constraints at the site and, where the property has one, its Energy Performance Certificate (rating band and technical facts, matched by UPRN, served under OGL with attribution; property owners can ask us to suppress their EPC — see the privacy policy). Discharge-of-conditions and variation-of-condition applications also carry parent_reference and parent_application_id — the permission they act on, resolved within the authority where we hold it.
/v1/applications/{id}/documentsList documents associated with an application.
/v1/applications/{id}/historyGet the status change history for an application.
/v1/constraintsPlanning constraints at a point — conservation areas, listed buildings, flood zones, green belt, tree preservation orders and more, from national open datasets. Query by coordinates or postcode.
Parameters
latfloatLatitude (WGS84); provide together with lnglngfloatLongitude (WGS84); provide together with latpostcodestringUK postcode; geocoded to its centroid when lat/lng are omitted/v1/applications/export.csvPaid. Stream a filtered result set as a CSV download — up to 25,000 rows per call, metered per-row against your monthly export allowance. Takes the same filters as /v1/applications, plus:
Parameters
date_validated_fromdateValidated on or after (YYYY-MM-DD)date_validated_todateValidated on or beforevalidated_or_receivedboolFall back to date_received where a council publishes no validated date — keeps a validated-date feed completelatfloatLatitude for radius filter (use with lng + radius)lngfloatLongitude for radius filterradiusintMetres around (lat, lng) — default 1000, max 50000window_daysintDays back from today when date_received_from is unset (default 90)limitintRows to return (max 25,000)/v1/applications/top-agents.csvPaid. The most-active planning agents within a filtered result set, as CSV — same filters as /v1/applications.
/v1/authoritiesList all local authorities in the database.
The endpoints behind the council scorecard at /analytics. “Paid” means any paid plan (Alerts Pro and above); the league tables and teasers are public.
/v1/analytics/councilsPublic — no auth. League table — approval rate, decision speed and volume for every authority (rolling 12 months, official MHCLG PS2). Rendered at plannexus.io/leagues/planning.
Parameters
sortstringapproval_rate | speed | decisionsregionstringFilter to one region/v1/analytics/councils/{gss}Paid. One council's scorecard vs national + regional averages, with rank and the major/minor/other split.
/v1/analytics/councils/{gss}/trendsPaid. Full quarterly approval/timeliness series, 1979 to present.
/v1/analytics/councils/{gss}/teaserPublic — no auth. Headline approval rate, national average and rank for one council.
/v1/analytics/councils/{gss}/performancePublic — no auth. One council's rolling-year approval rate, refusal rate and share decided on time against the national figure, the major / minor / householder split, and a calendar-year history (partial years marked). Rendered at plannexus.io/leagues/planning/{gss} for every authority with returns in the latest four quarters (310 at the time of writing; the history goes back to 1979).
/v1/analytics/activityPaid. Busiest authorities by applications received (live; 12-month and last-90-day volumes).
Parameters
sortstringvolume | recentregionstringFilter to one region/v1/analytics/activity/{gss}Paid. One authority's volume, application-type mix and 24-month trend.
/v1/analytics/councils/{gss}/pressure-teaserPublic — no auth. The development-pressure map in five quantile bands of applications received per cell — the band only, nothing else per cell. Rendered on plannexus.io/leagues/planning/{gss}; the counts, approval record, brownfield sites and hotspots are on the paid endpoint below.
/v1/analytics/energy/{gss}/hexPaid. MEES map: every rated EPC certificate placed on the council's hex grid through its UPRN (98.7% carry one), with all-stock and private-rented counts per cell and the private-rented below-C and F/G shares where a cell has at least 30 rated private-rented certificates; ranked hotspots. Rebuilt quarterly.
/v1/analytics/pressure/{gss}Paid. Development pressure: a metre-true hex grid over the council (500 m borough / 1 km district / 2 km county) with applications received in the last 12 months, determinations and approvals in the last 24 (a cell carries its own approval rate past 10 determinations) and brownfield-register sites per cell — as a GeoJSON FeatureCollection plus ranked hotspots and the share of activity in the busiest tenth of cells. Tracked councils only; cached six hours.
/v1/analytics/agentsPaid. Most-active planning agents by application volume (national, or scoped to one authority).
Parameters
gss_codestringScope to one authority (else national)limitintRows to return (default 50, max 100)/v1/analytics/market/{gss}Paid. Property-market summary for one council from HM Land Registry Price Paid — median price, sales volume and year-on-year change (England & Wales; YoY suppressed on thin samples).
/v1/analytics/market/{gss}/teaserPublic — no auth. Headline median price and sales volume for one council.
/v1/analytics/energyPublic — no auth. Energy-efficiency league built from 22M+ domestic EPC certificates — share rated C or better and mean SAP score per council (suppressed under 100 certificates). Rendered at plannexus.io/leagues/energy.
/v1/analytics/energy/{gss}Paid. One council's EPC profile: rating distribution, mean SAP score, retrofit signals, and the MEES figures — certificates rated F or G (cannot be let without an exemption) and below C (the proposed rental standard), with the national comparison, for all stock and — under private_rented — for the private-rented stock MEES applies to (tenure as recorded on the certificate; tenure_known_share says how complete it is).
/v1/analytics/energy/{gss}/teaserPublic — no auth. Headline share rated C+ and rank for one council.
/v1/analytics/appealsPublic — no auth. Appeals league from Planning Inspectorate casework — overturn rate per council (appeals allowed / decided; suppressed under 20 decided). Rendered at plannexus.io/leagues/appeals.
/v1/analytics/appeals/agentsPaid. Agent win rates at appeal — who actually gets refusals overturned.
/v1/analytics/appeals/{gss}Paid. One council's appeal record: overturn rate vs the national average, volume and 10-year trend.
/v1/analytics/appeals/{gss}/teaserPublic — no auth. Headline overturn rate and rank for one council.
“Will this be approved, and when?” — the approval rate over determinations (withdrawals excluded) and the median and 90th-percentile days from validation to decision for comparable applications at a council, from our own record of decided applications over the last three years, with the comparable count behind every figure. Fewer than 30 comparables returns no rate at all (confidence “insufficient”); 30–99 is “thin”. Available on every plan including Free.
/v1/outlookAny signed-in caller. One council, optionally one application type. A type too thin to answer on widens to the whole council and says so in basis.widened_from.
Parameters
authority_gssstringGSS code of the council, e.g. E09000007application_typestringhouseholder | full_planning | outline_planning | change_of_use | prior_approval | listed_building | advertisement | lawful_development | variation_conditions | discharge_conditions | reserved_matters | tree_works/v1/outlook/by-typeAny signed-in caller. The whole council in one call: `all` plus every application type with its own sample, largest first. Thin types come back as insufficient with their count — never silently widened.
Parameters
authority_gssstringGSS code of the council/v1/applications/{id}/outlookAny signed-in caller. The same comparison keyed off a live application — its council and type — so a record in a feed or alert carries its odds and timescale.
/v1/dossierPaid. PropertyDossier — one call, everything we hold about a site: planning history and outcomes, live constraints, flood zone, LPA performance, appeals record, heritage, market context, EPC (where present), and area ownership statistics. Every layer carries its own provenance (source + retrieved_at) and degrades honestly — a layer we can't serve says so and why, and the dossier states its own outcome-coverage grade for the area. Locate by postcode, coordinates, UPRN or address. Metered per tier: Free/Alerts Pro 25, Starter 250, Pro 1,500 lookups/mo; Enterprise unlimited. See a real response at plannexus.io/samples/dossier.
Parameters
postcodestringFull UK postcode to centre the dossier onlatfloatWGS84 latitude (with lng; alternative to postcode)lngfloatWGS84 longitudeuprnstringUnique Property Reference NumberaddressstringFree-text address (geocoded)formatstringjson (default) or pdf — the same dossier as a printable document, drawing down the same single lookup.See a real dossier response → (a refused retail scheme in Harrow, now at appeal — nothing mocked).
/v1/subscriptionsCreate a webhook subscription to receive notifications for new applications or status changes. Data Feed plan.
Parameters
webhook_urlstringHTTPS URL to receive payloadsfilter_postcodesstring[]Postcode prefixes, any-match; a whole district (E1) means that district onlyeventsstring[]Event types: new_application, status_change, decision_made/v1/subscriptionsList your webhook subscriptions.
Thin, typed clients around the REST API. Both honour Retry-After on 429 automatically and ship TypeScript/Python types for every response shape.
from plannexus import PlanNexus
with PlanNexus(api_key="pn_live_...") as client:
result = client.applications.search(
q="extension", postcode="SW1", per_page=10
)
for app in result["data"]:
print(app["reference"], app["address"])import { PlanNexus } from "plannexus";
const client = new PlanNexus({ apiKey: "pn_live_..." });
const result = await client.applications.search({
q: "extension",
postcode: "SW1",
per_page: 10,
});
for (const app of result.data) {
console.log(app.reference, app.address);
}import httpx
client = httpx.Client(
base_url="https://api.plannexus.io/v1",
headers={"X-API-Key": "pn_live_your_key"}
)
# Search for extensions in SW1
resp = client.get("/applications", params={
"q": "extension",
"postcode": "SW1",
"per_page": 10
})
data = resp.json()
for app in data["data"]:
print(f"{app['reference']} - {app['address']}")
print(f" Status: {app['status']}")
print(f" Received: {app['date_received']}")
const response = await fetch(
"https://api.plannexus.io/v1/applications? " +
new URLSearchParams({ q: "extension", postcode: "SW1" }),
{
headers: {"X-API-Key": "pn_live_your_key" },
}
);
const { data, meta } = await response.json();
console.log(`Found ${meta.total} applications`);
data.forEach((app) => {
console.log(`${app.reference} - ${app.address}`);
});List endpoints (applications, authorities, subscriptions) return a wrapped envelope:
{
"data": [ /* array of records */ ],
"meta": {
"total": 4218,
"page": 1,
"per_page": 25,
"pages": 169
}
}Single-record endpoints return the bare object. Pagination is page-based with page and per_page query params — `per_page` caps at 100.
| Status | Meaning | What to do |
|---|---|---|
| 400 | Malformed request / bad query | Read the detail field; fix the input |
| 401 | Missing / invalid API key | Add X-API-Key header; rotate if lost |
| 403 | Feature requires a higher tier (e.g. webhooks) | Upgrade at /dashboard/billing |
| 404 | Resource not found | Check the id / reference |
| 429 | Rate limit / quota exhausted | Honour the Retry-After header; retry after |
| 5xx | Server error | Retry with exponential backoff; persistent 5xx → email support |
Create a subscription via POST /v1/subscriptions (or the Webhooks page in your dashboard). PlanNexus POSTs a JSON payload to your endpoint whenever a matching application triggers a subscribed event.
new_application — a council published a new applicationstatus_change — an existing application's status moved (e.g. validated → under-consultation)decision_made — an application was decided (approved/refused/withdrawn){
"event": "new_application",
"timestamp": "2026-04-23T09:15:42.108Z",
"data": {
"application_id": "3f2504e0-4f89-11d3-9a0c-0305e82c3301",
"reference": "23/04521/FUL",
"authority_id": "018e1c50-...",
"address": "14 Bishopsgate, London EC2N 4AW",
"postcode": "EC2N 4AW",
"description": "Erection of a single-storey rear extension.",
"status": "validated",
"decision": null,
"application_type": "householder",
"date_received": "2026-04-23" }
}Content-Type : application/jsonX-PlanNexus-Signature : sha256=<hex-digest> — HMAC-SHA256 over the raw body, keyed by your webhook secretX-PlanNexus-Event — same as payload.event; handy for routing before parsing the bodyimport hmac, hashlib
def verify(body: bytes, header: str, secret: str) -> bool:
expected ="sha256=" + hmac.new(
secret.encode(), body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, header)import { createHmac, timingSafeEqual } from "crypto";
export function verify(body, header, secret) {
const expected =
"sha256=" +
createHmac("sha256", secret).update(body).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(header);
return a.length === b.length && timingSafeEqual(a, b);
}A 2xx response is treated as delivered. Anything else counts toward a consecutive-failure tally; a subscription is auto-paused after 10 consecutive failures. Paused subscriptions stop receiving events — resume them from the dashboard once your endpoint is healthy. Click Send test on a subscription to fire a synthetic event: "test" payload — test deliveries don't count against the failure tally.
Datasets held alongside the applications: corporate and overseas ownership, S106/CIL obligations, brownfield registers, OS Open UPRN and the council address gazetteers. Every response states its own source and limits; an empty result means “not covered”, never “nothing exists”.
/v1/ownershipCorporate and overseas-held Land Registry titles (CCOD/OCOD) by postcode or district. Company ownership only. Paid plans.
Parameters
postcodestringFull postcodedistrictstringLand Registry district, e.g. ISLINGTONoverseas_onlybooleanOnly overseas entities/v1/obligationsS106 covenants and CIL liabilities attached to a consent, by reference or authority. Coverage is the Exacom councils and is stated per response. Paid plans.
Parameters
referencestringPlanning application referenceauthoritystringCouncil namecil_onlybooleanOnly records carrying a CIL liability/v1/enforcementCouncil enforcement registers — alleged breaches under investigation: the council's reference, site address and postcode, what is alleged, status, date opened and the date a formal notice was served. Newest first, up to 200. Every response states its coverage: about half of idox councils publish no register, so absence is never evidence of no enforcement. Any signed-in caller. Per-council picture: /v1/analytics/enforcement/{gss} (paid) and the scorecard's Enforcement section.
Parameters
authoritystringCouncil GSS code, comma-separated for severalpostcodestringPostcode prefix (district-aware: M1 is not M10–M19)statusstringSubstring of the council's status textreceived_fromdateOpened on or afterreceived_todateOpened on or beforenotice_servedbooleantrue = a formal notice has been servedqstringSubstring of the allegation or the addresslimitinteger1–200, default 50offsetintegerPaging offset/v1/price-paidIndividual sales from HM Land Registry Price Paid — the comparables. Every recorded sale at a postcode or across an outward code: price, date, address, property type, tenure, new-build flag, category; newest first, up to 200. Any signed-in caller. Council-level median and trend: /v1/analytics/market/{gss}.
Parameters
postcodestringFull postcode, e.g. BS6 6NBpostcode_prefixstringOutward code or longer, e.g. BS6 or BS6 6 (one of postcode / postcode_prefix is required)property_typestringD | S | T | F | Onew_buildbooltrue / falsemin_priceint£max_priceint£yearsintLook back this many years (default 3, max 25)limitintRows (default 50, max 200)/v1/parcelsThe registered land parcel under a point and the parcels touching it (within 0.5 m), as GeoJSON with INSPIRE ids and areas — HM Land Registry INSPIRE Index Polygons, attribution carried on the response. No owner names (not in the open polygons; corporate owners are on /v1/ownership). Any signed-in caller.
Parameters
latfloatWGS84 latitudelngfloatWGS84 longitudeadjoiningboolInclude touching parcels (default true, capped at 30)/v1/epc/{uprn}The latest Energy Performance Certificate at a UPRN: band, score, potential, floor area, dates, tenure as recorded, and what the band means under MEES for a let property (mees_status). Property-level only — address fields are never served and erasure suppressions are honoured. Any signed-in caller.
/v1/brownfieldSites on councils' brownfield land registers: dwelling estimates, permission status, deliverability, last update. Open data; any signed-in caller.
Parameters
authority_gssstringCouncil GSS codepostcodestringPostcode or outward code prefixlat, lng, radiusnumberRadius in metres around a pointdeliverable_onlybooleanOnly sites marked deliverablemin_dwellingsintegerMinimum net dwellings/v1/uprn/{uprn}One UPRN: the OS Open UPRN point, its ONS postcode, any council-published address, and the applications we hold at it. Any signed-in caller.
/v1/addressesAddresses with UPRNs for one postcode from the council gazetteers we hold (not a national address product). Paid plans.
Parameters
postcodestringFull postcode (required)qstringText to match within the addressThe party-wall question: which properties adjoin a site? The Land Registry INSPIRE parcel containing the point, every parcel touching it, the addressable properties (UPRNs) inside them with a postcode each, and an address wherever a source we may republish carries one. The response counts named against unnamed rather than hiding the gap; statutory-notice certainty needs a licensed address product. Starter and above, one lookup per call against a monthly allowance.
/v1/neighboursAdjoining properties for a point.
Parameters
latnumberLatitude (WGS84)lngnumberLongitude (WGS84)/v1/applications/{id}/neighboursThe same lookup keyed by one of our applications (its geocoded site). 404 when the application has no location.
Two layers. The merge-ready CSV is on every paid plan and is metered as export rows. The mail-out endpoints under /v1/letters are Letters, included from Starter (mailout in your /v1/auth/me entitlements): a worklist that shrinks as you use it, campaigns sent through your own Stannp account, your letterheads and standard letters, and a do-not-write list. Each letter sent counts as one export row; nothing is charged per letter.
/v1/applications/letters.csvMerge-ready rows, one per property: recipient, salutation, address split into lines, and the planning facts. Filter by council or a radius. Paid plans; metered as export rows.
Parameters
authority_gssstringCouncil GSS code (or use lat + lng)lat, lng, radiusnumberRadius in metres around a point (100–20000)application_typestringe.g. householderdaysintegerReceived within the last N days (default 30)named_onlybooleanDrop rows with no usable owner name (default true)/v1/letters/worklistApplications in scope minus those already written to, dismissed, or on your do-not-write list — with counts saying why each excluded row is excluded. Scope by saved_search_id, authority_gss, or lat + lng + radius.
Parameters
saved_search_iduuidOne of your saved searches (its councils, postcodes, radius, types)include_skippedbooleanAlso return excluded rows with skip_reason (default true)limitintegerUp to 500/v1/letters/campaignsFreeze a recipient list from the worklist plus what to say: filters, optional application_ids, template_id or body text, letterhead_id, provider_id, test_mode. Creates a draft; nothing is sent or metered yet.
/v1/letters/campaigns/{id}/sendPush the campaign into your print account. Metered once here as export rows (429 if it would exceed your ceiling, and nothing is sent). Runs in the background; poll the campaign and its /letters for provider ids, statuses and any provider error text.
/v1/letters/providersConnect your own print account (provider: stannp | docmail | intelliprint | postgrid | clicksend, with that provider's credential fields). Verified against the provider before it is stored, encrypted. GET /v1/letters/providers/available lists what can be connected and which fields each needs.
/v1/letters/campaigns/{id}/preview.pdfThe first letter rendered exactly as it will be sent, with the address-window outline. POST /v1/letters/preview.pdf renders any text with sample data.
/v1/letters/suppressionsNever write to this address again ({ address_line_1, postcode, reason }). Matched on the normalised first line + postcode so a re-filed application at the same property is caught. POST /v1/letters/{letter_id}/suppress does it straight from a sent letter.
/v1/letters/dismissals'Not writing to these' — drop application_ids from the worklist without a letter.
/v1/letters/merge-fieldsThe {{fields}} a letter body, heading or signature may use: salutation, recipient_name, address lines, council, application_reference, application_type, date_received, description, portal_link, today.
Try the API directly in your browser with our Swagger UI.
Open Swagger UI