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.
Beta / dark-shipped. Discovery (Plan 60) is gated behind
DISCOVERY_API_ENABLEDand Usage (Plan 61) behindUSAGE_API_ENABLED; while off they return404. Both operations arex-internaland not yet in the public reference. Usage is GA-ready and may be enabled independently. Ask your WOHNO contact to enable the flags you need.
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. - The
DISCOVERY_API_ENABLEDand/orUSAGE_API_ENABLEDflags enabled.
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. |
NOT_FOUND | 404 | The relevant feature flag is off | Ask to enable DISCOVERY_API_ENABLED/USAGE_API_ENABLED. |
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.