# Lejested — for humans

Danmarks markedsplads for udlejning. Find bolig, bil, værktøj eller festudstyr tæt på dig — eller tjen penge på det, du ikke bruger. Gratis at oprette en annonce.

## The product in one paragraph

Lejested is a Danish general rental marketplace. Listers — private
people, businesses and estate agents — publish **listings** against a
**category** tree. Renters browse `/lej`, open a listing, and send an
**inquiry**. That inquiry is the conversion event: no renter ever pays on
the site, and Lejested is never a party to the rental agreement. Money
on-site is lister-side only — featured-listing boosts and business/agent
subscriptions, billed through Stripe.

## Things that trip people up

| | |
| --- | --- |
| **Conversion** | An inquiry, not a checkout. Do not wire renter-facing add-to-cart. |
| **Guest access** | `POST /api/inquiries` is public — a logged-out renter can inquire. It is rate limited per IP. |
| **Currency** | DKK everywhere. `12.400 kr`, `450 kr/dag`. Never `$`, `USD` or `toFixed(2)`. |
| **Units** | A listing's `price`/`deposit` are whole kroner. The ecommerce plugin's `priceInUSD` is a plugin field name whose value is DKK øre. |
| **Products** | A `product` is a lister-side boost or subscription plan, never something a renter buys. |
| **Locales** | `da` (default) and `en`. Public paths are the Danish words. |

## Public routes

| Path | Purpose |
| --- | --- |
| `/` | Front page — search, categories, featured listings |
| `/lej` | Browse & search, all filter state in the query string |
| `/lej/[slug]` | Listing detail and inquiry form |
| `/kategori/[slug]` | Category landing page |
| `/profil/[id]` | Public lister profile |
| `/udlej` | Rent out your things |
| `/priser` | Boost & plan pricing |
| `/saadan-virker-det` | How it works |
| `/blogs`, `/blogs/[slug]` | Guides |
| `/faq` | FAQ |
| `/om`, `/kontakt` | About, contact |
| `/vilkaar`, `/privatliv` | Terms, privacy |

The English aliases `/listings`, `/rent-out`, `/pricing`, `/how-it-works`,
`/about`, `/contact`, `/terms`, `/privacy`, `/category/*` and `/profile/*`
answer with a permanent 308 to the Danish path.

## Signed-in routes

`/konto/annoncer` (my listings) · `/konto/annoncer/ny` (create wizard) ·
`/konto/henvendelser` (inquiry inbox) · `/konto/gemte` (saved listings) ·
`/konto/[...path]` (account settings, security, billing, purchases).

## Elsewhere

- OpenAPI 3.1 spec: https://lejested-omegzs-projects.vercel.app/api/openapi.json
- Interactive docs: https://lejested-omegzs-projects.vercel.app/api/docs
- Agent-oriented summary: https://lejested-omegzs-projects.vercel.app/llms.txt

---
# Lejested Marketplace API — API Reference

Read rental listings from Lejested and send inquiries, without building a checkout: renters pay the lister, not the platform.

## Quick Start

1. **Browse categories** — `GET /api/categories` returns the tree plus the attribute schema each leaf category filters on
2. **Search listings** — `GET /api/listings` with filters (`category`, `city`, `priceMin`, `priceMax`, `priceUnit`, `from`, `to`), or `GET /api/search?q=keyword` for free text
3. **Read one listing** — `GET /api/listings/{slug}`
4. **Send an inquiry** — `POST /api/inquiries` with the listing id, a message and contact details. No account required; it is rate-limited per IP
5. **Sign in** — `POST /api/auth/sign-in/email` with `{ email, password }` to save listings, track inquiries, or publish your own
6. **Buy a boost or plan** — listers only: apply a discount with `POST /api/cart/apply-discount`, total with `POST /api/payment-amount`, pay via Stripe, then review with `GET /api/orders`

## Prices

Listing prices are whole DKK with a `priceUnit` of `hour`, `day`, `week`, `month`, `year` or `fixed` — format them as `450 kr/dag`. Order and product amounts come from Stripe and are in øre.

## Languages

Content is localised in Danish (`da`, default) and English (`en`). Pass `?locale=en` to Payload collection endpoints for English.

## Authentication

Two authentication methods are supported:

**Session Cookie** — Sign in via `POST /api/auth/sign-in/email` with `{ email, password }`. The `better-auth.session_token` cookie is set automatically.

**API Key** — Pass an `x-api-key` header with a scoped API key. Create keys at `/konto/developer` or via `POST /api/auth/api-key/create`. Keys are scoped to specific resources (listings, inquiries, saved, reviews, cart, orders).

## Error Format

All errors return `{ error: string }`. Validation errors additionally include `{ details: { fieldErrors, formErrors } }` with per-field messages.

## Table of Contents

- [Listings](#listings) — Rental listings — search, detail, and the lister's own listings.
- [Categories](#categories) — The category tree and its per-category attribute schemas, used to build both the create-listing form and the search filters.
- [Inquiries](#inquiries) — Rental requests sent from a renter to a lister, and the message thread that follows. Creating one does not require an account.
- [Saved](#saved) — A signed-in user's saved listings.
- [Contact](#contact) — Contact form submissions.
- [Newsletter](#newsletter) — Email newsletter subscriptions.
- [Reviews](#reviews) — Renter reviews and ratings of listings and listers.
- [Cart](#cart) — Cart discount management for boost and plan purchases (listers only).
- [Discounts](#discounts) — Discount code validation.
- [Payments](#payments) — Payment amount calculation for boosts and plans.
- [Orders](#orders) — A lister's purchase history for boosts and plans.
- [Search](#search) — Listing search and autocomplete.
- [Recommendations](#recommendations) — Similar and recommended listings, plus interaction tracking.

## Listings
> Rental listings — search, detail, and the lister's own listings.

### GET `/api/listings`

**Search rental listings** ` PUBLIC `

Paginated search across **published** listings. Drafts, pending, rented and archived listings are never returned here — use `GET /api/listings/mine` for a lister's own inventory.

Every filter is a query parameter so a result page is fully shareable and the browser back button restores the exact result set.

Per-category attributes are filtered with `attr.<key>` parameters — for example `?category=lejlighed&attr.rooms=3&attr.furnished=true`. The available keys come from the `attributeSchema` of the category returned by `GET /api/categories/{slug}`.

Prices are whole Danish kroner. A listing priced `450` with `priceUnit: "day"` renders as `450 kr/dag`.

Unrecognised query parameters are ignored rather than rejected, and an unknown `category` slug yields an empty page rather than a 404.

#### Parameters

| Name | In | Type | Required | Description |
|------|-----|------|----------|-------------|
| `q` | query | string | No | Free-text query matched against title, summary and city. |
| `category` | query | string | No | Category slug. Listings filed under any descendant category are included, so `?category=koeretoejer` returns cars, vans and trailers. |
| `city` | query | string | No | City the listing is located in. |
| `postalCode` | query | string | No | Danish postal code. |
| `region` | query | string | No | One of the five Danish administrative regions. |
| `priceMin` | query | integer | No | Lowest acceptable price, in whole DKK. |
| `priceMax` | query | integer | No | Highest acceptable price, in whole DKK. |
| `priceUnit` | query | string | No | Restrict to listings priced per this unit. |
| `from` | query | string | No | Start of the period the listing must be available for. Maps to `availableFrom`. |
| `to` | query | string | No | End of the period the listing must be available for. Maps to `availableTo`. |
| `condition` | query | string | No | Physical condition. Comma-separated; any of: new, like-new, good, fair. |
| `listerType` | query | string | No | Who is renting it out. Comma-separated; any of: private, business, agent. |
| `features` | query | string | No | Amenity chips the listing must carry, comma-separated. Matched against the free-form `features` list. |
| `onlyWithPhotos` | query | string | No | Only return listings that have at least one photo. |
| `featuredFirst` | query | string | No | Rank boosted listings ahead of the chosen sort. |
| `bbox` | query | string | No | Map viewport as `minLng,minLat,maxLng,maxLat` (GeoJSON order). Used by the map view on `/lej`. |
| `attr.{key}` | query | string | No | Per-category attribute filter. The key is an `attributeSchema[].key` on the listing's category; the value is compared as a display string. Repeatable across different keys. |
| `sort` | query | string | No | Result ordering. |
| `page` | query | integer | No | 1-indexed page number. |
| `limit` | query | integer | No | Results per page. Values above 48 are clamped to 48 rather than rejected. |

#### Responses

| Status | Description |
|--------|-------------|
| 200 | Paginated listing results |
| 400 | Invalid query parameters — for example a malformed date or a priceMax below priceMin |
| 500 | Internal server error |

**cURL Example:**
```bash
curl "https://lejested-omegzs-projects.vercel.app/api/listings"
```

---

### GET `/api/listings/{id}`

**Find a Listing by ID** ` PUBLIC `

#### Responses

| Status | Description |
|--------|-------------|
| 200 | Listing object |
| 404 | Listing not found |

**cURL Example:**
```bash
curl "https://lejested-omegzs-projects.vercel.app/api/listings/{id}"
```

---

### GET `/api/listings/{slug}`

**Get one listing by slug** ` PUBLIC `

Returns a single published listing with its category, photos, attributes and lister populated.

Reading a listing **increments its `viewCount`**, which is the number the detail page and the lister's dashboard show. Treat this as a write: do not call it to poll for changes.

`location.address` is only meaningful when `location.showExactAddress` is true — most listers are private people renting out of their own home, so the default is an approximate location.

`price` is whole Danish kroner and pairs with `priceUnit` (`450` + `day` → `450 kr/dag`).

#### Parameters

| Name | In | Type | Required | Description |
|------|-----|------|----------|-------------|
| `slug` | path | string | Yes | URL segment of the listing, as used by `/lej/{slug}`. |

#### Responses

| Status | Description |
|--------|-------------|
| 200 | The listing |
| 400 | The slug is not a valid slug |
| 404 | No published listing has this slug |
| 500 | Internal server error |

**cURL Example:**
```bash
curl "https://lejested-omegzs-projects.vercel.app/api/listings/{slug}"
```

---

### GET `/api/listings/{slug}/similar`

**List listings similar to one listing** ` PUBLIC `

Returns published listings comparable to the given one — same category first, then nearby location and a similar price band. Used by the 'Lignende annoncer' rail on `/lej/{slug}`.

The reference listing itself is never included. Unlike `GET /api/listings/{slug}` this endpoint does **not** increment `viewCount`, so it is safe to call on hover or prefetch.

#### Parameters

| Name | In | Type | Required | Description |
|------|-----|------|----------|-------------|
| `slug` | path | string | Yes | URL segment of the listing to find neighbours for. |
| `limit` | query | integer | No | How many similar listings to return. |

#### Responses

| Status | Description |
|--------|-------------|
| 200 | Similar listings, most relevant first |
| 400 | Invalid slug or limit |
| 404 | No published listing has this slug |
| 500 | Internal server error |

**cURL Example:**
```bash
curl "https://lejested-omegzs-projects.vercel.app/api/listings/{slug}/similar"
```

---

### GET `/api/listings/mine`

**List the authenticated user's own listings** ` AUTH REQUIRED `

Returns the caller's listings in **every** status — drafts, pending, published, rented and archived — which is what `/konto/annoncer` needs and what `GET /api/listings` deliberately never returns.

#### Parameters

| Name | In | Type | Required | Description |
|------|-----|------|----------|-------------|
| `status` | query | string | No | Only return listings in this status. |
| `q` | query | string | No | Free-text query matched against title and summary. |
| `sort` | query | string | No | Ordering. Defaults to most recently updated. |
| `page` | query | integer | No | 1-indexed page number. |
| `limit` | query | integer | No | Listings per page. |

#### Responses

| Status | Description |
|--------|-------------|
| 200 | Paginated listings owned by the caller |
| 400 | Invalid query parameters |
| 401 | Not authenticated |
| 404 | The session has no matching user record |
| 500 | Internal server error |

**cURL Example:**
```bash
curl "https://lejested-omegzs-projects.vercel.app/api/listings/mine"
```

---

### POST `/api/listings/mine`

**Create a listing** ` AUTH REQUIRED `

Creates a listing owned by the authenticated user.

**Attributes are validated against the category.** Every entry in `attributes` must match an `attributeSchema[].key` on the chosen category; `select` values must be one of that attribute's `options`, `number` values must parse as numbers, and every attribute marked `required` must be present. A mismatch is a `422` with `code: "category_mismatch"` and an `issues` array — distinct from the `400` a malformed body produces, so a client can tell 'you sent nonsense' from 'this does not fit the category you picked'.

Send attributes as `{ key, value }`. The `label` stored on the listing is snapshotted from the category, so relabelling a category later never rewrites existing listings.

**Money is DKK in whole kroner.** `price: 450` with `priceUnit: "day"` is 450 kr/dag. There are no minor units here — that is only true of the boost and plan products, which are a different collection.

`owner` is taken from the session, and `featured`, `featuredUntil`, `viewCount`, `inquiryCount` and `publishedAt` are ignored if sent: they are set by the boost fulfilment flow, the view counter, the inquiry hook and the collection's own hooks respectively.

> **Rate Limit:** 20 requests per 1 hour

#### Request Body

| Field | Type | Required | Constraints | Description |
|-------|------|----------|-------------|-------------|
| `title` | string | Yes | minLength: 3, maxLength: 160 | Headline shown on the card and detail page. |
| `slug` | string | No | pattern | URL segment. Derived from the title when omitted. Must be unique across all listings. |
| `summary` | string | No | maxLength: 200 | One or two concrete sentences, used on cards and in search. |
| `description` | object | No | — | Lexical rich-text document with a `root` node. |
| `category` | integer | Yes | min: 1 | ID of the leaf category. The category's `attributeSchema` decides which `attributes` are valid. |
| `attributes` | array | No | — | Answers to the category's attribute schema. Send `{ key, value }`; the label is snapshotted server-side from the category. |
| `images` | array | No | — | Photos in display order. The first one is the cover. |
| `price` | integer | Yes | min: 0 | Price in whole Danish kroner. 450 means 450 kr — never øre, never a decimal. |
| `priceUnit` | string | Yes | enum: hour, day, week, month, year, fixed | What the price buys. |
| `deposit` | integer | No | min: 0 | Refundable deposit in whole kroner. Omit when there is none. |
| `negotiable` | boolean | No | — | Show a 'kan forhandles' badge. |
| `minRentalPeriod` | integer | No | min: 0 |  |
| `maxRentalPeriod` | integer | No | min: 0 |  |
| `rentalPeriodUnit` | string | No | enum: hour, day, week, month |  |
| `location` | object | No | — |  |
| `availableFrom` | string | No | format: date |  |
| `availableTo` | string | No | format: date |  |
| `condition` | string | No | enum: new, like-new, good, fair |  |
| `features` | array | No | — | Free-form amenity chips. |
| `listerType` | string | Yes | enum: private, business, agent | Declared per listing, not read from the account. |
| `contactPreference` | string | No | enum: platform, phone, both |  |
| `contactPhone` | string | No | maxLength: 30 | Required when `contactPreference` is `phone` or `both`. |
| `status` | string | No | enum: draft, pending, published, rented, archived | Defaults to `draft`. Moving to `published` requires at least one photo. |

**Example:**
```json
{
  "title": "Ford Transit kassevogn — 3,5 t",
  "summary": "3,5 t kassevogn med bagsmæk. Afhentes i Åbyhøj.",
  "category": 12,
  "attributes": [
    {
      "key": "seats",
      "value": "3"
    },
    {
      "key": "fuel",
      "value": "diesel"
    }
  ],
  "images": [
    {
      "image": 41,
      "alt": "Hvid kassevogn set fra siden"
    }
  ],
  "price": 450,
  "priceUnit": "day",
  "deposit": 2000,
  "location": {
    "city": "Aarhus",
    "postalCode": "8230",
    "region": "midtjylland"
  },
  "features": [
    "Anhængertræk",
    "Bagsmæk"
  ],
  "listerType": "private",
  "status": "draft"
}
```

#### Responses

| Status | Description |
|--------|-------------|
| 201 | The created listing |
| 400 | Validation failed — a malformed body, a missing required field, or publishing without a photo |
| 401 | Not authenticated |
| 404 | The referenced category does not exist |
| 409 | A listing with this slug already exists |
| 422 | The attributes do not satisfy the selected category's attributeSchema |
| 429 | Rate limit exceeded — 20 new listings per hour per user |
| 500 | Internal server error |

**cURL Example:**
```bash
curl -X POST "https://lejested-omegzs-projects.vercel.app/api/listings/mine" \
  -H "Content-Type: application/json" \
  -d '{"title":"Ford Transit kassevogn — 3,5 t","summary":"3,5 t kassevogn med bagsmæk. Afhentes i Åbyhøj.","category":12,"attributes":[{"key":"seats","value":"3"},{"key":"fuel","value":"diesel"}],"images":[{"image":41,"alt":"Hvid kassevogn set fra siden"}],"price":450,"priceUnit":"day","deposit":2000,"location":{"city":"Aarhus","postalCode":"8230","region":"midtjylland"},"features":["Anhængertræk","Bagsmæk"],"listerType":"private","status":"draft"}'
```

---

### PATCH `/api/listings/id/{id}`

**Update a listing** ` AUTH REQUIRED `

Partially updates a listing. The caller must be the listing's owner, or an admin.

The route lives under a literal `id` segment rather than at `/api/listings/{id}` so it does not collide with the slug-addressed `GET /api/listings/{slug}`.

`category` and `attributes` must be sent together — an attribute list is only meaningful against the category whose `attributeSchema` it answers. The same per-attribute validation as `POST /api/listings/mine` applies.

`featured`, `featuredUntil`, `viewCount`, `inquiryCount`, `owner` and `publishedAt` cannot be set through this endpoint; they belong to the boost flow and the collection's own hooks.

#### Parameters

| Name | In | Type | Required | Description |
|------|-----|------|----------|-------------|
| `id` | path | integer | Yes | Numeric ID of the listing. |

#### Request Body

| Field | Type | Required | Constraints | Description |
|-------|------|----------|-------------|-------------|
| `title` | string | No | minLength: 3, maxLength: 160 | Headline shown on the card and detail page. |
| `slug` | string | No | pattern | URL segment. Derived from the title when omitted. Must be unique across all listings. |
| `summary` | string | No | maxLength: 200 | One or two concrete sentences, used on cards and in search. |
| `description` | object | No | — | Lexical rich-text document with a `root` node. |
| `category` | integer | No | min: 1 | ID of the leaf category. The category's `attributeSchema` decides which `attributes` are valid. |
| `attributes` | array | No | — | Answers to the category's attribute schema. Send `{ key, value }`; the label is snapshotted server-side from the category. |
| `images` | array | No | — | Photos in display order. The first one is the cover. |
| `price` | integer | No | min: 0 | Price in whole Danish kroner. 450 means 450 kr — never øre, never a decimal. |
| `priceUnit` | string | No | enum: hour, day, week, month, year, fixed | What the price buys. |
| `deposit` | integer | No | min: 0 | Refundable deposit in whole kroner. Omit when there is none. |
| `negotiable` | boolean | No | — | Show a 'kan forhandles' badge. |
| `minRentalPeriod` | integer | No | min: 0 |  |
| `maxRentalPeriod` | integer | No | min: 0 |  |
| `rentalPeriodUnit` | string | No | enum: hour, day, week, month |  |
| `location` | object | No | — |  |
| `availableFrom` | string | No | format: date |  |
| `availableTo` | string | No | format: date |  |
| `condition` | string | No | enum: new, like-new, good, fair |  |
| `features` | array | No | — | Free-form amenity chips. |
| `listerType` | string | No | enum: private, business, agent | Declared per listing, not read from the account. |
| `contactPreference` | string | No | enum: platform, phone, both |  |
| `contactPhone` | string | No | maxLength: 30 | Required when `contactPreference` is `phone` or `both`. |
| `status` | string | No | enum: draft, pending, published, rented, archived | Defaults to `draft`. Moving to `published` requires at least one photo. |

**Example:**
```json
{
  "price": 495,
  "status": "published"
}
```

#### Responses

| Status | Description |
|--------|-------------|
| 200 | The updated listing |
| 400 | Validation failed — an empty patch, a malformed field, or publishing a listing that has no photo |
| 401 | Not authenticated |
| 403 | The listing belongs to someone else |
| 404 | No listing with this ID, or the category does not exist |
| 409 | Another listing already uses the requested slug |
| 422 | The attributes do not satisfy the selected category's attributeSchema |
| 500 | Internal server error |

**cURL Example:**
```bash
curl -X PATCH "https://lejested-omegzs-projects.vercel.app/api/listings/id/{id}" \
  -H "Content-Type: application/json" \
  -d '{"price":495,"status":"published"}'
```

---

### DELETE `/api/listings/id/{id}`

**Delete a listing** ` AUTH REQUIRED `

Permanently removes a listing. The caller must be the listing's owner, or an admin.

Prefer `PATCH` with `status: "archived"` for anything a lister might want back: archiving keeps the listing out of search while preserving its inquiry thread and view history, which deletion does not.

#### Parameters

| Name | In | Type | Required | Description |
|------|-----|------|----------|-------------|
| `id` | path | integer | Yes | Numeric ID of the listing. |

#### Responses

| Status | Description |
|--------|-------------|
| 200 | The listing was deleted |
| 400 | The ID is not a positive integer |
| 401 | Not authenticated |
| 403 | The listing belongs to someone else |
| 404 | No listing with this ID |
| 500 | Internal server error |

**cURL Example:**
```bash
curl -X DELETE "https://lejested-omegzs-projects.vercel.app/api/listings/id/{id}"
```

---

## Categories
> The category tree and its per-category attribute schemas, used to build both the create-listing form and the search filters.

### GET `/api/categories`

**Get the category tree with listing counts** ` PUBLIC `

Returns the six top-level rental categories, each with its leaf categories nested under `children`, plus a count of published listings per category.

This one response drives the header menu, the front-page category tiles and the `/lej` filter rail. Counts are keyed by category ID and are cumulative for a branch, so the tile for *Køretøjer* shows everything filed under cars, vans and trailers.

Category names and descriptions are localised; the response follows the `locale` cookie and falls back to Danish.

Each leaf carries an `attributeSchema` — the per-category spec fields that drive both the create-listing form and the `attr.<key>` search filters. Fetch a single category to read it in full.

#### Responses

| Status | Description |
|--------|-------------|
| 200 | The category tree and its listing counts |
| 500 | Internal server error |

**cURL Example:**
```bash
curl "https://lejested-omegzs-projects.vercel.app/api/categories"
```

---

### GET `/api/categories/{id}`

**Find a Category by ID** ` PUBLIC `

#### Responses

| Status | Description |
|--------|-------------|
| 200 | Category object |
| 404 | Category not found |

**cURL Example:**
```bash
curl "https://lejested-omegzs-projects.vercel.app/api/categories/{id}"
```

---

### GET `/api/categories/{slug}`

**Get one category with its attribute schema** ` PUBLIC `

Returns a single category including its `attributeSchema` — the per-category spec fields that drive the create-listing form and the `attr.<key>` filters on `GET /api/listings`.

Each `attributeSchema` entry has a stable `key`, a localised `label`, a `type` of `text`, `number`, `select` or `boolean`, and — for `select` — the allowed `options`. Entries marked `filterable` are the ones worth rendering in a search rail; entries marked `required` must be filled before a listing in this category can be created.

`descendantIds` contains this category's ID plus every category beneath it, which is what a category landing page needs to count or query the listings of a whole branch in one request.

Names, descriptions and attribute labels follow the `locale` cookie and fall back to Danish.

#### Parameters

| Name | In | Type | Required | Description |
|------|-----|------|----------|-------------|
| `slug` | path | string | Yes | URL segment of the category, as used by `/kategori/{slug}`. |

#### Responses

| Status | Description |
|--------|-------------|
| 200 | The category and the IDs of its subtree |
| 400 | The slug is not a valid slug |
| 404 | No category has this slug |
| 500 | Internal server error |

**cURL Example:**
```bash
curl "https://lejested-omegzs-projects.vercel.app/api/categories/{slug}"
```

---

## Inquiries
> Rental requests sent from a renter to a lister, and the message thread that follows. Creating one does not require an account.

### GET `/api/inquiries`

**List your inquiries** ` AUTH REQUIRED `

Returns the signed-in user's inquiries, newest first. `box=received` returns inquiries on listings you own (the lister inbox); `box=sent` returns inquiries you sent as a renter. Both sides of `/konto/henvendelser` are served from this one endpoint.

#### Parameters

| Name | In | Type | Required | Description |
|------|-----|------|----------|-------------|
| `box` | query | string | No | `received` for the lister inbox, `sent` for inquiries you sent |
| `status` | query | string | No | Filter to a single inquiry status |
| `page` | query | integer | No | 1-indexed page number |
| `limit` | query | integer | No | Documents per page |

#### Responses

| Status | Description |
|--------|-------------|
| 200 | Paginated inquiries |
| 400 | Validation error — unknown box or status value |
| 401 | Unauthorized — authentication required |
| 500 | Internal server error |

**cURL Example:**
```bash
curl "https://lejested-omegzs-projects.vercel.app/api/inquiries"
```

---

### POST `/api/inquiries`

**Send a rental inquiry** ` PUBLIC `

Creates an inquiry against a published listing. **No account is required** — a logged-out renter can send one, which is the whole point of a lead-based marketplace. When a session is present the inquiry is attached to that user and the session email wins over whatever is in the body.

Creating an inquiry increments the listing's `inquiryCount` and emails both parties. Rate limited to 5 requests per 10 minutes per IP (per IP + user when signed in).

> **Dashboard:** Payload Admin > Marketplace > Inquiries

> **Rate Limit:** 5 requests per 10m

#### Request Body

| Field | Type | Required | Constraints | Description |
|-------|------|----------|-------------|-------------|
| `listingId` | integer | Yes | min: 1, max: 2147483647 | Numeric ID of the listing the renter is asking about |
| `name` | string | Yes | minLength: 1, maxLength: 150 | Name the renter wants the lister to see |
| `email` | string | Yes | minLength: 5, maxLength: 320, format: email | Where the lister's reply goes. Ignored in favour of the session email when signed in. |
| `phone` | string | No | maxLength: 30 | Optional phone number |
| `message` | string | Yes | minLength: 1, maxLength: 4000 | The opening message of the thread |
| `startDate` | string | No | — | Requested start of the rental period — `YYYY-MM-DD` or a full offset-aware timestamp |
| `endDate` | string | No | — | Requested end of the rental period. Blank means open-ended. |
| `partySize` | integer | No | min: 0, max: 100000 | How many people, for venues and event categories |
| `quantity` | integer | No | min: 0, max: 100000 | How many units, where a listing is countable |
| `source` | string | No | enum: listing-detail, listing-card, profile | Which surface the inquiry was sent from |

#### Responses

| Status | Description |
|--------|-------------|
| 201 | Inquiry created |
| 400 | Validation error — missing or invalid fields |
| 404 | Listing not found or not accepting inquiries |
| 429 | Too many inquiries — retry after the window closes |
| 500 | Internal server error |

**cURL Example:**
```bash
curl -X POST "https://lejested-omegzs-projects.vercel.app/api/inquiries"
```

---

### GET `/api/inquiries/{id}`

**Read one inquiry thread** ` AUTH REQUIRED `

Returns a single inquiry with its full message thread. Readable only by the lister who owns the listing, the renter who sent it, or an admin — anyone else gets 403.

#### Parameters

| Name | In | Type | Required | Description |
|------|-----|------|----------|-------------|
| `id` | path | string | Yes | Numeric ID of the inquiry |

#### Responses

| Status | Description |
|--------|-------------|
| 200 | The inquiry |
| 400 | Validation error — non-numeric id |
| 401 | Unauthorized — authentication required |
| 403 | Forbidden — you are neither the lister nor the renter |
| 404 | Inquiry not found |
| 500 | Internal server error |

**cURL Example:**
```bash
curl "https://lejested-omegzs-projects.vercel.app/api/inquiries/{id}"
```

---

### PATCH `/api/inquiries/{id}`

**Reply to an inquiry or change its status** ` AUTH REQUIRED `

Two actions behind one endpoint, discriminated by `action`.

- `{ action: "reply", body }` appends a message to the thread. The author side (`renter` / `lister`) is derived from who you are, never from the request.
- `{ action: "status", status }` moves the inquiry along its lifecycle (`new` → `read` → `replied` → `accepted` / `declined` / `closed`).

Restricted to the lister, the renter, or an admin.

#### Parameters

| Name | In | Type | Required | Description |
|------|-----|------|----------|-------------|
| `id` | path | string | Yes | Numeric ID of the inquiry |

#### Request Body

#### Responses

| Status | Description |
|--------|-------------|
| 200 | The updated inquiry |
| 400 | Validation error — unknown action, or missing body/status |
| 401 | Unauthorized — authentication required |
| 403 | Forbidden — you are not a party to this inquiry |
| 404 | Inquiry not found |
| 500 | Internal server error |

**cURL Example:**
```bash
curl -X PATCH "https://lejested-omegzs-projects.vercel.app/api/inquiries/{id}"
```

---

## Saved
> A signed-in user's saved listings.

### GET `/api/saved`

**Get saved listings** ` AUTH REQUIRED `

Returns the signed-in user's saved listings, newest first, with the `listing` relation populated (depth 2) so `/konto/gemte` can render cards without a second round-trip. Limited to 50 items.

#### Responses

| Status | Description |
|--------|-------------|
| 200 | Saved listings retrieved successfully |
| 401 | Unauthorized — authentication required |
| 500 | Internal server error |

**cURL Example:**
```bash
curl "https://lejested-omegzs-projects.vercel.app/api/saved"
```

---

### POST `/api/saved`

**Save a listing** ` AUTH REQUIRED `

Adds a listing to the signed-in user's shortlist. Returns 409 if it is already saved. This is a bookmark, not a cart — nothing here is ever charged, and renters never pay on Lejested.

#### Request Body

| Field | Type | Required | Constraints | Description |
|-------|------|----------|-------------|-------------|
| `listingId` | integer | Yes | min: 1, max: 2147483647 | Numeric ID of the listing to save |

#### Responses

| Status | Description |
|--------|-------------|
| 201 | Listing saved |
| 400 | Validation error — missing or invalid listingId |
| 401 | Unauthorized — authentication required |
| 404 | Authenticated user not found in the Payload users collection |
| 409 | Conflict — the listing is already saved |
| 500 | Internal server error |

**cURL Example:**
```bash
curl -X POST "https://lejested-omegzs-projects.vercel.app/api/saved"
```

---

### DELETE `/api/saved/{listingId}`

**Unsave a listing** ` AUTH REQUIRED `

Removes a listing from the signed-in user's shortlist. Keyed on the **listing** id rather than the saved-listing row id, so a save button on a listing card can toggle without first looking up which row it created. Ownership is enforced by the lookup itself — the row is only ever matched within your own saved listings, so another user's row can neither be seen nor deleted. Returns 404 if the listing was not saved.

#### Parameters

| Name | In | Type | Required | Description |
|------|-----|------|----------|-------------|
| `listingId` | path | string | Yes | Numeric ID of the listing to unsave |

#### Responses

| Status | Description |
|--------|-------------|
| 200 | Listing unsaved successfully |
| 400 | Validation error — non-numeric listingId |
| 401 | Unauthorized — authentication required |
| 404 | Not found — this listing is not in your saved listings |
| 500 | Internal server error |

**cURL Example:**
```bash
curl -X DELETE "https://lejested-omegzs-projects.vercel.app/api/saved/{listingId}"
```

---

### GET `/api/saved/check/{listingId}`

**Check if a listing is saved** ` AUTH REQUIRED `

Tells the save button on a listing card or detail page which state to render. Signed-out visitors get `{ saved: false }` with a 200 rather than a 401 — a save button that has not been clicked looks identical whether you are logged in or not, and 401-ing here would put an error in the console on every public listing page.

#### Parameters

| Name | In | Type | Required | Description |
|------|-----|------|----------|-------------|
| `listingId` | path | string | Yes | Numeric ID of the listing to check |

#### Responses

| Status | Description |
|--------|-------------|
| 200 | Saved-state result |
| 400 | Validation error — non-numeric listingId |

**cURL Example:**
```bash
curl "https://lejested-omegzs-projects.vercel.app/api/saved/check/{listingId}"
```

---

## Contact
> Contact form submissions.

### POST `/api/contact`

**Submit contact form** ` PUBLIC `

Accepts a contact form submission and stores it in the Payload CMS `contact-form-submissions` collection. No authentication is required.

> **Dashboard:** Payload Admin > Contact Form Submissions

#### Request Body

| Field | Type | Required | Constraints | Description |
|-------|------|----------|-------------|-------------|
| `name` | string | Yes | minLength: 1, maxLength: 150, pattern | Full name of the person submitting the form |
| `email` | string | Yes | minLength: 5, maxLength: 320, format: email | Contact email address |
| `subject` | string | Yes | minLength: 1, maxLength: 200 | Subject line for the contact message |
| `message` | string | Yes | minLength: 1, maxLength: 5000 | Body of the contact message |

#### Responses

| Status | Description |
|--------|-------------|
| 201 | Contact form submitted successfully |
| 400 | Validation error — missing or invalid fields |
| 500 | Internal server error |

**cURL Example:**
```bash
curl -X POST "https://lejested-omegzs-projects.vercel.app/api/contact"
```

---

## Newsletter
> Email newsletter subscriptions.

### POST `/api/newsletter`

**Subscribe to newsletter** ` PUBLIC `

Subscribes an email address to the newsletter. The address is stored in the Payload CMS `newsletter-subscribers` collection and optionally synced to a Resend audience when `RESEND_API_KEY` and `RESEND_AUDIENCE_ID` are configured. Duplicate emails are silently ignored.

> **Dashboard:** Payload Admin > Newsletter Subscribers

#### Request Body

| Field | Type | Required | Constraints | Description |
|-------|------|----------|-------------|-------------|
| `email` | string | Yes | minLength: 5, maxLength: 320, format: email | Email address to subscribe to the newsletter |

#### Responses

| Status | Description |
|--------|-------------|
| 200 | Successfully subscribed (or already subscribed) |
| 400 | Validation error — invalid email address |
| 500 | Internal server error |

**cURL Example:**
```bash
curl -X POST "https://lejested-omegzs-projects.vercel.app/api/newsletter"
```

---

## Reviews
> Renter reviews and ratings of listings and listers.

### GET `/api/reviews`

**List reviews for a listing** ` PUBLIC `

Returns a paginated list of approved reviews for a given listing, along with aggregate statistics (average rating, star-rating breakdown). Reviews are written by renters after a rental and are read as a verdict on the lister.

#### Parameters

| Name | In | Type | Required | Description |
|------|-----|------|----------|-------------|
| `listingId` | query | string | Yes | Numeric ID of the listing to fetch reviews for |
| `page` | query | string | No | Page number for pagination (defaults to 1) |
| `limit` | query | string | No | Number of reviews per page (max 50, defaults to 10) |
| `sort` | query | string | No | Sort order for reviews |

#### Responses

| Status | Description |
|--------|-------------|
| 200 | Paginated reviews with aggregate statistics |
| 400 | Validation error — missing listingId |
| 500 | Internal server error |

**cURL Example:**
```bash
curl "https://lejested-omegzs-projects.vercel.app/api/reviews"
```

---

### POST `/api/reviews`

**Review a listing** ` AUTH REQUIRED `

Creates a review for a listing. Requires authentication. The review is created with `pending` status and must be approved by an admin before it appears publicly. One review per user per listing is allowed (409 otherwise). `verifiedRental` is set automatically when the reviewer has an accepted inquiry on that listing — nothing is ever purchased on Lejested, so an accepted rental request is the only honest proof of a real transaction.

#### Request Body

| Field | Type | Required | Constraints | Description |
|-------|------|----------|-------------|-------------|
| `listingId` | integer | Yes | min: 1, max: 2147483647 | Numeric ID of the listing being reviewed |
| `rating` | integer | Yes | min: 1, max: 5 | Star rating from 1 to 5 |
| `title` | string | Yes | minLength: 1, maxLength: 200 | Short title for the review |
| `body` | string | Yes | minLength: 1, maxLength: 2000 | Full text of the review |

#### Responses

| Status | Description |
|--------|-------------|
| 201 | Review created successfully (status: pending) |
| 400 | Validation error — missing or invalid fields |
| 401 | Unauthorized — authentication required |
| 403 | Forbidden — a lister cannot review their own listing |
| 404 | Listing not found, or the authenticated user has no Payload record |
| 409 | Conflict — user has already reviewed this listing |
| 500 | Internal server error |

**cURL Example:**
```bash
curl -X POST "https://lejested-omegzs-projects.vercel.app/api/reviews"
```

---

### GET `/api/reviews/{id}`

**Find a Review by ID** ` PUBLIC `

#### Responses

| Status | Description |
|--------|-------------|
| 200 | Review object |
| 404 | Review not found |

**cURL Example:**
```bash
curl "https://lejested-omegzs-projects.vercel.app/api/reviews/{id}"
```

---

### POST `/api/reviews/{reviewId}/helpful`

**Mark a review as helpful** ` AUTH REQUIRED `

Increments the helpful vote count on a review. Requires authentication. The review must exist; otherwise a 404 is returned.

#### Parameters

| Name | In | Type | Required | Description |
|------|-----|------|----------|-------------|
| `reviewId` | path | string | Yes | Numeric ID of the review to mark as helpful |

#### Responses

| Status | Description |
|--------|-------------|
| 200 | Helpful count incremented successfully |
| 401 | Unauthorized — authentication required |
| 404 | Review not found |
| 500 | Internal server error |

**cURL Example:**
```bash
curl -X POST "https://lejested-omegzs-projects.vercel.app/api/reviews/{reviewId}/helpful"
```

---

## Cart
> Cart discount management for boost and plan purchases (listers only).

### POST `/api/cart/apply-discount`

**Apply a discount code to a cart** ` AUTH REQUIRED `

Validates and applies a discount code to a basket of boosts and plans. The caller must own the cart (via session) or supply the cart secret. Amounts are DKK øre — 14900 is 149,00 kr.

> **Dashboard:** Use from the basket or /checkout by entering a discount code and pressing Apply.

#### Request Body

| Field | Type | Required | Constraints | Description |
|-------|------|----------|-------------|-------------|
| `code` | string | Yes | minLength: 1, maxLength: 50 | The discount code to apply to the cart |
| `cartId` | integer | Yes | min: 1, max: 2147483647 | The ID of the cart to apply the discount to |
| `secret` | string | No | minLength: 1, maxLength: 255 | Cart secret for guest users who are not authenticated but own the cart |

**Example:**
```json
{
  "code": "SOMMER20",
  "cartId": 42
}
```

#### Responses

| Status | Description |
|--------|-------------|
| 200 | Discount applied successfully |
| 400 | Malformed request body, or a discount code that does not apply. The two are distinguishable: a rejected code carries `success: false`, a malformed body carries `details`. |
| 403 | Not authorised to modify this cart |
| 404 | Cart not found or already purchased |
| 500 | Internal server error |

**cURL Example:**
```bash
curl -X POST "https://lejested-omegzs-projects.vercel.app/api/cart/apply-discount" \
  -H "Content-Type: application/json" \
  -d '{"code":"SOMMER20","cartId":42}'
```

---

### POST `/api/cart/remove-discount`

**Remove a discount code from a cart** ` AUTH REQUIRED `

Removes any previously applied discount code from a basket of boosts and plans. The caller must own the cart (via session) or supply the cart secret.

> **Dashboard:** Use from the basket or /checkout by clicking the remove button next to the applied code.

#### Request Body

| Field | Type | Required | Constraints | Description |
|-------|------|----------|-------------|-------------|
| `cartId` | integer | Yes | min: 1, max: 2147483647 | The ID of the cart to remove the discount from |
| `secret` | string | No | minLength: 1, maxLength: 255 | Cart secret for guest users who are not authenticated but own the cart |

**Example:**
```json
{
  "cartId": 42
}
```

#### Responses

| Status | Description |
|--------|-------------|
| 200 | Discount removed successfully |
| 400 | Missing or invalid cart ID |
| 403 | Not authorised to modify this cart |
| 404 | Cart not found or already purchased |
| 500 | Internal server error |

**cURL Example:**
```bash
curl -X POST "https://lejested-omegzs-projects.vercel.app/api/cart/remove-discount" \
  -H "Content-Type: application/json" \
  -d '{"cartId":42}'
```

---

## Discounts
> Discount code validation.

### POST `/api/discount/validate`

**Validate a discount code** ` PUBLIC `

Validates a discount code for a boost or plan purchase: it must exist, be active, sit inside its valid date range, be under its usage limits and meet the minimum order amount. Optionally calculates the discount when a subtotal is supplied. All amounts are DKK øre — 14900 is 149,00 kr. Rate limited to 10 requests per IP per minute.

> **Rate Limit:** 10 requests per 60s

#### Request Body

| Field | Type | Required | Constraints | Description |
|-------|------|----------|-------------|-------------|
| `code` | string | Yes | minLength: 1, maxLength: 50 | The discount code to validate |
| `customerEmail` | string | No | minLength: 3, maxLength: 320, format: email | Customer email for per-customer usage limit checks |
| `subtotal` | integer | No | min: 0, max: 99999999 | Cart subtotal in DKK øre for the minimum-order check and discount calculation |

**Example:**
```json
{
  "code": "VELKOMMEN10",
  "customerEmail": "mette@eksempel.dk",
  "subtotal": 14900
}
```

#### Responses

| Status | Description |
|--------|-------------|
| 200 | Validation result. Both valid and invalid codes return 200; check the `valid` field. |
| 400 | Missing or invalid request body |
| 429 | Rate limit exceeded (10 requests per minute per IP) |
| 500 | Internal server error |

**cURL Example:**
```bash
curl -X POST "https://lejested-omegzs-projects.vercel.app/api/discount/validate" \
  -H "Content-Type: application/json" \
  -d '{"code":"VELKOMMEN10","customerEmail":"mette@eksempel.dk","subtotal":14900}'
```

---

## Payments
> Payment amount calculation for boosts and plans.

### POST `/api/payment-amount`

**Calculate final payment amount with optional discount** ` AUTH REQUIRED `

Retrieves the current amount of a Stripe PaymentIntent for a boost or plan purchase and optionally applies a discount code. Every amount is DKK øre — 14900 is 149,00 kr. The PaymentIntent must still be in the `requires_payment_method` status. For authenticated users, ownership is verified via the Stripe customer. For guests, the PaymentIntent ID acts as authorization. Rate limited to 20 requests per IP per minute.

> **Rate Limit:** 20 requests per 60s

#### Request Body

| Field | Type | Required | Constraints | Description |
|-------|------|----------|-------------|-------------|
| `paymentIntentId` | string | Yes | minLength: 1, maxLength: 255 | The Stripe PaymentIntent ID |
| `discountCode` | string | No | minLength: 1, maxLength: 50 | Optional discount code to apply to the payment |

**Example:**
```json
{
  "paymentIntentId": "pi_3Oc0X2Abc123def456",
  "discountCode": "SOMMER20"
}
```

#### Responses

| Status | Description |
|--------|-------------|
| 200 | Payment amount calculated successfully |
| 400 | Missing paymentIntentId, invalid payment state, or discount error |
| 403 | PaymentIntent does not belong to the authenticated user |
| 429 | Rate limit exceeded (20 requests per minute per IP) |
| 500 | Internal server error |

**cURL Example:**
```bash
curl -X POST "https://lejested-omegzs-projects.vercel.app/api/payment-amount" \
  -H "Content-Type: application/json" \
  -d '{"paymentIntentId":"pi_3Oc0X2Abc123def456","discountCode":"SOMMER20"}'
```

---

## Orders
> A lister's purchase history for boosts and plans.

### GET `/api/orders`

**List the authenticated user's orders** ` AUTH REQUIRED `

Returns a paginated list of orders belonging to the authenticated user. Orders are matched by the internal user ID or the user's email and are sorted by creation date (newest first).

#### Parameters

| Name | In | Type | Required | Description |
|------|-----|------|----------|-------------|
| `page` | query | integer | No | Page number for pagination (defaults to 1) |
| `limit` | query | integer | No | Number of orders per page (defaults to 10, max 50) |

#### Responses

| Status | Description |
|--------|-------------|
| 200 | Paginated order list |
| 401 | Not authenticated |
| 500 | Internal server error |

**cURL Example:**
```bash
curl "https://lejested-omegzs-projects.vercel.app/api/orders"
```

---

## Search
> Listing search and autocomplete.

### GET `/api/search`

**Search listings, categories and guides** ` PUBLIC `

Full-text search across published rental listings (title, summary, city), the category tree and the guide archive. Listings are matched in Payload and re-ordered by the SaaSignal ranking index when it is available — if SaaSignal is unconfigured or unreachable the endpoint still returns Payload matches rather than failing. Returns empty arrays when no query is supplied. Prices are in whole Danish kroner.

#### Parameters

| Name | In | Type | Required | Description |
|------|-----|------|----------|-------------|
| `q` | query | string | Yes | Full-text search query |
| `limit` | query | integer | No | Maximum number of results per group (default 10, max 20) |
| `locale` | query | string | No | Locale for editorial content (categories, guides) |

#### Responses

| Status | Description |
|--------|-------------|
| 200 | Matched listings, categories and guides |
| 500 | Internal server error |

**cURL Example:**
```bash
curl "https://lejested-omegzs-projects.vercel.app/api/search"
```

---

### GET `/api/search/suggest`

**Autocomplete search suggestions** ` PUBLIC `

Prefix-based autocomplete for the listing search box. Served from the SaaSignal suggest index when available, falling back to Payload prefix matches on published listing titles and category names so the type-ahead keeps working without SaaSignal configured. Returns an empty array when no prefix is supplied.

> **Rate Limit:** 30 requests per 1m

#### Parameters

| Name | In | Type | Required | Description |
|------|-----|------|----------|-------------|
| `q` | query | string | Yes | Prefix text for autocomplete suggestions |
| `limit` | query | integer | No | Maximum number of suggestions (default 5, max 10) |
| `locale` | query | string | No | Locale for category names in the fallback suggestions |

#### Responses

| Status | Description |
|--------|-------------|
| 200 | Autocomplete suggestions |
| 429 | Too many requests — 30 suggestions per minute per IP |
| 500 | Internal server error |

**cURL Example:**
```bash
curl "https://lejested-omegzs-projects.vercel.app/api/search/suggest"
```

---

## Recommendations
> Similar and recommended listings, plus interaction tracking.

### GET `/api/listings/{slug}/similar`

**List listings similar to one listing** ` PUBLIC `

Returns published listings comparable to the given one — same category first, then nearby location and a similar price band. Used by the 'Lignende annoncer' rail on `/lej/{slug}`.

The reference listing itself is never included. Unlike `GET /api/listings/{slug}` this endpoint does **not** increment `viewCount`, so it is safe to call on hover or prefetch.

#### Parameters

| Name | In | Type | Required | Description |
|------|-----|------|----------|-------------|
| `slug` | path | string | Yes | URL segment of the listing to find neighbours for. |
| `limit` | query | integer | No | How many similar listings to return. |

#### Responses

| Status | Description |
|--------|-------------|
| 200 | Similar listings, most relevant first |
| 400 | Invalid slug or limit |
| 404 | No published listing has this slug |
| 500 | Internal server error |

**cURL Example:**
```bash
curl "https://lejested-omegzs-projects.vercel.app/api/listings/{slug}/similar"
```

---

### GET `/api/recommendations/related/{listingId}`

**Get similar listings** ` PUBLIC `

Returns published listings similar to the given one — same category, comparable price band, source listing excluded. Candidates come from `ListingService.similar`; the SaaSignal ranking engine only reorders them, so an unconfigured or stale index costs relevance but never correctness and never fails the request. Returns 404 when the source listing does not exist. Prices are in whole Danish kroner.

#### Parameters

| Name | In | Type | Required | Description |
|------|-----|------|----------|-------------|
| `listingId` | path | string | Yes | The numeric ID of the listing to find similar listings for |
| `limit` | query | integer | No | Maximum number of similar listings (default 6, max 12) |

#### Responses

| Status | Description |
|--------|-------------|
| 200 | Similar listings |
| 400 | Validation error — listingId is not a positive integer |
| 404 | Listing not found |
| 500 | Internal server error |

**cURL Example:**
```bash
curl "https://lejested-omegzs-projects.vercel.app/api/recommendations/related/{listingId}"
```

---

## Error Reference

All errors return a JSON object with an `error` field:
```json
{ "error": "Human-readable error message" }
```

Validation errors (400) additionally include structured details:
```json
{
  "error": "Validation failed",
  "details": {
    "fieldErrors": { "email": ["Invalid email address"] },
    "formErrors": []
  }
}
```
