# Public Listings Search API

> Read-only, anonymous, cursor-paginated search over active public real-estate listings on leiakv.ee. No API key required. Machine-readable OpenAPI 3.1 description at https://leiakv.ee/api/public/v1/openapi.json.

## Endpoints

GET https://leiakv.ee/api/public/v1/listings          — search listings
GET https://leiakv.ee/api/public/v1/listings/{id}     — fetch a single listing

## Search Query Parameters

| Param              | Values                                                              | Notes                                             |
|--------------------|--------------------------------------------------------------------|---------------------------------------------------|
| `dealType`         | `sale`, `rent`, `shortterm`                                       | Single value                                      |
| `propertyType`     | `house,apartment,townhouseBox,partOfHouse,summerHouse,garage,land,commercialSpace` | Comma-separated; every value must be valid        |
| `countySlug`       | EHAK county slug                                                   | Required for `municipalitySlug`                   |
| `municipalitySlug` | EHAK municipality slug                                             | Requires `countySlug`                             |
| `settlementSlug`   | EHAK settlement slug                                               | Requires `municipalitySlug`                       |
| `settlements`      | Comma-separated settlement/district slugs                          | Select several areas at once                      |
| `abroad`           | `only`, `include`                                                 | Omit to exclude foreign (non-Estonian) listings   |
| `country`          | Comma-separated ISO 3166-1 alpha-2 codes                          | Only meaningful with `abroad=only` or `include`   |
| `city`             | Comma-separated foreign city slugs                                | Only meaningful with `abroad=only` or `include`   |
| `minPrice` / `maxPrice`   | integer (EUR)                                             |                                                   |
| `minRooms` / `maxRooms`   | integer                                                  |                                                   |
| `minSize` / `maxSize`     | number (m²)                                              |                                                   |
| `minFloor` / `maxFloor`   | integer                                                  | The listing's own floor                           |
| `minFloors` / `maxFloors` | integer                                                  | Storeys in the building                           |
| `topFloor`         | `only`, `exclude`                                                | `exclude` keeps listings with no storey count     |
| `minPlotArea` / `maxPlotArea` | number (m²)                                         |                                                   |
| `priceDrop`        | `1`                                                              | Only listings whose price has dropped             |
| `minYear` / `maxYear`     | integer                                                  |                                                   |
| `searchText`       | free text                                                         |                                                   |
| `sort`             | `featured`, `newest`, `oldest`, `price-asc`, `price-desc`, `price-sqm-asc`, `price-sqm-desc`, `size-asc`, `size-desc` | Default `featured` |
| `limit`            | 1–50                                                               | Default 20                                        |
| `cursor`           | opaque string                                                     | Pass `nextCursor` from the previous response      |

Unknown or out-of-range `dealType`, `propertyType`, or location slugs return `400 invalid_params`.

## Response

### GET /api/public/v1/listings

```json
{ "items": [ /* listings */ ], "nextCursor": "…" | null, "totalCount": 123 }
```

- Multilingual fields (`slogan`, `description`) carry every supported locale. Date fields are ISO 8601 strings.
- Paginate by passing `nextCursor` back as `cursor` until it is `null`.
- `400`: `{ "items": [], "nextCursor": null, "totalCount": 0, "error": "invalid_params" }`.
- `429`: `{ "error": "rate_limit_exceeded" }` — 60 requests/minute per IP. See `Retry-After`.
- `500`: `{ "items": [], "nextCursor": null, "totalCount": 0, "error": "internal_error" }`.

### GET /api/public/v1/listings/{id}

```json
{ "listing": { /* listing */ } }
```

- `404`: `{ "error": "not_found" }` — unknown ID or not publicly visible.
- `429`: `{ "error": "rate_limit_exceeded" }` — 30 requests/minute per IP.
- `500`: `{ "error": "internal_error" }`.

## Contact Info

Listing payloads deliberately omit phone and email. To resolve a seller for a listing:

GET https://leiakv.ee/api/seller/{userId}?listingId={id}

returns SellerPublicInfo — `{ displayName, hasPhone, photoURL? }` — never a raw phone number.

## Discovery

- API catalog (RFC 9727): https://leiakv.ee/.well-known/api-catalog
- AI/agent site guide: https://leiakv.ee/llms.txt

---

*This document is machine- and human-readable and is referenced as the `service-doc`
for this API in https://leiakv.ee/.well-known/api-catalog (RFC 9727).*
