plannexus.ioplannexus.io
SearchPrecedentReportsLettersCoverageLeaguesAnalytics
Pricing
Sign inSign up
plannexus.ioplannexus.io
SearchPrecedentReportsLettersCoverageLeaguesAnalytics
Pricing
Sign inSign up

API Documentation

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.

Authentication

All API requests require an API key passed via the X-API-Key header.

Example requestcurl
curl https://api.plannexus.io/v1/applications \
 -H"X-API-Key: pn_live_your_key_here" \
 -G -d"q=extension" -d"postcode=SW1"

Rate Limiting

Rate limits are communicated via response headers:

TierRateMonthlyPrice
Free10/min1,000£0
Starter60/min50,000£99/mo
Pro300/min150,000£299/mo
EnterpriseCustomUnlimitedCustom

Endpoints

GET/v1/applications

Search 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 query
postcodestringPostcode 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 type
authority_iduuidFilter by authority
date_received_fromdateReceived on or after (YYYY-MM-DD)
date_received_todateReceived on or before
decisionstringapproved | approved_with_conditions | refused | withdrawn … — decided applications only
date_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 before
in_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_sssi
sortstringdate_received (default) | decision_date | date_validated
orderstringasc 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.
GET/v1/applications/nearby

Find planning applications near a geographic point.

Parameters

latfloatLatitude (-90 to 90)
lngfloatLongitude (-180 to 180)
radiusintRadius in metres (default: 1000, max: 50000)
GET/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.

GET/v1/applications/{id}/documents

List documents associated with an application.

GET/v1/applications/{id}/history

Get the status change history for an application.

GET/v1/constraints

Planning 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 lng
lngfloatLongitude (WGS84); provide together with lat
postcodestringUK postcode; geocoded to its centroid when lat/lng are omitted
GET/v1/applications/export.csv

Paid. 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 before
validated_or_receivedboolFall back to date_received where a council publishes no validated date — keeps a validated-date feed complete
latfloatLatitude for radius filter (use with lng + radius)
lngfloatLongitude for radius filter
radiusintMetres around (lat, lng) — default 1000, max 50000
window_daysintDays back from today when date_received_from is unset (default 90)
limitintRows to return (max 25,000)
GET/v1/applications/top-agents.csv

Paid. The most-active planning agents within a filtered result set, as CSV — same filters as /v1/applications.

GET/v1/authorities

List all local authorities in the database.

Council analytics

The endpoints behind the council scorecard at /analytics. “Paid” means any paid plan (Alerts Pro and above); the league tables and teasers are public.

GET/v1/analytics/councils

Public — 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 | decisions
regionstringFilter to one region
GET/v1/analytics/councils/{gss}

Paid. One council's scorecard vs national + regional averages, with rank and the major/minor/other split.

GET/v1/analytics/councils/{gss}/trends

Paid. Full quarterly approval/timeliness series, 1979 to present.

GET/v1/analytics/councils/{gss}/teaser

Public — no auth. Headline approval rate, national average and rank for one council.

GET/v1/analytics/councils/{gss}/performance

Public — 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).

GET/v1/analytics/activity

Paid. Busiest authorities by applications received (live; 12-month and last-90-day volumes).

Parameters

sortstringvolume | recent
regionstringFilter to one region
GET/v1/analytics/activity/{gss}

Paid. One authority's volume, application-type mix and 24-month trend.

GET/v1/analytics/councils/{gss}/pressure-teaser

Public — 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.

GET/v1/analytics/energy/{gss}/hex

Paid. 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.

GET/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.

GET/v1/analytics/agents

Paid. 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)
GET/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).

GET/v1/analytics/market/{gss}/teaser

Public — no auth. Headline median price and sales volume for one council.

GET/v1/analytics/energy

Public — 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.

GET/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).

GET/v1/analytics/energy/{gss}/teaser

Public — no auth. Headline share rated C+ and rank for one council.

GET/v1/analytics/appeals

Public — 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.

GET/v1/analytics/appeals/agents

Paid. Agent win rates at appeal — who actually gets refusals overturned.

GET/v1/analytics/appeals/{gss}

Paid. One council's appeal record: overturn rate vs the national average, volume and 10-year trend.

GET/v1/analytics/appeals/{gss}/teaser

Public — no auth. Headline overturn rate and rank for one council.

Decision outlook

“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.

GET/v1/outlook

Any 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. E09000007
application_typestringhouseholder | full_planning | outline_planning | change_of_use | prior_approval | listed_building | advertisement | lawful_development | variation_conditions | discharge_conditions | reserved_matters | tree_works
GET/v1/outlook/by-type

Any 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
GET/v1/applications/{id}/outlook

Any 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.

GET/v1/dossier

Paid. 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 on
latfloatWGS84 latitude (with lng; alternative to postcode)
lngfloatWGS84 longitude
uprnstringUnique Property Reference Number
addressstringFree-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).

POST/v1/subscriptions

Create a webhook subscription to receive notifications for new applications or status changes. Data Feed plan.

Parameters

webhook_urlstringHTTPS URL to receive payloads
filter_postcodesstring[]Postcode prefixes, any-match; a whole district (E1) means that district only
eventsstring[]Event types: new_application, status_change, decision_made
GET/v1/subscriptions

List your webhook subscriptions.

Official SDKs

Thin, typed clients around the REST API. Both honour Retry-After on 429 automatically and ship TypeScript/Python types for every response shape.

Python — pip install plannexuspython
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"])
Node / TS — npm install plannexustypescript
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);
}

Raw HTTP examples

Pythonpython
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']}")
JavaScriptjavascript
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}`);
});

Response envelope & pagination

List endpoints (applications, authorities, subscriptions) return a wrapped envelope:

List response shapejson
{
"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.

Error codes

StatusMeaningWhat to do
400Malformed request / bad queryRead the detail field; fix the input
401Missing / invalid API keyAdd X-API-Key header; rotate if lost
403Feature requires a higher tier (e.g. webhooks)Upgrade at /dashboard/billing
404Resource not foundCheck the id / reference
429Rate limit / quota exhaustedHonour the Retry-After header; retry after
5xxServer errorRetry with exponential backoff; persistent 5xx → email support

Webhooks

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.

Event types

  • new_application — a council published a new application
  • status_change — an existing application's status moved (e.g. validated → under-consultation)
  • decision_made — an application was decided (approved/refused/withdrawn)

Payload format

POST to your webhook URLjson
{
"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" }
}

Headers

  • Content-Type : application/json
  • X-PlanNexus-Signature : sha256=<hex-digest> — HMAC-SHA256 over the raw body, keyed by your webhook secret
  • X-PlanNexus-Event — same as payload.event; handy for routing before parsing the body

Verifying signatures

Pythonpython
import 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)
Node.jsjavascript
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);
}

Delivery & retries

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.

Land records

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”.

GET/v1/ownership

Corporate and overseas-held Land Registry titles (CCOD/OCOD) by postcode or district. Company ownership only. Paid plans.

Parameters

postcodestringFull postcode
districtstringLand Registry district, e.g. ISLINGTON
overseas_onlybooleanOnly overseas entities
GET/v1/obligations

S106 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 reference
authoritystringCouncil name
cil_onlybooleanOnly records carrying a CIL liability
GET/v1/enforcement

Council 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 several
postcodestringPostcode prefix (district-aware: M1 is not M10–M19)
statusstringSubstring of the council's status text
received_fromdateOpened on or after
received_todateOpened on or before
notice_servedbooleantrue = a formal notice has been served
qstringSubstring of the allegation or the address
limitinteger1–200, default 50
offsetintegerPaging offset
GET/v1/price-paid

Individual 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 6NB
postcode_prefixstringOutward code or longer, e.g. BS6 or BS6 6 (one of postcode / postcode_prefix is required)
property_typestringD | S | T | F | O
new_buildbooltrue / false
min_priceint£
max_priceint£
yearsintLook back this many years (default 3, max 25)
limitintRows (default 50, max 200)
GET/v1/parcels

The 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 latitude
lngfloatWGS84 longitude
adjoiningboolInclude touching parcels (default true, capped at 30)
GET/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.

GET/v1/brownfield

Sites on councils' brownfield land registers: dwelling estimates, permission status, deliverability, last update. Open data; any signed-in caller.

Parameters

authority_gssstringCouncil GSS code
postcodestringPostcode or outward code prefix
lat, lng, radiusnumberRadius in metres around a point
deliverable_onlybooleanOnly sites marked deliverable
min_dwellingsintegerMinimum net dwellings
GET/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.

GET/v1/addresses

Addresses 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 address

Adjoining properties

The 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.

GET/v1/neighbours

Adjoining properties for a point.

Parameters

latnumberLatitude (WGS84)
lngnumberLongitude (WGS84)
GET/v1/applications/{id}/neighbours

The same lookup keyed by one of our applications (its geocoded site). 404 when the application has no location.

Letters

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.

GET/v1/applications/letters.csv

Merge-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. householder
daysintegerReceived within the last N days (default 30)
named_onlybooleanDrop rows with no usable owner name (default true)
GET/v1/letters/worklist

Applications 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
POST/v1/letters/campaigns

Freeze 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.

POST/v1/letters/campaigns/{id}/send

Push 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.

POST/v1/letters/providers

Connect 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.

GET/v1/letters/campaigns/{id}/preview.pdf

The 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.

POST/v1/letters/suppressions

Never 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.

POST/v1/letters/dismissals

'Not writing to these' — drop application_ids from the worklist without a letter.

GET/v1/letters/merge-fields

The {{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.

Interactive API Explorer

Try the API directly in your browser with our Swagger UI.

Open Swagger UI