Property Change Alerts API
Watch properties by UPRN and get a signed webhook when something about them changes.
How it works
- Register an https webhook URL. Store the signing secret from the response: it is shown once.
- Watch each property by UPRN. Find a UPRN with the address autocomplete or UPRN lookup APIs.
- When our monthly and weekly data updates find a change for a watched UPRN, we POST a signed event to every webhook on your account.
- Check the signature, store the event ID, and respond with any 2xx status within 10 seconds.
Event types
| Type | Source | When it is sent |
|---|---|---|
| sale.recorded | HM Land Registry Price Paid Data | A new sale is published. Checked weekly; Land Registry publishes monthly, often weeks after completion. |
| sale.corrected | HM Land Registry Price Paid Data | Land Registry corrects a sale it already published. |
| epc.lodged | GOV.UK EPC register | A new domestic EPC is lodged. Each property is checked about once a week. |
| ownership.changed | HM Land Registry UK companies that own property (CCOD) | A UK company becomes an owner (added), the company owners change (changed), or no company owns the title any more (removed). Monthly. |
| address.changed | OS AddressBase | The address or postcode for the UPRN changes. Monthly. |
| address.retired | OS AddressBase | The UPRN is removed from AddressBase, for example after demolition. Monthly. |
Land Registry sale and company ownership records do not carry a UPRN, so we match them to the watched address within the same postcode. House numbers and flat numbers or letters must agree exactly, so a sale of a whole building does not match a watch on one of its flats. Each event includes data.match showing the address it was matched to. More event types will be added; if a webhook has no eventTypes filter it receives new types automatically, so ignore types you do not recognise.
Endpoints
| Method | Endpoint | Purpose | Credits |
|---|---|---|---|
| POST | /api/v1/watches | Watch a UPRN. | 2 a month |
| GET | /api/v1/watches | List watches. Optional status, limit (1 to 200) and offset. | 0 |
| DELETE | /api/v1/watches/{watchId} | Stop watching. The current month is not refunded. | 0 |
| GET | /api/v1/watches/events | Recent events, newest first. Optional since, type, uprn and limit. Defaults to the last 90 days. | 0 |
| POST | /api/v1/webhooks | Register a webhook URL. Up to 10 per account. | 0 |
| GET | /api/v1/webhooks | List webhooks and their status. Secrets are never listed. | 0 |
| DELETE | /api/v1/webhooks/{webhookId} | Delete a webhook and discard its queued events. | 0 |
| POST | /api/v1/webhooks/{webhookId}/test | Send a signed webhook.test event now. | 0 |
| POST | /api/v1/webhooks/{webhookId}/rotate-secret | Replace the signing secret. The old secret stops working straight away. | 0 |
All routes need the x-api-key header and only see the watches and webhooks of that API account.
Register a webhook
POST /api/v1/webhooks
{
"url": "https://hooks.example.com/property-alerts",
"eventTypes": ["sale.recorded", "ownership.changed"],
"description": "Portfolio monitoring"
}url must use https, use port 443 or a port from 1024 to 65535, and resolve to a public internet address. Leave out eventTypes to receive every type.
{
"success": true,
"data": {
"id": "WEBHOOK_UUID",
"url": "https://hooks.example.com/property-alerts",
"description": "Portfolio monitoring",
"eventTypes": ["sale.recorded", "ownership.changed"],
"status": "active",
"disabledReason": null,
"lastSuccessAt": null,
"createdAt": "2026-10-26T09:30:00.000Z",
"secret": "whsec_BASE64_SECRET"
}
}Watch a property
POST /api/v1/watches
{
"uprn": "100000000001",
"label": "Portfolio ref 12"
} Returns 201 and charges 2 credits for a new watch. Watching a UPRN you already watch returns 200 with created: false and no charge.
{
"success": true,
"data": {
"id": "WATCH_UUID",
"uprn": "100000000001",
"label": "Portfolio ref 12",
"address": "12 EXAMPLE STREET, EXAMPLETON, EX1 2AB",
"postcode": "EX1 2AB",
"status": "active",
"pausedReason": null,
"nextChargeAt": "2026-11-26T09:31:00.000Z",
"createdAt": "2026-10-26T09:31:00.000Z",
"created": true
},
"billing": {
"mode": "prepaid",
"creditsCharged": 2,
"creditsRemaining": 1998,
"creditsRefreshAt": "2026-11-14T09:30:00.000Z"
}
}Webhook requests
Each event is a JSON POST with these headers, following the Standard Webhooks scheme:
| Header | Value |
|---|---|
| webhook-id | Delivery ID. The same on every retry, so use it to ignore duplicates. |
| webhook-timestamp | Unix time in seconds when the request was signed. |
| webhook-signature | v1, then the base64 HMAC-SHA256 of webhook-id.webhook-timestamp.body, keyed with the base64-decoded part of your secret after whsec_. |
Example event
{
"id": "EVENT_UUID",
"type": "sale.recorded",
"timestamp": "2026-10-27T05:12:44.000Z",
"data": {
"uprn": "100000000001",
"watch": { "id": "WATCH_UUID", "label": "Portfolio ref 12" },
"occurredAt": "2026-09-18T00:00:00.000Z",
"transactionId": "4E1A0F53-0000-4C1B-8E3A-000000000001",
"price": 385000,
"transactionDate": "2026-09-18",
"propertyType": "terraced",
"newBuild": false,
"tenure": "freehold",
"category": "A",
"address": { "saon": null, "paon": "12", "street": "EXAMPLE STREET", "town": "EXAMPLETON", "postcode": "EX1 2AB" },
"match": { "method": "address", "watchedAddress": "12 EXAMPLE STREET, EXAMPLETON, EX1 2AB" }
}
}ownership.changed data has change, titleNumber, tenure, propertyAddress, proprietors, previousProprietors, dateProprietorAdded and pricePaid. epc.lodged data has certificateNumber, lodgedAt, inspectionDate, rating and previousCertificateNumber. address.changed data has address, postcode, previousAddress and previousPostcode.
Verify the signature (Node.js)
import crypto from 'node:crypto'
// rawBody must be the exact request body string, before JSON parsing.
export function verifyPropertyAlert(secret, headers, rawBody) {
const id = headers['webhook-id']
const timestamp = headers['webhook-timestamp']
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
const expected = 'v1,' + crypto.createHmac('sha256', key)
.update(id + '.' + timestamp + '.' + rawBody)
.digest('base64')
return String(headers['webhook-signature']).split(' ').some(signature =>
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)))
}Retries
Any 2xx response within 10 seconds counts as delivered. Redirects are not followed. Other responses and timeouts are retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours, 24 hours and 24 hours, about 3 days in total. A webhook that keeps failing for 3 days without a successful delivery, or that answers 410 Gone, is disabled; delete it and register it again once it is fixed. Events stay available from GET /api/v1/watches/events, so you can catch up on anything a webhook missed.
Errors
| HTTP | Code | Meaning |
|---|---|---|
| 400 | INVALID_UPRN | uprn is not 1 to 12 digits. |
| 400 | WEBHOOK_URL_NOT_HTTPS | The webhook URL does not use https. |
| 400 | WEBHOOK_URL_PRIVATE | The URL points to, or resolves to, a private, local or reserved address. |
| 400 | INVALID_EVENT_TYPES | eventTypes is empty or has an unknown type. |
| 402 | INSUFFICIENT_CREDITS | The account cannot pay for the first month of a new watch. |
| 403 | TRIAL_WATCH_LIMIT | Trial accounts can watch up to 25 properties. |
| 404 | UPRN_NOT_FOUND | The UPRN is not in AddressBase. |
| 422 | UPRN_HAS_NO_POSTCODE | The UPRN has no valid postcode, so its changes cannot be tracked. No credits are charged. |
| 429 | WEBHOOK_TEST_RATE_LIMITED | A test event was sent to this webhook in the last 30 seconds. |
| 409 | WATCH_LIMIT_REACHED | The account already watches 10,000 properties. |
| 409 | WEBHOOK_LIMIT_REACHED | The account already has 10 webhooks. |
Billing details in the response
Successful chargeable JSON responses include a top-level billing object. The endpoint examples on this page focus on the endpoint-specific data, so this repeated block may not be shown in every example.
"billing": {
"mode": "prepaid",
"creditsCharged": 1,
"creditsRemaining": 1999,
"creditsRefreshAt": "2026-08-14T09:30:00.000Z"
}| Field or header | Meaning |
|---|---|
| billing.creditsCharged X-Credits-Charged | Credits charged by this call. Failed and non-chargeable calls return 0 in the header. |
| billing.creditsRemaining X-Credits-Remaining | The credit balance after the call. |
| billing.creditsRefreshAt X-Credits-Refresh-At | The next monthly credit refresh as an ISO 8601 timestamp. Trial and non-renewing balances return null and omit the header. |
All authenticated API-key calls expose the billing headers, including validation errors and zero-credit status or management requests. Only successful chargeable JSON responses add the billing object to the response body.
When credits run out
If your balance is lower than the cost of a request, the API returns HTTP 402 and charges nothing. The error includes the credits needed, your balance and a link to change plan.
{
"statusCode": 402,
"statusMessage": "Payment Required",
"data": {
"success": false,
"error": {
"code": "INSUFFICIENT_CREDITS",
"message": "This request costs 35 API credits. Upgrade your plan or wait for your monthly refresh: https://propertyinsights.co.uk/billing",
"required": 35,
"available": 12,
"upgradeUrl": "https://propertyinsights.co.uk/billing"
}
}
}