UK Planning Applications API reference
Structured, classified, source-linked UK planning application data over a REST JSON API. Base URL https://api.plota.co.uk/v1
Authentication
Every request needs an API key, sent as a bearer token (preferred) or the X-API-Key header. Keys are secret - never expose them in client-side code.
curl "https://api.plota.co.uk/v1/applications?postcode=SW11&limit=5" \
-H "Authorization: Bearer plota_live_your_key"Endpoints
| Endpoint | Purpose |
|---|---|
GET /v1/applications | Search applications with filters + cursor pagination |
GET /v1/applications/{id} | Retrieve one application by public id or numeric id |
GET /v1/applications/nearby | Applications near a coordinate or postcode |
GET /v1/councils | Covered councils with coverage metadata |
GET /v1/categories | Plota planning categories |
GET /v1/alerts | Saved alerts belonging to your key’s email |
GET /v1/alerts/{id} | Retrieve one saved alert |
GET /v1/alerts/{id}/matches | Applications matching a saved alert |
GET /v1/applications/{id}/history | Status & decision timeline |
POST /v1/exports | Bulk export as CSV/JSON (Starter+) |
POST /v1/webhooks | Register a signed webhook (Starter+) |
GET / DELETE /v1/webhooks/{id} | List or remove webhooks |
POST /v1/webhooks/{id}/test | Send a test event |
Query parameters
GET /v1/applications accepts:
| Parameter | Description |
|---|---|
postcode | Full or partial postcode, e.g. SW11 or SW11 6HB |
council | Council slug (see /v1/councils), e.g. wandsworth |
nation | england, scotland, wales, northern-ireland |
category | One or more category slugs, comma-separated (see /v1/categories) |
q | Keyword match in description, address, or reference |
reference | Exact council reference, e.g. 3PL/2024/0561/HOU - case- and space-insensitive. Unique only within a council, so add council to pin one application |
status | Substring match on status, e.g. pending, approved |
date_from / date_to | Filter by received date, YYYY-MM-DD |
limit | Page size, capped per plan (Demo 10 → Business 250) |
cursor | Opaque pagination cursor from meta.next_cursor |
format | json (default) or csv |
include_contact | Contact Data add-on only: false omits contact fields on this call so nothing is metered against your monthly contact allowance |
Pagination
List endpoints use opaque cursor pagination. Pass meta.next_cursor back as cursor to fetch the next page. A null next_cursor means the last page.
{
"data": [
"..."
],
"meta": {
"limit": 25,
"count": 25,
"next_cursor": "eyJzIjoiMjAyNi0wNi0xOCIsImkiOjE4fQ"
}
}Rate limits & quotas
Every response carries X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, plus monthly counterparts. A 429 includes Retry-After.
| Plan | Per minute | Per month | Max page | Export rows |
|---|---|---|---|---|
| Demo | 30 / min | 500 total (1,000 rows) | 10 | - |
| Starter | 120 / min | 20,000 / month | 50 | 5,000 / month |
| Pro | 300 / min | 100,000 / month | 100 | 100,000 / month |
| Business | 600 / min | 500,000 / month | 250 | 500,000 / month |
| Enterprise | 1,200 / min | Custom | 500 | Custom |
Export rows = the total POST /v1/exports rows a key can pull per calendar month, across all export calls. Per-call caps still apply; the response reports both via meta.monthly_rows_used and meta.monthly_rows_cap.
Errors
Errors use a consistent shape with a request_id you can quote in support.
{
"error": {
"type": "invalid_request",
"message": "The postcode parameter is invalid.",
"param": "postcode",
"request_id": "req_01jz7k8a2p8b"
}
}| HTTP | type | Meaning |
|---|---|---|
| 400 | invalid_request | A parameter is missing or malformed |
| 401 | authentication_error | Missing, invalid, or revoked API key |
| 403 | permission_error | Your plan does not allow this resource |
| 404 | not_found | No resource with that id |
| 429 | rate_limit_error | Per-minute or monthly quota exceeded |
| 500 | server_error | An unexpected error on our side |
| 501 | not_implemented | A roadmap endpoint not yet live |
The application object
One consistent shape across every council. Beyond the core record, each application carries enrichment read from the full council file - ward, parish, UPRN, decision detail, procedural dates, document and public-comment counts. A decided application looks like this:
{
"id": "a1b2c3d4",
"reference": "2026/2284/FUL",
"authority": {
"slug": "wandsworth",
"name": "Wandsworth"
},
"address": "81 Example Road, London SW11 6HB",
"postcode": "SW11 6HB",
"ward": "Northcote",
"parish": null,
"uprn": "100022543210",
"description": "Single storey rear extension.",
"category": {
"slug": "extensions",
"label": "Extensions & alterations"
},
"categories": [
{
"slug": "extensions",
"label": "Extensions & alterations"
}
],
"planning_route": "Full planning permission",
"dwelling_count": null,
"status": "Application Permitted",
"stage": "approved",
"decision": {
"outcome": "Application Permitted",
"issued_date": "2026-07-14",
"level": "Delegated"
},
"appeal": null,
"date_received": "2026-05-18",
"date_validated": "2026-05-20",
"date_decided": "2026-07-10",
"key_dates": {
"target_decision": "2026-07-15",
"committee": null,
"consultation_end": "2026-06-19",
"advertised": "2026-05-28",
"statutory_expiry": "2026-07-15",
"permission_expiry": "2029-07-10"
},
"location": {
"lat": 51.4601,
"lng": -0.1702
},
"documents_count": 14,
"comments": {
"objections": 2,
"supporting": 5,
"received": 8,
"consulted": 21,
"counted_at": "2026-07-29T06:40:12Z"
},
"links": {
"plota": "https://plota.co.uk/application/a1b2c3d4",
"council": "https://..."
},
"source": "live"
}Enrichment & data depth
Normalised vs verbatim. Two rules keep integrations dependable. Filter and aggregate on stage - the normalised status (pending, approved, refused, withdrawn, decided, other), identical across every council. status and decision.outcome carry the council's own wording verbatim - use them for display and for verifying a record against the council source, never for filtering, because every authority words them differently.
Enrichment blocks. decision, appeal, key_dates and comments are read from the full council record on Plota's recurring checks and are null where the authority publishes nothing for them - councils vary widely in how much of the file they publish, so depth differs by authority, not by Plota. Within comments, 0 is real data: counted, none found. uprn is the authoritative UK property identifier for joining planning records to UPRN-keyed property systems. dwelling_count is Plota-derived: the number of homes explicitly stated in the description, null where none is stated.
Live vs historical. Enrichment is a live-data feature (2026 onward). Plota archive records (source: "historical", paid plans) carry the core record with all enrichment blocks null - except dwelling_count, which is backfilled onto the archive too. Single-application responses additionally carry created_at and updated_at; note updated_at moves on every re-check, including no-change ones, so it is not a content-change signal.
Contact Data add-on. With the add-on, records also include applicant, agent, agent_email, agent_phone, case_officer, case_officer_email and case_officer_phone where the council publishes them, metered as one monthly allowance (10,000 records; only records that deliver a contact value count). Pass include_contact=false to skip contact fields and metering on any call. Without the add-on these fields are absent entirely.
Webhooks
Available on Starter and above. Register an HTTPS endpoint in your dashboard (Webhooks → Add a webhook - no code needed) or with POST /v1/webhooks, linked to a saved alert. Plota POSTs an application.match.created event for each new match, with retries, and auto-disables endpoints that keep failing. Every delivery carries a Plota-Event-Id header and event id; retries reuse the same id, so treat it as an idempotency key. The full event body is the WebhookEvent schema in the OpenAPI document. Verify each delivery: compute HMAC-SHA256 over ${Plota-Timestamp}.${rawBody} with your webhook secret and compare to the Plota-Signature header.
const sig = crypto.createHmac('sha256', secret)
.update(req.headers['plota-timestamp'] + '.' + rawBody).digest('hex');
if (sig === req.headers['plota-signature']) { /* trusted */ }Integration guides (Zapier, Make, Slack, Sheets, Airtable, HubSpot, Pipedrive). Manage keys & webhooks in your dashboard. Machine-readable schema: /openapi.json. Licence: API terms. Building with AI: paste /llms.txt into your assistant, or use the MCP server for live access. Questions? hello@plota.co.uk.