# Lejested > 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. ## What this site is Lejested is a Danish general rental marketplace. Listers (private people, businesses and estate agents) publish listings for anything rentable — homes, rooms, offices, vehicles, tools, event gear, leisure and camera equipment. Renters search, compare, and send an inquiry to the lister. Facts an agent must not get wrong: - The conversion event is an INQUIRY, not a checkout. A renter never pays on this site. Payment and hand-over are settled directly between renter and lister, off-platform. - Inquiries can be sent while logged out. Creating one is public and rate limited. - Money on-site exists only on the lister side: featured-listing boosts and business/agent subscriptions. - Currency is DKK, written `12.400 kr` and `450 kr/dag`. Listing `price` and `deposit` are whole kroner. The ecommerce plugin's `priceInUSD` field is a plugin field name only — its value is DKK in minor units (øre). - Languages are Danish (`da`, default) and English (`en`). Nothing else. ## Key pages - Front page: https://lejested-omegzs-projects.vercel.app/ - Browse & search listings: https://lejested-omegzs-projects.vercel.app/lej - Listing detail: https://lejested-omegzs-projects.vercel.app/lej/{slug} - Category landing: https://lejested-omegzs-projects.vercel.app/kategori/{slug} - Public lister profile: https://lejested-omegzs-projects.vercel.app/profil/{id} - Rent out your things: https://lejested-omegzs-projects.vercel.app/udlej - Boost & plan pricing: https://lejested-omegzs-projects.vercel.app/priser - How it works: https://lejested-omegzs-projects.vercel.app/saadan-virker-det - Guides: https://lejested-omegzs-projects.vercel.app/blogs - FAQ: https://lejested-omegzs-projects.vercel.app/faq - Contact: https://lejested-omegzs-projects.vercel.app/kontakt - Terms: https://lejested-omegzs-projects.vercel.app/vilkaar - Privacy: https://lejested-omegzs-projects.vercel.app/privatliv English aliases (`/listings`, `/rent-out`, `/pricing`, `/how-it-works`, `/about`, `/contact`, `/terms`, `/privacy`, `/category/*`, `/profile/*`) answer with a 308 to the Danish path. Link to the Danish path directly. ## Further reading - OpenAPI 3.1 spec (JSON): https://lejested-omegzs-projects.vercel.app/api/openapi.json - Human-readable API reference: https://lejested-omegzs-projects.vercel.app/to-humans.md - Interactive API docs: https://lejested-omegzs-projects.vercel.app/api/docs --- # Lejested Marketplace API > Read rental listings from Lejested and send inquiries, without building a checkout: renters pay the lister, not the platform. ## Authentication Session-based via cookie `better-auth.session_token`. Sign in: POST /api/auth/sign-in/email { email, password } Endpoints marked [public] do not require authentication. ## Categories ### 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. Responses: 200: The category tree and its listing counts 500: Internal server error ### GET /api/categories/{id} Find a Category by ID [public] Responses: 200: Category object 404: Category not found ### 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.` filters on `GET /api/listings`. Parameters: slug (string, path, required): URL segment of the category, as used by `/kategori/{slug}`. Responses: 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 ## 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. Parameters: q (string, query, optional): Free-text query matched against title, summary and city. category (string, query, optional): Category slug. Listings filed under any descendant category are included, so `?category=koeretoejer` returns cars, vans and trailers. city (string, query, optional): City the listing is located in. postalCode (string, query, optional): Danish postal code. region (string, query, optional): One of the five Danish administrative regions. priceMin (integer, query, optional): Lowest acceptable price, in whole DKK. priceMax (integer, query, optional): Highest acceptable price, in whole DKK. priceUnit (string, query, optional): Restrict to listings priced per this unit. from (string, query, optional): Start of the period the listing must be available for. Maps to `availableFrom`. to (string, query, optional): End of the period the listing must be available for. Maps to `availableTo`. condition (string, query, optional): Physical condition. Comma-separated; any of: new, like-new, good, fair. listerType (string, query, optional): Who is renting it out. Comma-separated; any of: private, business, agent. features (string, query, optional): Amenity chips the listing must carry, comma-separated. Matched against the free-form `features` list. onlyWithPhotos (string, query, optional): Only return listings that have at least one photo. featuredFirst (string, query, optional): Rank boosted listings ahead of the chosen sort. bbox (string, query, optional): Map viewport as `minLng,minLat,maxLng,maxLat` (GeoJSON order). Used by the map view on `/lej`. attr.{key} (string, query, optional): 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 (string, query, optional): Result ordering. page (integer, query, optional): 1-indexed page number. limit (integer, query, optional): Results per page. Values above 48 are clamped to 48 rather than rejected. Responses: 200: Paginated listing results 400: Invalid query parameters — for example a malformed date or a priceMax below priceMin 500: Internal server error ### GET /api/listings/{id} Find a Listing by ID [public] Responses: 200: Listing object 404: Listing not found ### GET /api/listings/{slug} Get one listing by slug [public] Returns a single published listing with its category, photos, attributes and lister populated. Parameters: slug (string, path, required): URL segment of the listing, as used by `/lej/{slug}`. Responses: 200: The listing 400: The slug is not a valid slug 404: No published listing has this slug 500: Internal server error ### 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}`. Parameters: slug (string, path, required): URL segment of the listing to find neighbours for. limit (integer, query, optional): How many similar listings to return. Responses: 200: Similar listings, most relevant first 400: Invalid slug or limit 404: No published listing has this slug 500: Internal server error ### 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: status (string, query, optional): Only return listings in this status. q (string, query, optional): Free-text query matched against title and summary. sort (string, query, optional): Ordering. Defaults to most recently updated. page (integer, query, optional): 1-indexed page number. limit (integer, query, optional): Listings per page. Responses: 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 ### POST /api/listings/mine Create a listing [auth required] Creates a listing owned by the authenticated user. Request body (JSON): title: string [min: 3, max: 160] (required) — Headline shown on the card and detail page. (e.g. "Ford Transit kassevogn — 3,5 t") slug: string [pattern: ^[a-z0-9]+(?:-[a-z0-9]+)*$] (optional) — URL segment. Derived from the title when omitted. Must be unique across all listings. (e.g. "ford-transit-kassevogn-aarhus") summary: string [max: 200] (optional) — One or two concrete sentences, used on cards and in search. (e.g. "3,5 t kassevogn med bagsmæk. Afhentes i Åbyhøj.") description: object (optional) — Lexical rich-text document with a `root` node. category: integer [min: 1] (required) — ID of the leaf category. The category's `attributeSchema` decides which `attributes` are valid. (e.g. 12) attributes: array (optional) — Answers to the category's attribute schema. Send `{ key, value }`; the label is snapshotted server-side from the category. images: array (optional) — Photos in display order. The first one is the cover. price: integer [min: 0] (required) — Price in whole Danish kroner. 450 means 450 kr — never øre, never a decimal. (e.g. 450) priceUnit: string [enum: hour, day, week, month, year, fixed] (required) — What the price buys. (e.g. "day") deposit: integer [min: 0] (optional) — Refundable deposit in whole kroner. Omit when there is none. (e.g. 2000) negotiable: boolean (optional) — Show a 'kan forhandles' badge. minRentalPeriod: integer [min: 0] (optional) (e.g. 1) maxRentalPeriod: integer [min: 0] (optional) (e.g. 14) rentalPeriodUnit: string [enum: hour, day, week, month] (optional) location: object (optional) availableFrom: string [format: date] (optional) (e.g. "2026-09-01") availableTo: string [format: date] (optional) (e.g. "2026-12-20") condition: string [enum: new, like-new, good, fair] (optional) features: array (optional) — Free-form amenity chips. (e.g. ["Anhængertræk","Bagsmæk"]) listerType: string [enum: private, business, agent] (required) — Declared per listing, not read from the account. (e.g. "private") contactPreference: string [enum: platform, phone, both] (optional) contactPhone: string [max: 30] (optional) — Required when `contactPreference` is `phone` or `both`. status: string [enum: draft, pending, published, rented, archived] (optional) — Defaults to `draft`. Moving to `published` requires at least one photo. (e.g. "draft") Responses: 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 Rate limit: 20 requests per 1 hour ### 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. Parameters: id (integer, path, required): Numeric ID of the listing. Request body (JSON): title: string [min: 3, max: 160] (optional) — Headline shown on the card and detail page. (e.g. "Ford Transit kassevogn — 3,5 t") slug: string [pattern: ^[a-z0-9]+(?:-[a-z0-9]+)*$] (optional) — URL segment. Derived from the title when omitted. Must be unique across all listings. (e.g. "ford-transit-kassevogn-aarhus") summary: string [max: 200] (optional) — One or two concrete sentences, used on cards and in search. (e.g. "3,5 t kassevogn med bagsmæk. Afhentes i Åbyhøj.") description: object (optional) — Lexical rich-text document with a `root` node. category: integer [min: 1] (optional) — ID of the leaf category. The category's `attributeSchema` decides which `attributes` are valid. (e.g. 12) attributes: array (optional) — Answers to the category's attribute schema. Send `{ key, value }`; the label is snapshotted server-side from the category. images: array (optional) — Photos in display order. The first one is the cover. price: integer [min: 0] (optional) — Price in whole Danish kroner. 450 means 450 kr — never øre, never a decimal. (e.g. 450) priceUnit: string [enum: hour, day, week, month, year, fixed] (optional) — What the price buys. (e.g. "day") deposit: integer [min: 0] (optional) — Refundable deposit in whole kroner. Omit when there is none. (e.g. 2000) negotiable: boolean (optional) — Show a 'kan forhandles' badge. minRentalPeriod: integer [min: 0] (optional) (e.g. 1) maxRentalPeriod: integer [min: 0] (optional) (e.g. 14) rentalPeriodUnit: string [enum: hour, day, week, month] (optional) location: object (optional) availableFrom: string [format: date] (optional) (e.g. "2026-09-01") availableTo: string [format: date] (optional) (e.g. "2026-12-20") condition: string [enum: new, like-new, good, fair] (optional) features: array (optional) — Free-form amenity chips. (e.g. ["Anhængertræk","Bagsmæk"]) listerType: string [enum: private, business, agent] (optional) — Declared per listing, not read from the account. (e.g. "private") contactPreference: string [enum: platform, phone, both] (optional) contactPhone: string [max: 30] (optional) — Required when `contactPreference` is `phone` or `both`. status: string [enum: draft, pending, published, rented, archived] (optional) — Defaults to `draft`. Moving to `published` requires at least one photo. (e.g. "draft") Responses: 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 ### 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. Parameters: id (integer, path, required): Numeric ID of the listing. Responses: 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 ## Reviews ### 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: listingId (string, query, required): Numeric ID of the listing to fetch reviews for page (string, query, optional): Page number for pagination (defaults to 1) limit (string, query, optional): Number of reviews per page (max 50, defaults to 10) sort (string, query, optional): Sort order for reviews Responses: 200: Paginated reviews with aggregate statistics 400: Validation error — missing listingId 500: Internal server error ### 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 (JSON): listingId: integer [min: 1, max: 2147483647] (required) — Numeric ID of the listing being reviewed (e.g. 42) rating: integer [min: 1, max: 5] (required) — Star rating from 1 to 5 (e.g. 4) title: string [min: 1, max: 200] (required) — Short title for the review (e.g. "Nem handel og præcis beskrivelse") body: string [min: 1, max: 2000] (required) — Full text of the review (e.g. "Vognen var lige så ren som på billederne, og afhentningen tog fem minutter. Ville leje af Mette igen uden at tøve.") Responses: 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 ### GET /api/reviews/{id} Find a Review by ID [public] Responses: 200: Review object 404: Review not found ### 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: reviewId (string, path, required): Numeric ID of the review to mark as helpful Responses: 200: Helpful count incremented successfully 401: Unauthorized — authentication required 404: Review not found 500: Internal server error ## Guides ### GET /api/blogs Retrieve a list of Guides [public] Parameters: page (number, query, optional): limit (number, query, optional): depth (number, query, optional): locale (string, query, optional): fallback-locale (string, query, optional): sort (string, query, optional): where (string, query, optional): Responses: 200: List of Guides ### GET /api/blogs/{id} Find a Guide by ID [public] Responses: 200: Guide object 404: Guide not found ## FAQs ### GET /api/faqs Retrieve a list of FAQs [public] Parameters: page (number, query, optional): limit (number, query, optional): depth (number, query, optional): locale (string, query, optional): fallback-locale (string, query, optional): sort (string, query, optional): where (string, query, optional): Responses: 200: List of FAQs ### GET /api/faqs/{id} Find a FAQ by ID [public] Responses: 200: FAQ object 404: FAQ not found ## Media ### GET /api/media Retrieve a list of Media [public] Parameters: page (number, query, optional): limit (number, query, optional): depth (number, query, optional): locale (string, query, optional): fallback-locale (string, query, optional): sort (string, query, optional): where (string, query, optional): Responses: 200: List of Media ### GET /api/media/{id} Find a Media by ID [public] Responses: 200: Media object 404: Media not found ## addresses ### GET /api/addresses Retrieve a list of addresses [auth required] Parameters: page (number, query, optional): limit (number, query, optional): depth (number, query, optional): locale (string, query, optional): fallback-locale (string, query, optional): sort (string, query, optional): where (string, query, optional): Responses: 200: List of addresses ### POST /api/addresses Create a new addresses [auth required] Parameters: depth (number, query, optional): locale (string, query, optional): Request body (JSON): customer: string (optional) — ID of the users title: string | null (optional) firstName: string | null (optional) lastName: string | null (optional) company: string | null (optional) addressLine1: string | null (optional) addressLine2: string | null (optional) city: string | null (optional) state: string | null (optional) postalCode: string | null (optional) country: string [enum: US, GB, CA, AU, AT, BE, BR, BG, CY, CZ, DK, EE, FI, FR, DE, GR, HK, HU, IN, IE, IT, JP, LV, LT, LU, MY, MT, MX, NL, NZ, NO, PL, PT, RO, SG, SK, SI, ES, SE, CH] (required) phone: string | null (optional) Responses: 201: addresses object ### GET /api/addresses/{id} Find a addresses by ID [auth required] Responses: 200: addresses object 404: addresses not found ### PATCH /api/addresses/{id} Update a addresses [auth required] Request body (JSON): customer: string (optional) — ID of the users title: string | null (optional) firstName: string | null (optional) lastName: string | null (optional) company: string | null (optional) addressLine1: string | null (optional) addressLine2: string | null (optional) city: string | null (optional) state: string | null (optional) postalCode: string | null (optional) country: string [enum: US, GB, CA, AU, AT, BE, BR, BG, CY, CZ, DK, EE, FI, FR, DE, GR, HK, HU, IN, IE, IT, JP, LV, LT, LU, MY, MT, MX, NL, NZ, NO, PL, PT, RO, SG, SK, SI, ES, SE, CH] (optional) phone: string | null (optional) Responses: 200: addresses object 404: addresses not found ### DELETE /api/addresses/{id} Delete a addresses [auth required] Responses: 200: addresses object 404: addresses not found ## variants ### GET /api/variants Retrieve a list of variants [public] Parameters: page (number, query, optional): limit (number, query, optional): depth (number, query, optional): locale (string, query, optional): fallback-locale (string, query, optional): sort (string, query, optional): where (string, query, optional): Responses: 200: List of variants ### GET /api/variants/{id} Find a variants by ID [public] Responses: 200: variants object 404: variants not found ## variantTypes ### GET /api/variantTypes Retrieve a list of variantTypes [public] Parameters: page (number, query, optional): limit (number, query, optional): depth (number, query, optional): locale (string, query, optional): fallback-locale (string, query, optional): sort (string, query, optional): where (string, query, optional): Responses: 200: List of variantTypes ### GET /api/variantTypes/{id} Find a variantTypes by ID [public] Responses: 200: variantTypes object 404: variantTypes not found ## variantOptions ### GET /api/variantOptions Retrieve a list of variantOptions [public] Parameters: page (number, query, optional): limit (number, query, optional): depth (number, query, optional): locale (string, query, optional): fallback-locale (string, query, optional): sort (string, query, optional): where (string, query, optional): Responses: 200: List of variantOptions ### GET /api/variantOptions/{id} Find a variantOptions by ID [public] Responses: 200: variantOptions object 404: variantOptions not found ## products ### GET /api/products Retrieve a list of products [public] Parameters: page (number, query, optional): limit (number, query, optional): depth (number, query, optional): locale (string, query, optional): fallback-locale (string, query, optional): sort (string, query, optional): where (string, query, optional): Responses: 200: List of products ### GET /api/products/{id} Find a products by ID [public] Responses: 200: products object 404: products not found ## carts ### GET /api/carts Retrieve a list of carts [auth required] Parameters: page (number, query, optional): limit (number, query, optional): depth (number, query, optional): locale (string, query, optional): fallback-locale (string, query, optional): sort (string, query, optional): where (string, query, optional): Responses: 200: List of carts ### POST /api/carts Create a new carts [auth required] Parameters: depth (number, query, optional): locale (string, query, optional): Request body (JSON): items: array | null (optional) secret: string | null (optional) customer: string (optional) — ID of the users purchasedAt: string | null (optional) status: string | null [enum: active, purchased, abandoned] (optional) subtotal: number | null (optional) currency: string | null [enum: USD] (optional) discountCode: string | null (optional) — Applied discount code for this cart Responses: 201: carts object ### GET /api/carts/{id} Find a carts by ID [auth required] Responses: 200: carts object 404: carts not found ### PATCH /api/carts/{id} Update a carts [auth required] Request body (JSON): items: array | null (optional) secret: string | null (optional) customer: string (optional) — ID of the users purchasedAt: string | null (optional) status: string | null [enum: active, purchased, abandoned] (optional) subtotal: number | null (optional) currency: string | null [enum: USD] (optional) discountCode: string | null (optional) — Applied discount code for this cart Responses: 200: carts object 404: carts not found ### DELETE /api/carts/{id} Delete a carts [auth required] Responses: 200: carts object 404: carts not found ## Orders ### 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: page (integer, query, optional): Page number for pagination (defaults to 1) limit (integer, query, optional): Number of orders per page (defaults to 10, max 50) Responses: 200: Paginated order list 401: Not authenticated 500: Internal server error ## orders ### GET /api/orders/{id} Find a orders by ID [auth required] Responses: 200: orders object 404: orders not found ## Contact ### 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. Request body (JSON): name: string [min: 1, max: 150, pattern: ^[\p{L}\p{N}\s\-'.]+$] (required) — Full name of the person submitting the form (e.g. "Jane Doe") email: string [min: 5, max: 320, format: email] (required) — Contact email address (e.g. "jane@example.com") subject: string [min: 1, max: 200] (required) — Subject line for the contact message (e.g. "Partnership inquiry") message: string [min: 1, max: 5000] (required) — Body of the contact message (e.g. "Hi, I would love to discuss a potential partnership. Please let me know a good time to connect.") Responses: 201: Contact form submitted successfully 400: Validation error — missing or invalid fields 500: Internal server error Dashboard: Payload Admin > Contact Form Submissions ## Newsletter ### 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. Request body (JSON): email: string [min: 5, max: 320, format: email] (required) — Email address to subscribe to the newsletter (e.g. "subscriber@example.com") Responses: 200: Successfully subscribed (or already subscribed) 400: Validation error — invalid email address 500: Internal server error Dashboard: Payload Admin > Newsletter Subscribers ## Saved ### 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: 200: Saved listings retrieved successfully 401: Unauthorized — authentication required 500: Internal server error ### 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 (JSON): listingId: integer [min: 1, max: 2147483647] (required) — Numeric ID of the listing to save (e.g. 42) Responses: 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 ### 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: listingId (string, path, required): Numeric ID of the listing to unsave Responses: 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 ### 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: listingId (string, path, required): Numeric ID of the listing to check Responses: 200: Saved-state result 400: Validation error — non-numeric listingId ## Recommendations ### 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}`. Parameters: slug (string, path, required): URL segment of the listing to find neighbours for. limit (integer, query, optional): How many similar listings to return. Responses: 200: Similar listings, most relevant first 400: Invalid slug or limit 404: No published listing has this slug 500: Internal server error ### 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: listingId (string, path, required): The numeric ID of the listing to find similar listings for limit (integer, query, optional): Maximum number of similar listings (default 6, max 12) Responses: 200: Similar listings 400: Validation error — listingId is not a positive integer 404: Listing not found 500: Internal server error ## Inquiries ### 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: box (string, query, optional): `received` for the lister inbox, `sent` for inquiries you sent status (string, query, optional): Filter to a single inquiry status page (integer, query, optional): 1-indexed page number limit (integer, query, optional): Documents per page Responses: 200: Paginated inquiries 400: Validation error — unknown box or status value 401: Unauthorized — authentication required 500: Internal server error ### 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. Request body (JSON): listingId: integer [min: 1, max: 2147483647] (required) — Numeric ID of the listing the renter is asking about (e.g. 42) name: string [min: 1, max: 150] (required) — Name the renter wants the lister to see (e.g. "Mette Sørensen") email: string [min: 5, max: 320, format: email] (required) — Where the lister's reply goes. Ignored in favour of the session email when signed in. (e.g. "mette@example.dk") phone: string [max: 30] (optional) — Optional phone number (e.g. "+45 20 12 34 56") message: string [min: 1, max: 4000] (required) — The opening message of the thread (e.g. "Hej! Er lejligheden stadig ledig fra 1. oktober? Jeg er ikke-ryger og har ingen husdyr.") startDate: string (optional) — Requested start of the rental period — `YYYY-MM-DD` or a full offset-aware timestamp (e.g. "2026-10-01") endDate: string (optional) — Requested end of the rental period. Blank means open-ended. (e.g. "2027-03-31") partySize: integer [min: 0, max: 100000] (optional) — How many people, for venues and event categories (e.g. 40) quantity: integer [min: 0, max: 100000] (optional) — How many units, where a listing is countable (e.g. 6) source: string [enum: listing-detail, listing-card, profile] (optional) — Which surface the inquiry was sent from (e.g. "listing-detail") Responses: 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 Dashboard: Payload Admin > Marketplace > Inquiries Rate limit: 5 requests per 10m ### 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: id (string, path, required): Numeric ID of the inquiry Responses: 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 ### PATCH /api/inquiries/{id} Reply to an inquiry or change its status [auth required] Two actions behind one endpoint, discriminated by `action`. Parameters: id (string, path, required): Numeric ID of the inquiry Request body (JSON): Responses: 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 ## Cart ### 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. Request body (JSON): code: string [min: 1, max: 50] (required) — The discount code to apply to the cart (e.g. "SOMMER20") cartId: integer [min: 1, max: 2147483647] (required) — The ID of the cart to apply the discount to (e.g. 42) secret: string [min: 1, max: 255] (optional) — Cart secret for guest users who are not authenticated but own the cart (e.g. "a1b2c3d4-e5f6-7890-abcd-ef1234567890") Responses: 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 Dashboard: Use from the basket or /checkout by entering a discount code and pressing Apply. ### 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. Request body (JSON): cartId: integer [min: 1, max: 2147483647] (required) — The ID of the cart to remove the discount from (e.g. 42) secret: string [min: 1, max: 255] (optional) — Cart secret for guest users who are not authenticated but own the cart (e.g. "a1b2c3d4-e5f6-7890-abcd-ef1234567890") Responses: 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 Dashboard: Use from the basket or /checkout by clicking the remove button next to the applied code. ## Discounts ### 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. Request body (JSON): code: string [min: 1, max: 50] (required) — The discount code to validate (e.g. "VELKOMMEN10") customerEmail: string [min: 3, max: 320, format: email] (optional) — Customer email for per-customer usage limit checks (e.g. "mette@eksempel.dk") subtotal: integer [min: 0, max: 99999999] (optional) — Cart subtotal in DKK øre for the minimum-order check and discount calculation (e.g. 14900) Responses: 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 Rate limit: 10 requests per 60s ## Payments ### 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. Request body (JSON): paymentIntentId: string [min: 1, max: 255] (required) — The Stripe PaymentIntent ID (e.g. "pi_3Oc0X2Abc123def456") discountCode: string [min: 1, max: 50] (optional) — Optional discount code to apply to the payment (e.g. "SOMMER20") Responses: 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 Rate limit: 20 requests per 60s ## Search ### 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: q (string, query, required): Full-text search query limit (integer, query, optional): Maximum number of results per group (default 10, max 20) locale (string, query, optional): Locale for editorial content (categories, guides) Responses: 200: Matched listings, categories and guides 500: Internal server error ### 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. Parameters: q (string, query, required): Prefix text for autocomplete suggestions limit (integer, query, optional): Maximum number of suggestions (default 5, max 10) locale (string, query, optional): Locale for category names in the fallback suggestions Responses: 200: Autocomplete suggestions 429: Too many requests — 30 suggestions per minute per IP 500: Internal server error Rate limit: 30 requests per 1m ## Documentation ### GET /api/openapi.json OpenAPI 3.1 specification (JSON) [public] Returns this API's full OpenAPI 3.1 specification as JSON. Use this to generate client SDKs, import into API tools (Postman, Insomnia), or power interactive documentation UIs. Responses: 200: OpenAPI 3.1 JSON specification ### GET /llms.txt LLM-friendly API reference (plain text) [public] Returns the full API reference as structured plain text optimized for LLM context windows. Use this to feed API documentation into AI assistants, chatbots, or code generators. Responses: 200: Plain-text API reference ### GET /to-humans.md Human-readable API reference (Markdown) [public] Returns the full API reference as a Markdown document with table of contents, request/response tables, cURL examples, and error reference. Suitable for rendering in documentation sites or reading directly. Responses: 200: Markdown API reference