Build a discovery aggregator
Aggregate public listings across organizations with cursor pagination and ETag caching, and monitor your own API usage with the usage endpoint.
This guide shows how to build a cross-organization listings aggregator feed — a portal or comparison site that pulls public listings discreetly and efficiently — and how to keep an eye on your own consumption with the usage endpoint.
The problem this solves: you run an aggregator and need a clean, paginated, cacheable feed of public listings that respects discreetly-marketed properties, plus a way to watch your monthly quota.
Generally available. Discovery (Plan 60) and Usage (Plan 61) are live and documented in the public reference — no feature flag gates them. Discovery is part of the marketplace launch gate: before the public marketplace launch, calls to
GET /discovery/listingsreturn403 LAUNCH_PENDING. Usage is not gated and is available now.
Prerequisites
- For discovery: a publishable key (
pk_live_…) with an origin allowlist —discovery:readis on the publishable whitelist — or a secret key. - For usage monitoring: a secret key (
sk_live_…) withusage:read. Usage is sk-only (business figures must not sit in a browser key); apk_key is rejected with403 INSUFFICIENT_SCOPE. - A paid plan with API access. Before the public marketplace launch, discovery
calls return
403 LAUNCH_PENDING; usage is unaffected.
Step 1 — Page through the discovery feed
Discovery returns a reduced DiscoveryListingDto. Discreetly-marketed, inactive
and blocked listings are structurally excluded, geo coordinates are rounded
to ~110 m, and street/house-number are never returned.
curl "https://wohno.de/api/v1/discovery/listings?city=Berlin&rooms_min=2&limit=50" \
-H "X-API-Key: pk_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Origin: https://aggregator.example.com"Response (200, cursor-paginated):
{
"data": [
{
"id": "9f1c2a3b-...",
"title": "Bright 2-room apartment",
"city": "Berlin",
"zip": "10115",
"rooms": 2,
"rent_max": 980,
"property_type": "apartment",
"lat": 52.53,
"lng": 13.38
}
],
"pagination": { "next_cursor": "eyJpZCI6...", "has_more": true, "limit": 50 }
}Available filters: city, zip (1–5 digits), rooms_min, rent_max,
property_type. limit is 1–50. Keep passing pagination.next_cursor as the
next ?cursor= while has_more is true.
Step 2 — Cache with ETags
Each response carries an ETag. Store it and send it back as If-None-Match to
get a cheap 304 Not Modified when nothing changed — this saves bandwidth and
counts more favorably against your quota:
curl "https://wohno.de/api/v1/discovery/listings?city=Berlin&limit=50" \
-H "X-API-Key: pk_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Origin: https://aggregator.example.com" \
-H 'If-None-Match: W/"l3-1a2b3c4d5e6f7a8b"' \
-iA 304 comes back with no body — reuse your cached page.
Step 3 — Respect discreet listings
You do not need to do anything special: discreetly-marketed, inactive and blocked
listings never appear in this feed (enforced by the public_discovery_listings
view). Do not try to cross-reference IDs with other endpoints to re-expose them.
Step 4 — Monitor your usage
Pull your own quota/consumption for the current month with a secret key:
curl "https://wohno.de/api/v1/usage" \
-H "X-API-Key: sk_live_xxxxxxxxxxxxxxxxxxxxxxxx"Response (200):
{
"data": {
"plan": "plus",
"api_requests": {
"used": 3450,
"limit": 10000,
"reset_at": "2026-07-01T00:00:00.000Z"
},
"webhook_deliveries": {
"used": 120,
"limit": 5000,
"reset_at": "2026-07-01T00:00:00.000Z"
},
"embed_impressions": {
"used": 8800,
"limit": 50000,
"reset_at": "2026-07-01T00:00:00.000Z"
}
}
}- Query a past month with
?period=YYYY-MM(Europe/Berlin). A future period returns400. - A
limitof-1means unlimited (Premium). - If the counter store (Redis) is briefly unavailable, you get a snapshot
fallback with
"stale": trueinstead of an error — treat it as approximate.
Build a simple warning: alert when used / limit >= 0.8. (The same threshold
drives the X-Quota-Status: soft-warning header on every API response.)
Error handling
| Code | HTTP | What happened | Fix |
|---|---|---|---|
VALIDATION_ERROR | 400 | limit > 50, bad zip, or future period | Fix the query parameters. |
INSUFFICIENT_SCOPE | 403 | pk_ key on /usage, or missing scope | Use a secret key with usage:read. |
ORIGIN_NOT_ALLOWED | 403 | Discovery pk_ call from a non-allowed domain | Add the domain in Settings → API. |
LAUNCH_PENDING | 403 | Discovery called before the marketplace launch | Available at launch; usage is unaffected. |
RATE_LIMITED | 429 | Discovery over 300 req / 300 s | Cache via ETags; respect Retry-After. |
See the conventions reference.
Best practices
- Cache aggressively with ETags — discovery is rate-limited to 300 requests per 300 seconds.
- Page with cursors, never offsets; cursors are stable under concurrent inserts/deletes.
- Watch the 80% line. Wire a usage alert before you hit the monthly limit.
Next steps
- Embed a WBS eligibility check — the other publishable Plan 60 endpoint.
- Embed listings on your website — your own listings vs. the aggregate feed.
- API Reference — discovery and usage DTOs.