API reference
REST API for Dutch rental listings, with signed webhooks for new matches. Base URL: https://woning.me/api/developer/v1
Getting started
The WoningMe Developer API gives you programmatic access to rental listings across the Netherlands, both private-sector and housing-association/public-sector, scoped to search filters you define, with optional webhook notifications when new listings match.
- Create a developer account. The same account works for the WoningMe app.
- Open your dashboard and click "Generate API key". Copy it immediately, it's shown once.
- Call
GET /listingsfor listings right away, with no setup, or create a search filter and set one webhook URL to be told about every new match.
Building with an AI assistant? , read Build with AI, or connect the MCP server.
A machine-readable OpenAPI spec is also available, for client generators, Postman/Insomnia import, and similar tooling.
402.
Authentication
Every request must include your API key in the X-Api-Key header:
curl https://woning.me/api/developer/v1/searches \ -H "X-Api-Key: YOUR_API_KEY"
Requests are rate limited to 30 per minute per API key. Exceeding this returns 429.
Limits
Five general safeguards apply to keep the API reliable for everyone:
- A search filter's
radius_kmmay not exceed 50 km. Creating or updating a search filter with a larger radius returns422. - An account may have at most 10 search filters. Creating one more returns
403. - The Rental properties endpoint returns at most 50 properties per request by default (pass
limitto request fewer; requesting more than 50 returns422). - The Rental properties endpoint may be called at most 5,000 times per rolling 24 hours and 50,000 times per rolling 30 days, per API key. Exceeding either returns
429. - Creating, updating, or deleting a search filter is a "mutation": at most 20 per rolling minute and 50 per rolling 24 hours, per API key. Exceeding either returns
429.
These are general safeguards against misuse, not restrictions on legitimate use. Get in touch if your use case needs higher limits.
Using the API efficiently
The API is a shared service. Following these rules keeps it fast for everyone and keeps you well inside your limits. They apply to scripts, apps and AI assistants alike.
- For new listings, use a webhook instead of polling (PUT /webhook after creating saved searches).
- If you must poll, do it at most once every 5 minutes per query, and ask only for what is new: set listed_since to the time of your last check (ISO date or datetime) with sort=first_seen_at, order=desc.
- Be specific. Always set city and the filters you care about (max_price, min_rooms, property_type), and keep limit small. Do not page through all results: use total from the first response to know how many exist, and stop when you have enough.
- Never repeat an identical query within a minute: answers are cached for 60 seconds, and a response with a notice field means you are repeating yourself.
- Keep your own copy. Store results and reuse them instead of asking again for the same listings.
- Send requests one after another, not in parallel bursts, and prefer one well-filtered request over many near-identical ones.
- Watch the X-RateLimit-Remaining header. On HTTP 429 wait for the Retry-After seconds and then back off (double the wait on each repeated 429).
- Page with cursor, not skip. Do not create a saved search for every question: saved searches (max 10) exist for webhooks. Do not regenerate your key or send test events in a loop.
Every response from an API-key route carries X-RateLimit-Limit and X-RateLimit-Remaining (requests left in the current minute). Errors have a hint that says how to fix them, and a rate-limit error has Retry-After.
Listing details (planned)
Listing details (all images, the full description) are not available through the API yet. When a details endpoint is added, these rules apply:
- Request the images of a listing only when a user is viewing that listing's detail page, one listing per user action.
- Request the description of a listing only when a user is viewing that listing's detail page, one listing per user action.
- Do not fetch details periodically, in a loop, in bulk, or in advance for a list of listings, and do not poll or refresh them on a schedule. This is not allowed.
- Until then: show the main image (imageUrl) and link to the original listing (url) for everything else.
This endpoint does not exist yet. The rules are published in advance so you and your AI assistant can build with them from the start.
Search listings
GET /api/developer/v1/listings
The quickest way to get listings: one call, no saved search needed. Combine any of the filters below, sort, and page with a cursor. Use search filters when you want a webhook for new matches.
| Query param | Default | Notes |
|---|---|---|
city | none | Dutch city. Forgiving: case, accents, aliases (den haag) and small typos. The response's resolvedCity shows the match. Without radius_km it matches the city name. |
radius_km | none | Radius around city, up to 50 km. |
min_price, max_price | none | Whole euros per month. |
min_rooms, min_surface | none | Minimum rooms, minimum m². |
property_type, source | none | For example apartment, house, studio, room; or one source. |
is_free_market, is_social_housing, is_middenhuur | all | Segment flags. |
listed_since | none | ISO date: only listings first seen on or after it. |
sort, order | first_seen_at, desc | Newest first by default. Sort fields as for Rental properties. |
limit, cursor | 50 | Max 50 per request; pass nextCursor back as cursor. |
fuzzy | true | false uses the city exactly as typed. |
curl "https://woning.me/api/developer/v1/listings?city=utrecht&max_price=1500&property_type=apartment&limit=20" \ -H "X-Api-Key: YOUR_API_KEY"
The response has the same shape as Rental properties plus resolvedCity. It counts towards the same daily and monthly property-request limits.
Search filters
A search filter defines a city, radius, housing segments, and optional price/rooms/surface minimums/maximums. You can't query rental properties directly: every property lookup and webhook is scoped to a search filter you've created first. The endpoint path is /searches; "search filter" is just what we call it in these docs.
POST /api/developer/v1/searches
Creates a new search filter. webhook_url is optional and normally not needed: new listings for all your search filters are sent to your account webhook. Set it only if this one filter must go to its own URL.
By default a new search filter matches all three housing segments (private-sector, housing-association, and mid-market rent). Set is_free_market / is_social_housing / is_middenhuur explicitly to narrow it to just the segment(s) you want, for example is_social_housing: true alone returns only housing-association listings.
| Field | Type | Required | Notes |
|---|---|---|---|
label | string | yes | A name you choose for this search filter. |
city | string | yes | Dutch city or municipality name. |
radius_km | number | yes | Search radius in km from the city center. Max 50 km, see Limits. |
is_free_market | boolean | no | Include private-sector (vrije sector) listings. Segment flags are independent; see note above. |
is_social_housing | boolean | no | Include housing-association / public-sector (corporatie, sociale huur) listings. |
is_middenhuur | boolean | no | Include mid-market rent (middenhuur) listings. |
min_price | integer | no | Minimum monthly rent in whole euros. Omit for no minimum. |
max_price | integer | no | Maximum monthly rent in whole euros. Omit for no maximum. |
min_rooms | integer | no | Minimum number of rooms. Omit for no minimum. |
min_surface | integer | no | Minimum floor area in square meters. Omit for no minimum. |
webhook_url | string | no | Optional own webhook URL for this filter only (replaces the account webhook for it). Omit to use your account webhook. |
curl -X POST https://woning.me/api/developer/v1/searches \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"label": "Amsterdam centrum", "city": "Amsterdam", "radius_km": 5.0, "is_social_housing": true, "min_price": 700, "webhook_url": "https://your-app.com/hooks/amsterdam"}'
201 Created, with a Location header pointing at the new search filter:
{
"id": 42,
"label": "Amsterdam centrum",
"city": "Amsterdam",
"radiusKm": 5.0,
"createdAt": "2026-07-27T20:00:00+00:00",
"propertiesUrl": "/api/developer/v1/searches/42/properties",
"isFreeMarket": false,
"isSocialHousing": true,
"isMiddenhuur": false,
"minPrice": 700,
"maxPrice": null,
"minRooms": null,
"minSurface": null,
"webhookUrl": "https://your-app.com/hooks/amsterdam",
"webhookSecret": "a3f9c2e1..."
}
maxPrice, minRooms, and minSurface are null when no filter was set for that field. webhookSecret is generated the first time you set a webhook_url and stays the same across later changes to the URL. See Webhooks for how to use it.
GET /api/developer/v1/searches
Lists all of your search filters.
curl https://woning.me/api/developer/v1/searches \ -H "X-Api-Key: YOUR_API_KEY"
{
"searches": [
{ "id": 42, "label": "Amsterdam centrum", "city": "Amsterdam", "radiusKm": 5.0, ... }
]
}
GET /api/developer/v1/searches/{id}
Fetches a single search filter by ID. Returns 404 if it doesn't exist.
curl https://woning.me/api/developer/v1/searches/42 \ -H "X-Api-Key: YOUR_API_KEY"
PATCH /api/developer/v1/searches/{id}
Updates a search filter. Only fields you include are changed. Omit a field to leave it as-is. Send an empty string for webhook_url to remove the filter's own webhook (this also clears its secret); the filter then uses your account webhook again.
There is currently no way to clear a price/rooms/surface filter back to unset via PATCH once set; delete and recreate the search filter instead.
curl -X PATCH https://woning.me/api/developer/v1/searches/42 \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"radius_km": 7.5}'
DELETE /api/developer/v1/searches/{id}
Deletes a search filter. Returns 204 No Content.
curl -X DELETE https://woning.me/api/developer/v1/searches/42 \ -H "X-Api-Key: YOUR_API_KEY"
Rental properties
GET /api/developer/v1/searches/{id}/properties
Returns rental properties matching that search filter's city, radius, and segments, further narrowed by any of the query params below. All query params are optional and combine with each other and with the search filter, for example ?source=nederwoon&property_type=apartment&max_price=1500 returns only Nederwoon apartments at or under €1,500. The geographic and segment scope always comes from the search filter and can't be widened per request; min_price/max_price/min_rooms/min_surface can only narrow the search filter's own values further, not loosen them.
| Query param | Default | Notes |
|---|---|---|
source | none | filter to one source, e.g. nederwoon |
property_type | none | filter to one type, e.g. apartment, house, studio, room |
min_price | none | whole euros; combines with the search filter's own min_price, the stricter of the two wins |
max_price | none | whole euros; combines with the search filter's own max_price, the stricter of the two wins |
min_rooms | none | combines with the search filter's own min_rooms, the stricter of the two wins |
min_surface | none | m²; combines with the search filter's own min_surface, the stricter of the two wins |
sort | price | price, surface, rooms, available, city, last_synced_at, first_seen_at |
order | asc | asc or desc |
limit | your account's max (50 for standard developer accounts) | max 1000 |
cursor | none | opaque cursor from the previous response's nextCursor; omit for the first page |
Results are paginated with cursor: the first request omits it, and each response's nextCursor is passed back as the cursor query param to fetch the next page. nextCursor is null once there are no more results. This stays correct even if listings are added or removed between requests, which offset-based pagination (a fixed skip count) can't guarantee.
curl "https://woning.me/api/developer/v1/searches/42/properties?source=nederwoon&property_type=apartment&max_price=1500" \ -H "X-Api-Key: YOUR_API_KEY"
{
"total": 274,
"limit": 2,
"properties": [
{
"id": 19022,
"source": "nederwoon",
"sourceLabel": "Nederwoon",
"url": "https://...",
"imageUrl": "https://...",
"street": "Kerkstraat 12",
"postcode": "1234AB",
"city": "Amsterdam",
"propertyType": "Apartment",
"constructionType": null,
"surfaceM2": 75,
"rooms": 3,
"availableFrom": null,
"priceEurCents": 145000,
"priceText": "€ 1.450",
"reactions": null,
"agent": "Some Agency",
"agentWebsite": "https://...",
"agentPhone": null,
"socialHousing": false,
"isHousingAssociation": false,
"isMiddenhuur": false,
"assignmentType": null,
"neighbourhoodScore": { "score": 6.8, "class": "ruim voldoende", "year": 2024, "dimensions": {...} },
"lat": 52.37,
"lon": 4.90,
"firstSeenAt": "2026-07-27T20:20:00+00:00",
"lastSyncedAt": "2026-07-27T20:20:00+00:00"
}
],
"nextCursor": "eyJ2IjoxNDUwMDAsImlkIjoxOTAyMn0="
}
Many fields are nullable, since sources don't all publish the same data: constructionType, availableFrom, reactions, agentWebsite, agentPhone, and assignmentType are frequently null; imageUrl, street, postcode, surfaceM2, rooms, priceEurCents, agent, lat, and lon are null when the source didn't publish that field or a listing couldn't be geocoded; neighbourhoodScore is null when unavailable for that postcode.
Webhooks
Set one webhook URL for your whole account and we send an HTTP POST to it for every new listing that matches any of your search filters. No polling required, and no URL per filter: each message tells you which filter(s) it belongs to.
Payload
POST https://your-app.com/hooks/woningme
Content-Type: application/json
X-WoningMe-Event: listing.new
X-WoningMe-Delivery: 1234
X-WoningMe-Signature: sha256=<hex digest>
{
"event": "listing.new",
"searches": [
{"id": 42, "label": "Amsterdam centrum"},
{"id": 57, "label": "Utrecht < 1500"}
],
"property": { /* same shape as the Rental properties endpoint above */ }
}
A listing that matches several of your filters is sent once; searches lists every filter it matched (use id to route it in your own system, label is the name you gave the filter). X-WoningMe-Delivery is stable across the retry, so you can use it to ignore duplicates.
Set up your webhook
| Endpoint | Purpose |
|---|---|
PUT /api/developer/v1/webhook | Set or change the URL: {"url": "https://your-app.com/hooks/woningme"}. The response contains your signing secret, created the first time and kept when you change the URL. |
GET /api/developer/v1/webhook | Read the URL and secret (404 if none is set). |
POST /api/developer/v1/webhook/test | Send a signed webhook.test event to your URL and see whether it answered with a 2xx. |
POST /api/developer/v1/webhook/rotate-secret | Create a new signing secret. Deliveries use it immediately. |
GET /api/developer/v1/webhook/deliveries | Recent deliveries, newest first, each with the searchIds it matched. |
DELETE /api/developer/v1/webhook | Remove the webhook. |
curl -X PUT https://woning.me/api/developer/v1/webhook \
-H "X-Api-Key: YOUR_API_KEY" -H "Content-Type: application/json" \
-d '{"url": "https://your-app.com/hooks/woningme"}'
You can also manage all of this in your dashboard. All search filters of your account count, including ones created from the dashboard.
Own webhook for a single filter (optional)
A search filter can still have its own webhook_url. That filter is then delivered only to its own URL, in the older one-filter format ({"event": "listing.new", "searchId": 42, "search": {"id": 42, "label": "..."}, "property": {...}}), signed with that filter's own webhookSecret, and is left out of the account webhook messages. Existing integrations keep working unchanged.
Verifying the signature
The X-WoningMe-Signature header is an HMAC-SHA256 digest of the raw request body, using your webhook's secret as the key (for a filter's own webhook: that filter's webhookSecret). Always verify it before trusting a payload.
# Python
import hashlib, hmac
def verify(request_body: bytes, signature_header: str, webhook_secret: str) -> bool:
expected = "sha256=" + hmac.new(
webhook_secret.encode(), request_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature_header)
// Node.js
const crypto = require("crypto");
function verify(rawBody, signatureHeader, webhookSecret) {
const expected = "sha256=" + crypto
.createHmac("sha256", webhookSecret)
.update(rawBody)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));
}
Retries
If your endpoint doesn't respond with a 2xx status (or times out), we retry once after about 30 seconds, then give up. Check delivery history to see what happened (the account webhook has its own /webhook/deliveries endpoint).
MCP server
Let an AI assistant use the API directly. The MCP server at https://woning.me/mcp (Streamable HTTP) offers tools to search rentals, manage saved searches and set the webhook. It uses your API key (X-Api-Key or Authorization: Bearer) and the same billing and limits as the REST API.
claude mcp add --transport http woningme https://woning.me/mcp \ --header "X-Api-Key: YOUR_API_KEY"
Setup for Cursor and other clients, the list of tools and examples: MCP server guide.
Deliveries
GET /api/developer/v1/searches/{id}/deliveries
Returns the recent webhook delivery attempts for a search filter. Useful for debugging your endpoint.
curl https://woning.me/api/developer/v1/searches/42/deliveries \ -H "X-Api-Key: YOUR_API_KEY"
{
"deliveries": [
{
"id": 501,
"propertyId": 19022,
"status": "success",
"httpStatus": 200,
"error": null,
"attemptCount": 1,
"attemptedAt": "2026-07-27T20:20:49+00:00"
}
]
}
status is one of success, pending_retry, or failed.
Errors
| Status | Meaning |
|---|---|
401 | Missing or invalid API key. |
402 | No active subscription. Set up billing in the dashboard; new accounts get a 2-day trial. |
403 | Maximum of 10 search filters per account reached. |
404 | The search filter doesn't exist. |
422 | Request body failed validation (missing/invalid field), a search filter's radius exceeds 50 km, or a limit over your account's maximum properties per request. |
429 | Rate limit exceeded: 30 requests per minute overall; for Rental properties, also 5,000 per 24 hours / 50,000 per 30 days; for search-filter mutations, also 20 per minute / 50 per 24 hours. All per API key. |
Errors follow the shape {"detail": "..."}.