Property Change Alerts API

Watch properties by UPRN and get a signed webhook when something about them changes.

New salesNew EPCsCompany ownershipAddress changes2 credits per property per month

How it works

  1. Register an https webhook URL. Store the signing secret from the response: it is shown once.
  2. Watch each property by UPRN. Find a UPRN with the address autocomplete or UPRN lookup APIs.
  3. 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.
  4. Check the signature, store the event ID, and respond with any 2xx status within 10 seconds.
Each watched property costs 2 credits when you add it and 2 credits each month after that. If your account runs out of credits or your API plan ends, watches pause and resume on their own once credits or a plan are back. A paused watch gets no webhooks and its events are not listed. Trial accounts can watch up to 25 properties. Webhooks, events and test sends are free.

Event types

TypeSourceWhen it is sent
sale.recordedHM Land Registry Price Paid DataA new sale is published. Checked weekly; Land Registry publishes monthly, often weeks after completion.
sale.correctedHM Land Registry Price Paid DataLand Registry corrects a sale it already published.
epc.lodgedGOV.UK EPC registerA new domestic EPC is lodged. Each property is checked about once a week.
ownership.changedHM 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.changedOS AddressBaseThe address or postcode for the UPRN changes. Monthly.
address.retiredOS AddressBaseThe 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

MethodEndpointPurposeCredits
POST/api/v1/watchesWatch a UPRN.2 a month
GET/api/v1/watchesList 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/eventsRecent events, newest first. Optional since, type, uprn and limit. Defaults to the last 90 days.0
POST/api/v1/webhooksRegister a webhook URL. Up to 10 per account.0
GET/api/v1/webhooksList 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}/testSend a signed webhook.test event now.0
POST/api/v1/webhooks/{webhookId}/rotate-secretReplace 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:

HeaderValue
webhook-idDelivery ID. The same on every retry, so use it to ignore duplicates.
webhook-timestampUnix time in seconds when the request was signed.
webhook-signaturev1, 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

HTTPCodeMeaning
400INVALID_UPRNuprn is not 1 to 12 digits.
400WEBHOOK_URL_NOT_HTTPSThe webhook URL does not use https.
400WEBHOOK_URL_PRIVATEThe URL points to, or resolves to, a private, local or reserved address.
400INVALID_EVENT_TYPESeventTypes is empty or has an unknown type.
402INSUFFICIENT_CREDITSThe account cannot pay for the first month of a new watch.
403TRIAL_WATCH_LIMITTrial accounts can watch up to 25 properties.
404UPRN_NOT_FOUNDThe UPRN is not in AddressBase.
422UPRN_HAS_NO_POSTCODEThe UPRN has no valid postcode, so its changes cannot be tracked. No credits are charged.
429WEBHOOK_TEST_RATE_LIMITEDA test event was sent to this webhook in the last 30 seconds.
409WATCH_LIMIT_REACHEDThe account already watches 10,000 properties.
409WEBHOOK_LIMIT_REACHEDThe 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 headerMeaning
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"
    }
  }
}