Planning Applications API Docs
Use PlanWire as a UK planning applications API for council planning records, postcode planning data, radius search, current applications, decisions, source links, freshness metadata, signed webhooks and MCP workflows.
Base URL: https://api.planwire.io · Version: v1
From sample key to a working integration
Keep your API key on your server. Free keys return evaluation samples; verify live coverage and missing fields before selecting a paid plan. A successful sample request confirms the response shape, not full territory coverage.
Get a sample key · Check your councils · Integration walkthrough · Customer workflows · MCP server
Run the tested Node.js council-search example. No dependencies are required. It keeps authentication in a server-side header, limits the sample to five records and handles empty responses and errors. This is a PlanWire reference example, not customer code.
Prefer Python? Search UK planning applications with the tested Python example, or open the public Postman collection for search, record lookup, coverage and webhook recovery (JSON download). Keep credentials local and unshared; webhook recovery requires an existing subscription owned by your paid key.
Common evaluator requests
These are the request shapes most teams test first when comparing UK planning data APIs.
curl "https://api.planwire.io/v1/applications?council=adur&page=1&limit=5" \ -H "X-API-Key: YOUR_KEY"
curl "https://api.planwire.io/v1/applications?date_from=2026-08-28&date_to=2026-08-28&limit=100" \ -H "X-API-Key: YOUR_KEY"
curl "https://api.planwire.io/v1/applications?postcode=SW1A&status=Approved&limit=25" \ -H "X-API-Key: YOUR_KEY"
curl "https://api.planwire.io/v1/applications/nearby?lat=51.4975&lng=-0.1278&radius_km=1" \ -H "X-API-Key: YOUR_KEY"
Authentication
New sample keys are delivered to your signup email. Existing keys continue to work and cannot be retrieved by submitting an email address. Sign in to your account to regenerate a key. This immediately invalidates the old key; update all integrations.
To rotate programmatically, send POST /v1/account/rotate-key with your current API key and JSON body {"confirm":true}. The response contains the new key once. This account operation does not consume request credits. If the response is lost, contact hello@planwire.io from your registered email for recovery; do not blindly retry.
Pass your API key in the X-API-Key header. You can also use Authorization: Bearer YOUR_KEY.
X-API-Key: pw_live_a1b2c3d4e5f6...
Errors
All errors return JSON with an error field and an appropriate HTTP status code.
| Status | Meaning |
|---|---|
| 400 | Bad request — missing or invalid parameters |
| 401 | Unauthorised — missing or invalid API key |
| 402 | Payment required — fresh data or broad coverage requires a paid plan |
| 404 | Not found — resource does not exist |
| 429 | Rate limit exceeded — upgrade your plan |
| 500 | Server error — contact support |
Webhook deliveries do not count against the request allowance. API reads, lookups, exports, and backfill requests do.
Pagination
All list endpoints return a meta object. Use ?page=2&limit=50 to paginate. Paid plans can request up to 100 records per page. Free keys are capped at 10 records per page and older sample data.
"meta": { "total": 4821, // total matching records "page": 1, // current page (1-indexed) "limit": 25, // records per page "pages": 242 // total pages }
Freshness & dates
An application's freshness.recordCheckedAt is its last successful individual council-page check. It can advance even when the status or decision has not changed. A null value means no successful individual check has been recorded by this mechanism, not that the council has never been visited.
- recordCheckFailedAt
- Time of the last failed individual check, or null. Retained after a later success: compare it with recordCheckedAt to determine which happened most recently.
- recordCheckError
- Machine-readable failure code, or null. Examples include source_timeout, source_blocked, source_rate_limited and detail_authority_mismatch. Cleared on a successful check.
- lastSeenAt
- Time this stored record was last written. It is not proof that its council page was re-read.
- lastScrapedAt
- Council-wide source-check time, not the check time for this individual application. sourceCheckStatus also describes the council source, not an individual refresh result.
These fields appear in application responses. To request an individual source check, use Refresh application. A failed check does not erase a previously known decision.
PlanWire exposes source-check freshness and application-date freshness separately. lastScrapedAt means the source was checked or refreshed. It should not be treated on its own as proof that new application records were ingested.
For data recency, use latestApplicationDate on councils and dates.applicationDate on applications. Application lists are sorted by applicationDate desc, firstSeenAt desc by default.
"freshness": { "sourceCheckStatus": "fresh", "dataStatus": "stale", "lastScrapedAt": "2026-05-01T09:20:00.000Z", "latestApplicationDate": "2026-03-12" }
Rate limits
Limits reset at midnight UTC. Exceeding the limit returns 429. Free access remains 25 credits/day, up to 10 historical application samples per response and the first 50 results per query, with application dates more than 30 days old. Undated records are not available on free.
Free responses include application identity, location, description and receipt/validation dates. They omit current status, decisions, documents, source-check timestamps, UPRN and enrichment; the access object explains these omissions. They are samples, not historical snapshots or a monitoring feed. Status, decision, UPRN and enrichment filters require a paid plan. Current intelligence (including commencement signals and LPA performance), property flags, changed flags, market evidence, refresh and webhook recovery return HTTP 402 on free keys without reserving request credits. Paid-plan limits are unchanged. Live evaluations and exports are paid, with an agreed scope and no automatic billing.
| Plan | Requests/day | Price |
|---|---|---|
| Free | 25 | £0 |
| Developer | 1,000 | £49/mo + VAT |
| Production | 10,000 | £199/mo + VAT |
| Business | 50,000 | £499/mo + VAT |
| Enterprise | Custom | Contact us |
/v1/applications
List planning applications. Parameters can be combined for council, postcode, date-window, status, decision and keyword workflows. Each application includes council, source, freshness, UPRN property identity where available, decision first-seen, and document metadata so you can verify where the record came from and join it to property datasets.
Query Parameters
adur, camden)postcode_prefix for broad prefix scans.Approved, Refused)Validation collection and reconciliation
Collect an inclusive validation window with validated_from and validated_to. These searches sort by validatedDate and id ascending. They do not infer dates missing from council records. Existing date_from/date_to behaviour is unchanged; combining date filters intersects them.
curl -G 'https://api.planwire.io/v1/applications' \ -H 'X-API-Key: YOUR_API_KEY' \ --data-urlencode 'council=dartford' \ --data-urlencode 'validated_from=2026-08-17' \ --data-urlencode 'validated_to=2026-10-11' \ --data-urlencode 'limit=100'
For ongoing reconciliation, request changed_since without a validation window to include decisions on older applications. Follow meta.nextCursor until null, keeping the same filters. The order is updatedAt then id ascending; meta.hasMore indicates another page. Date-window responses omit exact totals.
curl -G 'https://api.planwire.io/v1/applications' \ -H 'X-API-Key: YOUR_API_KEY' \ --data-urlencode 'council=gravesham' \ --data-urlencode 'changed_since=2026-10-11T00:00:00Z' \ --data-urlencode 'limit=100'
Only advance your checkpoint after every page succeeds. Start the next poll slightly before the prior meta.changedBefore and upsert by id to tolerate overlapping results, in-flight writes and replica lag. Choose an overlap suitable for your workflow and periodically reconcile tracked records. This is a live record view, not a frozen snapshot or an exactly-once change stream: records can move to a later window during a read; deletions are not emitted; maintenance writes can appear without a business-field change. Free keys retain their existing historical sample limits and cannot use changed-since monitoring.
curl "https://api.planwire.io/v1/applications?council=adur&q=extension&status=Approved&limit=5" \ -H "X-API-Key: YOUR_KEY"
Operational record fields
propertyIdentity
object
UPRN and match metadata where the authority supplies a source-native UPRN, PlanWire can attach one from an approved official match index, or a record has a conservative OS Open UPRN coordinate match. Use it to join planning records to EPC, property, price-paid and internal asset tables.
propertyContext
object
Optional single-record enrichment joined by UPRN or application geometry where available: EPC facts, safe HMLR price-paid summaries, and constraints such as conservation areas, listed buildings, flood zones, green belt, Article 4, TPOs and brownfield land.
decisionFirstSeenAt
datetime | null
Immutable timestamp for when PlanWire first observed a decision date or usable decision outcome on this record. Use it to separate publication availability from later row updates.
documents
array
Document links and titles where the authority source supplies document metadata. Empty when the source does not expose documents through the checked route.
freshness
object
Includes source-check status, source age, application-date status, first-seen, last-seen, decision date, and decision-first-seen fields for audit workflows.
/v1/applications/nearby
Find applications within a radius using PostGIS spatial indexing. Extremely fast even over millions of records.
curl "https://api.planwire.io/v1/applications/nearby?lat=51.4975&lng=-0.1278&radius_km=1" \ -H "X-API-Key: YOUR_KEY"
/v1/applications/:id
Fetch a single planning application by its UUID. Includes source metadata, document links where available, and current light planning context: EA flood zone plus Land Registry sold prices for the postcode.
# Take the id from a search response and fetch the full record curl "https://api.planwire.io/v1/applications/$APPLICATION_ID" \ -H "X-API-Key: YOUR_KEY"
Application ids are internal and can change if a record is re-ingested from its council. If you need a stable handle, use the council reference lookup below — a council's own reference does not change, so it is the right thing to store against your own records.
Light Planning Context Fields
floodRisk
object | null
EA Flood Map for Planning zone. zone is one of low, 2, 3a, 3b. Cached 30 days. Null on first call, populated within ~10s.
nearbyPrices
object | null
Land Registry price paid data for the same postcode over the last 2 years. Returns avgPrice (pence), count, postcode. Null if no sales in period or on first call.
documents
array
Source document titles and URLs where available. Constraint datasets such as listed-building, conservation-area, green-belt and Article 4 flags are not exposed until backed by loaded source datasets.
Application object
Core application fields and nested paths. See the OpenAPI schema for the full endpoint contract. Field names are camelCase in JSON responses. Fields marked
| null are absent or null where the source authority does not publish them —
coverage varies by council, so treat any single field as optional.
| Field | Type | Notes |
|---|---|---|
| id | string | Internal PlanWire UUID for fetching this stored record. For durable customer records, store councilId plus reference and use the reference lookup. |
| councilId | string | PlanWire council slug, e.g. leeds. |
| council.name | string | Full authority name. |
| reference | string | The authority's own application reference. |
| url | string | null | Link to the record on the council portal. |
| address | string | null | Site address as published. |
| postcode | string | null | Site postcode where available. |
| uprn | string | null | Unique Property Reference Number where source-native, officially matched, or matched from OS Open UPRN coordinates. |
| uprnMatchMethod | string | null | source_native, official_address_match, os_open_coordinate, or manual. |
| uprnMatchConfidence | number | null | 0 to 1 confidence score for the UPRN match. |
| uprnMatchedAt | timestamp | null | When the UPRN was attached to the record. |
| propertyContext | object | Optional enrichment on single-application endpoints: EPC, HMLR price-paid summary and constraint matches where available. |
| lat | number | null | Latitude, WGS84. |
| lng | number | null | Longitude, WGS84. |
| description | string | null | Proposal text as published by the authority. |
| applicationType | string | null | Authority's application type label. |
| status | string | null | Authority's status label. |
| decision | string | null | Decision text as published. |
| decisionOutcome | string | Normalised outcome: approved, refused, withdrawn, other, or unknown when the source text cannot be classified. |
| receivedDate | date | null | Date the authority received the application. |
| validatedDate | date | null | Date the application was validated. |
| decisionDate | date | null | Date the decision was issued. |
| applicationDate | date | null | Best available application date: receivedDate, falling back to validatedDate. |
| applicationDateSource | string | null | Which field applicationDate came from: receivedDate or validatedDate. |
| applicantName | string | null | Applicant as published. Sparsely populated — present on a minority of records. |
| agentName | string | null | Agent as published. Sparsely populated. |
| documents | array | Objects of { title, url }. Empty for most records. |
| source.type | string | Portal family the record was sourced from, e.g. idox. |
| source.lastScrapedAt | timestamp | null | When PlanWire last checked this council's source. |
| freshness.sourceAgeHours | number | null | Hours since the source check; null when the check time is unavailable. |
| createdAt | timestamp | When PlanWire first recorded this application. |
| updatedAt | timestamp | When PlanWire last changed this record. |
| council | object | Authority identifier and name: council.id and council.name. |
| source | object | Portal type, label and source.lastScrapedAt. A source check is not proof of new records. |
| freshness | object | Separate sourceCheckStatus and dataStatus signals, source age and observation dates. |
| dates | object | Application dates and their provenance, including dates.decisionFirstSeenAt where known. |
| propertyIdentity | object | UPRN and match method/confidence; inspect whether the join is available before using it. |
/v1/property/flags
Planning flags for a batch of postcodes, for filtering a property index before deciding what is worth opening. Returns whether each postcode has any planning history, whether any of it was refused, and when it was last decided — without returning the underlying records.
Metered per property, not per request. A batch of 400 postcodes costs 400 against your daily allowance. Duplicate postcodes are collapsed before charging, so you are not billed twice for the same answer.
Request Body
SW1 are rejected rather than widened to a sector.Batch size by plan
| Plan | Postcodes per request |
|---|---|
| Free | 10 |
| Developer | 100 |
| Production | 500 |
| Business | 500 |
curl -X POST "https://api.planwire.io/v1/property/flags" \ -H "X-API-Key: YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "postcodes": ["SW1A 1AA", "LS10 1BS", "M1 1AE"] }'
Response
Every requested postcode gets a row, including ones with no planning history — that is the answer that lets you skip a
property. hasRefusal reads the normalised
decision_outcome, not council decision text, so it means the same thing in every authority.
{ "data": [ { "postcode": "SW1A 1AA", "hasApplication": true, "hasRefusal": false, "lastDecisionDate": "2025-11-04", "lastApplicationDate": "2025-08-19", "applicationCount": 12 } ], "meta": { "requested": 3, "charged": 3, // charged per postcode, after duplicates collapse "duplicatesCollapsed": 0, "invalidCount": 0, "callsToday": 431 } }
/v1/applications/ref/:council/:reference
Fetch an application using the council's own reference number (e.g. 24/01234/FUL). Single-application reads also include optional property context, flood risk and nearby sold prices where available.
Both this lookup and GET by ID read PlanWire's stored planning record; neither re-checks the council's planning page. Use the returned id with Refresh application when a supported record needs checking.
curl "https://api.planwire.io/v1/applications/ref/adur/AWDM%2F0158%2F25" \ -H "X-API-Key: YOUR_KEY"
Refresh application
/v1/applications/:id/refresh
Queue a council-page check for one stored application using your existing paid API key. No request body is required. Take the UUID from a recent council/reference lookup; the UUID is an internal record identifier.
curl -X POST \ "https://api.planwire.io/v1/applications/$APPLICATION_ID/refresh" \ -H "X-API-Key: YOUR_KEY"
202 Accepted means queued, not refreshed. Illustrative response (UUID values abbreviated):
{
"data": {
"id": "QUEUE_ID",
"status": "pending",
"createdAt": "2026-09-21T12:30:21.000Z",
"applicationId": "APPLICATION_ID",
"alreadyQueued": false
}
}
data.id identifies the queue entry, not the application. Repeating a request while it is pending or running returns the same queue entry with alreadyQueued: true. Use a refresh batch when you need durable progress and per-reference outcomes.
Check the result
Save the application's current freshness fields before submitting. After allowing time for processing, read the application again by UUID or council/reference. A newer recordCheckedAt confirms a successful source check; a newer recordCheckFailedAt records a failure. Inspect recordCheckError and the relative timestamps. If neither has advanced, completion is not yet confirmed. Do not keep POSTing or poll rapidly; subsequent API reads consume requests.
The server scheduler checks for queued work every minute, with bounded sequential processing and customer requests prioritised over automatic revisits. This is not a one-minute completion guarantee: queue load, source health holds and council response times can delay or prevent a check. Failed jobs are retained; the individual endpoint does not automatically retry a failed source check.
Availability and limits
- Available on paid plans, including Developer. Free keys receive HTTP 402; the free allowance is unchanged.
- Supported routes are active, live IDOX council detail routes and Stratford-on-Avon's native application detail route. Coverage in the feed does not imply refresh support. Unsupported routes receive HTTP 409. Inactive or unavailable councils also receive HTTP 409.
- The initial worker checks status and decision fields. It can enrich an undecided record but preserves an existing known decision. It does not refresh documents or reconstruct full stage history.
- A one-hour cooldown applies per application across callers after a refresh request, including a failed attempt. Pending/running work is deduplicated first. During the cooldown, a new request returns HTTP 429 with code
refresh_cooldownandRetry-After: 3600. - Refresh POSTs, including authenticated duplicate or cooldown requests, and subsequent GETs use the normal API request allowance. The background council check is not an additional customer API request. Normal daily and burst limits still apply; not every 429 is a refresh cooldown.
Responses
| HTTP | Meaning |
|---|---|
| 202 | Queued or already pending/running. |
| 400 / 401 | Invalid UUID / missing or invalid authentication. |
| 402 / 404 | Paid plan required / no stored application with this ID. |
| 409 | refresh_unsupported or refresh_unavailable: this record's council route is not available for refresh. |
| 429 | Refresh cooldown or normal API rate limit. Inspect the code and Retry-After header. |
| 503 | refresh_unavailable when disabled, or temporarily_unavailable for a transient queue conflict. |
See the OpenAPI specification for the request and response schemas.
Refresh a batch
/v1/applications/refresh-batches
Submit up to 50,000 stored council/reference pairs for asynchronous reconciliation. The batch is durable, customer-isolated and idempotent. Only one batch may be active per API key.
curl -X POST \
"https://api.planwire.io/v1/applications/refresh-batches" \
-H "X-API-Key: YOUR_KEY" \
-H "Idempotency-Key: reconciliation-2026-10-19" \
-H "Content-Type: application/json" \
--data '{"applications":[
{"council":"southampton","reference":"26/00784/FUL"},
{"council":"bristol","reference":"26/12543/F"}
]}'
HTTP 202 confirms storage, not completion. Reuse the same idempotency key and body safely after a client timeout. Reusing the key with a different body returns HTTP 409.
Read progress
/v1/applications/refresh-batches/:batchId
curl \ "https://api.planwire.io/v1/applications/refresh-batches/$BATCH_ID?limit=100&page=1" \ -H "X-API-Key: YOUR_KEY"
The response reports total, completed and failed items plus paged per-reference states: pending, queued, running, done, failed, skipped or not_found. A terminal completed_with_errors batch includes explicit failure reasons for records that could not be checked.
Source protection
A large accepted batch does not create a source-request burst. At most three batch records per minute enter the existing globally bounded queue. Council health holds, shared-source pacing, URL validation and per-record cooldowns remain in force. Completion time therefore depends on source availability and queue load.
Supplier insights Pilot
Add include=supplier to application list, nearby, ID or council-reference requests to receive an optional supplierInsights object. Existing authentication, request metering, response limits and free/paid data access still apply. No extra database searches or external AI calls are made.
curl "https://api.planwire.io/v1/applications?postcode=LS1&include=supplier&limit=25" \ -H "X-API-Key: YOUR_API_KEY"
classification: conservative description rules for new housing, extensions, industrial development, demolition and refurbishment. Each match includes an exact evidence span and source URL where available. Multiple categories can apply; unmatched descriptions stay unclassified.quantities: explicit numeric dwelling mentions and labelled floor-area mentions. Ranges, approximate values, upper bounds and existing/historical contexts are retained. Word-only quantities are not extracted. Mentions are not project totals, net additions or material quantities.history: explicit labelled references in the description, resolved only against records in the returned response and the current council. Reference formats outside the pilot parser remain unlinked. Same-address and UPRN matches are not merged. Recorded dates are not a complete revision history.territory: source postcode/council matches or the existing PostGIS distance from a nearby search. This is geographic relevance, not buying intent. Classifications are not server-side search filters.
Planning permission does not establish that construction has started or that a supplier order exists. The pilot schema is versioned as pilot-1; verify classifications and source evidence before operational use.
/v1/councils
List council source records and freshness metadata. Paid plans receive full coverage; free keys receive a limited preview.
{ "data": [{ "id": "adu", "name": "Adur District Council", "portalType": "official", "applicationCount": 874 }] }
/v1/councils/:id
Get a single council's details including the date of the most recent application.
curl "https://api.planwire.io/v1/councils/adu" \ -H "X-API-Key: YOUR_KEY"
/v1/webhooks
List all webhooks registered to your API key.
/v1/webhooks
Subscribe to planning application events. You'll receive an HTTP POST to your URL whenever a matching application is new or updated. Developer keys can create one active webhook scoped to a council or postcode prefix. Production supports up to 100 webhooks, including national coverage; Business supports national webhook workflows subject to fair use.
Request Body
SW1)curl -X POST "https://api.planwire.io/v1/webhooks" \ -H "X-API-Key: YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.com/hooks/planning", "filters": { "postcodePrefix": "SW1", "status": "Approved" } }'
Webhook Payload
Requests are signed with HMAC-SHA256 — verify using the X-PlanWire-Signature header.
{ "event": "application.new", // or "application.updated" "timestamp": "2025-03-19T14:23:01Z", "id": "stable-event-uuid", "cursor": "12345", "changedFields": ["documents"], "data": { /* captured application fields, including councilId, reference and documents */ } }
The application.updated event is emitted when PlanWire stores a changed record, including document links or titles when the authority source supplies a changed non-empty document list.
Verifying signatures
Compute HMAC-SHA256(secret, rawBody) and compare to the X-PlanWire-Signature header value (after stripping the sha256= prefix).
import { createHmac } from 'crypto'; function verify(secret, rawBody, sigHeader) { const expected = 'sha256=' + createHmac('sha256', secret) .update(rawBody).digest('hex'); return sigHeader === expected; }
import hmac, hashlib def verify(secret, raw_body, sig_header): expected = 'sha256=' + hmac.new( secret.encode(), raw_body, hashlib.sha256 ).hexdigest() return hmac.compare_digest(sig_header, expected)
/v1/webhooks/:id
Remove a webhook by its UUID.
/v1/webhooks/:id/test
Send a test event to verify your endpoint is reachable and your signature verification works.
curl -X POST "https://api.planwire.io/v1/webhooks/YOUR_WEBHOOK_ID/test" \ -H "X-API-Key: YOUR_KEY"
/v1/webhooks/:id/deliveries
View the last 50 delivery attempts for a webhook — useful for debugging failed deliveries.
{ "data": [{ "id": "a3f2...", "event": "application.new", "statusCode": 200, "success": true, "createdAt": "2025-03-19T14:23:01Z" }] }
/v1/webhooks/:id/missed
List failed delivery attempts since an ISO date. This is an attempt log, not the recovery cursor. Durable events retry with increasing delays for up to 24 hours and remain recoverable after the retry window. Use the events endpoint below to recover complete recorded payloads.
curl "https://api.planwire.io/v1/webhooks/YOUR_WEBHOOK_ID/missed?since=2026-09-01T00:00:00Z" \ -H "X-API-Key: YOUR_KEY"
Recoverable planning monitoring
GET /v1/webhooks/:id/events?after=0&limit=500 returns captured events in committed cursor order. Store the entire page, then persist meta.nextCursor. Continue while meta.hasMore is true. Deduplicate by event ID; a webhook arriving is not proof that earlier events were received.
Document-only changes emit application.updated once captured. The stream begins with durable capture for your subscription; it cannot recreate earlier missed changes or guarantee that every council document is available. Events are retained for at least 30 days; deleting the webhook removes its recovery stream. Recovery API requests count against your allowance; webhook deliveries do not.
Scoped evaluation and recovery guide · Runnable Bun receiver · Persistent cache and signature verification
Developer includes one council/postcode-scoped webhook. For a scoped paid evaluation or export, send your councils or postcode areas and intended usage to hello@planwire.io. No free live-data offers or automatic billing.
Use sourceHealth on the councils endpoint to distinguish the latest audit status from application freshness. An empty flood result is unavailable screening, not proof of low risk.
Ready to build?
Get an API key →