Skip to content

Owning.pro — API Reference

Status: Live (production) Base URL: https://api.owning.pro Version: v1 Author: Sage Moreno (Technical Writer & Documentation Lead) Last updated: 2026-08-01 (R58 — Added GET /api/listings/compare, POST /api/searches, GET /api/searches, DELETE /api/searches/:id endpoint documentation) Source code: services/api/src/routes/

Overview

Owning.pro is a nautical marketplace — buy and sell boats and nautical equipment. The API is RESTful, returns JSON by default, and also serves listings as Markdown for AI agent consumption.

Key features

  • API-first: Every feature is accessible via REST endpoints.
  • Agent-friendly: Listings available as Markdown (.md suffix), agent

discovery via /.well-known/ai.json, OpenAPI spec at /api/openapi.json.

  • Dual response formats: JSON for applications, Markdown for AI agents.
  • 7 locales: Content available in en, es, fr, de, it, ko, ja.
  • Rate limited: Per-IP and per-API-key limits with X-RateLimit-* headers.
  • Cached: CDN-cached GET responses with Cache-Control headers.

Interactive docs

  • Scalar UI: https://api.owning.pro/api/docs — interactive API explorer.
  • OpenAPI JSON: https://api.owning.pro/api/openapi.json
  • OpenAPI YAML: https://api.owning.pro/api/openapi.yaml
  • Machine schema: https://api.owning.pro/api/schema

Authentication

Auth methods

Method Header Use case
API Key Authorization: Bearer own_... Agents, integrations, programmatic access
JWT Authorization: Bearer <jwt> Web app sessions (login via /api/auth/login)

API Keys

API keys are prefixed with own_ and have three permission levels:

Permission Access
read GET-only — search, browse, view listings
write read + create, update, delete listings (default)
admin write + management operations

Creating an API key

# 1. Login to get a JWT
curl -X POST https://api.owning.pro/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"you@example.com","password":"..."}'

# 2. Create an API key using the JWT
curl -X POST https://api.owning.pro/api/api-keys \
  -H "Authorization: Bearer <jwt>" \
  -H "Content-Type: application/json" \
  -d '{"name":"My Agent","permissions":"write"}'

Response (201):

{
  "id": "01JX2K3M4N5P6Q7R8S9T0U",
  "key": "own_aBcDeFgHiJkLmNoPqRsTuVwXyZ1234567890",
  "key_prefix": "own_aBcDeF",
  "label": "My Agent",
  "permissions": "write",
  "created_at": "2026-07-10T08:30:00Z",
  "message": "Store this API key securely. It will not be shown again."
}
The plaintext key is only shown once. Store it securely. Subsequent requests only show the key_prefix.

Public endpoints (no auth required)

All GET endpoints for browsing listings, categories, schema, and search are public. Write operations (POST, PUT, DELETE, PATCH) require authentication.


Error Handling

All errors follow a consistent shape:

{
  "error": {
    "code": "not_found",
    "message": "Listing not found",
    "details": {}
  }
}

Error codes

Code HTTP Status Meaning
bad_request 400 Malformed request
validation_error 400 Request body/query failed validation
unauthorized 401 Missing or invalid auth token
forbidden 403 Authenticated but insufficient permissions
not_found 404 Resource not found
conflict 409 Conflict with existing resource
rate_limited 429 Rate limit exceeded (see Retry-After header)
internal_error 500 Unexpected server error

Rate Limiting

Rate limits are applied per IP and per API key. Responses include headers:

Header Description
X-RateLimit-Limit Max requests in the window
X-RateLimit-Remaining Remaining requests
X-RateLimit-Reset Unix timestamp when the window resets
X-RateLimit-Policy IETF draft format: `{limit};w=60;scope={ip user}`
Retry-After Seconds to wait (only on 429 responses)

Per-endpoint-type limits (R53 — updated)

Endpoint type Limit Keyed by Notes
Read (GET /api/*) — public 200/min IP Browse, search, view listings
Read (GET /api/*) — authenticated 600/min User Higher limit for logged-in users
Write (POST/PUT/PATCH/DELETE) 30/min User Create, update, delete operations
Auth (login/register) 10/min IP Anti brute-force
Upload 20/min User File uploads
Admin 1000/min Admin user R53: bumped from 100/min — admin dashboard widgets poll frequently; endpoints are token-protected

Specific limits

Endpoint Limit
Listing creation 10/day per user + 20/day per IP
AI generate 5/hour per user
Contact seller 5/hour per IP

Exempt paths (no rate limiting)

  • /health, /health/db — monitoring systems
  • /.well-known/ai.json — agent discovery crawls
  • /robots.txt — crawler requests

Response Caching (R53 — expanded)

The API uses an in-memory LRU cache for GET responses. Cached responses include X-Cache: HIT (or MISS) headers for debugging.

Cache configuration

Pattern TTL Notes
/api/listings (collection) 60s JSON + Markdown formats
/api/listings/:id (detail) 120s JSON + Markdown formats
/api/categories 300s Tree, flat, detail
/api/asset-types 3600s Effectively static
/.well-known/ai.json 3600s Agent discovery
/robots.txt 3600s Static
/api/search/suggest 60s NEW (R53) — was hitting DB on every request (270ms)
/api/pricing/recommend 300s NEW (R53) — expensive aggregates that change slowly
/api/pricing/assess 300s NEW (R53) — same rationale as recommend

Cache features

  • Max 500 entries with LRU eviction
  • Periodic cleanup of expired entries (every 5 min)
  • Authenticated requests are NOT cached (responses may differ per user)
  • Only 200 responses are cached (errors are never cached)
  • X-Cache: HIT / X-Cache: MISS headers for debugging

Cache invalidation (R53 — new)

  • invalidateSearchCache() — invalidates search + listings cache entries; called after

ingest operations (POST /api/ingest, POST /api/ingest/publish-bulk, POST /api/ingest/nautical) to refresh browse results when new listings are added

  • invalidateAllCache() — full cache flush for bulk operations

Locale Handling

Owning supports 7 locales. Locale is determined by:

  1. URL path prefix: /es/listings/..., /fr/listings/... (frontend)
  2. Accept-Language header: Accept-Language: es (API)
  3. Query parameter: ?lang=es (API, overrides header)
  4. Default: en (English)

Supported locales

Code Language
en English (default)
es Spanish
fr French
de German
it Italian
ko Korean
ja Japanese

Rewritten content

Listings scraped from external portals have their descriptions rewritten by the LLM pipeline into unique, SEO-optimized content. The rewritten content is stored per-locale in the seo_data JSONB column. When requesting a listing, the API returns the rewritten content for the requested locale if available, falling back to the original description.


Nautical Categories

Owning uses 9 nautical categories (post-pivot):

Category ID Name Subcategories
sailboats Sailboats Cruising, Racing, Catamarans, Trimarans
motorboats Motorboats Sport, Flybridge, Trawlers, Express
yachts Yachts Motor Yachts, Sailing Yachts, Superyachts
ribs RIBs Rigid Inflatable Boats
houseboats Houseboats
small-boats Small Boats Dinghies, Rowboats, Kayaks
engines Engines Outboard, Inboard
trailers Trailers
nautical-equipment Nautical Equipment Electronics, Safety, Anchoring, Sails
Category IDs are validated against FLAT_CATEGORIES in @owning/shared/constants. Invalid category IDs return a 400 error with the list of valid categories.

Endpoints — Listings

Source: services/api/src/routes/listings.ts

GET /api/listings — Search listings

Search and filter listings with pagination. Public, no auth required.

Supports two response formats:

  • JSON (default): GET /api/listings
  • Markdown: GET /api/listings.md or Accept: text/markdown

Query parameters

Parameter Type Default Description
q string Full-text search query (PostgreSQL tsvector)
category string Category ID (e.g., sailboats, motorboats)
condition enum new, like_new, good, fair, poor, refurbished
min_price number Minimum price
max_price number Maximum price
country string ISO 3166-1 alpha-2 country code (e.g., ES, FR)
city string City name
shipping boolean Filter by shipping availability
type enum sale, wanted
status string active Listing status (default: active only)
sort enum newest newest, oldest, price_asc, price_desc, relevance
Sort options (R55 — planned fix): Kai is tasked with fixing invalid sort options in R55. Some sort values may not be properly validated or indexed, causing unexpected behavior. The relevance sort requires a q parameter (full-text search) — using it without q falls back to newest.
page integer 1 Page number
limit integer 20 Results per page (max: 100)
attr[key] string Dynamic attribute filter (e.g., attr[brand]=Hanse)
attr[key_min] number Attribute range minimum (e.g., attr[length_min]=8)
attr[key_max] number Attribute range maximum (e.g., attr[length_max]=12)
Attribute filters: When using attr[...] filters without a category parameter, the API attempts to infer the asset type from the attribute keys. If it can't uniquely map them, it returns a 400 asking for ?category=.

Response (200)

{
  "results": [
    {
      "id": "lst_01JX2K3M4N5P6Q7R8S9T0U",
      "slug": "hanse-315-2018-palma-de-mallorca",
      "title": "Hanse 315 (2018) — Palma de Mallorca",
      "description": "Rewritten unique content...",
      "condition": "good",
      "type": "sale",
      "price": { "amount": 85000, "currency": "EUR", "negotiable": true },
      "category": { "id": "sailboats", "name": "Sailboats", "path": ["sailboats"] },
      "location": { "country": "ES", "city": "Palma de Mallorca", "shipping": false },
      "seller": { "id": "usr_abc123", "name": "Broker Name", "type": "agent", "verified": false, "member_since": "2026-07-10" },
      "status": "active",
      "images": [{ "url": "https://...", "alt": "Hanse 315 exterior" }],
      "tags": ["hanse", "sailboat", "spain"],
      "views": 142,
      "created_at": "2026-07-21T10:00:00Z",
      "updated_at": "2026-07-21T16:00:00Z",
      "meta": { "source": "api", "language": "en" }
    }
  ],
  "pagination": { "page": 1, "limit": 20, "total": 8381, "pages": 420 }
}

Markdown response

Request GET /api/listings.md or send Accept: text/markdown to receive a Markdown catalog of listings with YAML frontmatter. Designed for AI agents.

Example

# Search for sailboats in Spain under 100K
curl "https://api.owning.pro/api/listings?category=sailboats&country=ES&max_price=100000&sort=price_asc&limit=10"

# Full-text search
curl "https://api.owning.pro/api/listings?q=beneteau+oceanis&limit=5"

# Attribute filter (length range)
curl "https://api.owning.pro/api/listings?category=sailboats&attr[length_min]=10&attr[length_max]=15"

# Markdown format for AI agents
curl "https://api.owning.pro/api/listings.md?category=sailboats&limit=5"

GET /api/listings/:id — Get a single listing

Retrieve a single listing by ID or slug. Public, no auth required.

The :id parameter accepts either:

  • Listing ID: lst_01JX2K3M4N5P6Q7R8S9T0U
  • Slug: hanse-315-2018-palma-de-mallorca

Response (200)

Full listing object with all fields (see Boat Schema below).

curl https://api.owning.pro/api/listings/lst_01JX2K3M4N5P6Q7R8S9T0U
curl https://api.owning.pro/api/listings/hanse-315-2018-palma-de-mallorca

Markdown format

Append .md to get the listing as Markdown with YAML frontmatter:

curl https://api.owning.pro/api/listings/lst_01JX2K3M4N5P6Q7R8S9T0U.md

Response (text/markdown):

---
id: "lst_01JX2K3M4N5P6Q7R8S9T0U"
title: "Hanse 315 (2018)"
price: 85000
currency: "EUR"
condition: "good"
---

# Hanse 315 (2018)

**Price:** €85,000 (negotiable)
...
Security: Non-public statuses (draft, expired, flagged) are only visible to the listing owner (requires auth with owner's token).

POST /api/listings — Create a listing

Create a new listing. Requires auth (write permission). Listings are created as draft status — publish with POST /:id/publish.

Rate limits: 10 listings/day per user + 20/day per IP.

Request body

{
  "title": "Hanse 315 (2018) — Palma de Mallorca",
  "description": "Well-maintained cruising sailboat...",
  "category_id": "sailboats",
  "condition": "good",
  "type": "sale",
  "price": { "amount": 85000, "currency": "EUR", "negotiable": true },
  "location": { "country": "ES", "city": "Palma de Mallorca", "shipping": false },
  "images": [{ "url": "https://cdn.owning.pro/img/01.jpg", "alt": "Exterior" }],
  "attributes": { "brand": "Hanse", "model": "315", "year": 2018, "length": 9.5 },
  "tags": ["hanse", "sailboat", "spain"]
}

Response (201)

Full listing object with generated id, slug, seller, and timestamps.


POST /api/listings/ai-generate — AI listing draft

Generate a listing draft from uploaded photos using AI. Requires auth.

Rate limit: 5 generations/hour per user.

Request body

{ "images": ["https://cdn.owning.pro/img/photo1.jpg", "https://cdn.owning.pro/img/photo2.jpg"] }

Response (200)

Returns a structured listing draft (title, description, suggested category, detected attributes). When OPENROUTER_API_KEY is not set, runs in stub mode with mock data.


PUT /api/listings/:id — Update a listing

Update an existing listing. Owner only. Requires auth (write permission).

Request body

Same shape as POST (partial update — omitted fields are unchanged).


DELETE /api/listings/:id — Delete a listing

Hard delete a listing. Owner only. Requires auth.

Response: { "deleted": true, "id": "lst_..." }


PATCH /api/listings/:id — Change status

Change listing status. Owner only. Allowed values: active, paused, sold.

curl -X PATCH https://api.owning.pro/api/listings/lst_... \
  -H "Authorization: Bearer own_..." \
  -H "Content-Type: application/json" \
  -d '{"status":"sold"}'

POST /api/listings/:id/publish — Publish a draft

Move a listing from draftactive. Owner only. Sends a listing-approved email to the owner.


POST /api/listings/:id/mark-sold — Mark as sold

Move a listing to sold status. Owner only. Sends a listing-sold email.


POST /api/listings/:id/renew — Renew a listing

Extend expiration by 30 days and set status to active. Owner only.


POST /api/listings/:id/contact — Contact the seller

Send a message to the listing owner via email. Public, no auth required. The seller's email is never exposed in the API.

Rate limit: 5 contacts/hour per IP.

Request body

{
  "name": "John Buyer",
  "email": "john@example.com",
  "message": "I'm interested in this boat. Is it still available?"
}

Response (201): { "message": "Your message has been sent to the listing owner." }


POST /api/listings/:id/flag — Flag for moderation

Flag a listing for moderation review. Requires auth. The listing transitions to flagged status.

Request body

{
  "reason": "spam",
  "description": "This listing appears to be a duplicate."
}

Reasons: spam, fraud, inappropriate, duplicate, illegal, other

Response (201): { "flag_id": "...", "listing_id": "...", "listing_status": "flagged", "reason": "duplicate", "created_at": "..." }


GET /api/listings/compare — Compare listings side-by-side

Retrieve up to 4 listings in a comparison-optimized format. Returns a subset of listing fields plus extracted nautical specifications from boat_specs, designed for side-by-side comparison tables. Public, no auth required.

Only listings with public statuses (active, paused, sold) are returned. IDs that don't exist or are not publicly visible are silently omitted from the results — the response may contain fewer listings than requested.

Cache: public, max-age=300, s-maxage=600, stale-while-revalidate=1200 (5 min CDN, 10 min SWR).

Query parameters

Parameter Type Required Description
ids string (comma-separated) Yes Listing IDs to compare, e.g. ids=lst_abc,lst_def. Maximum 4 IDs. Duplicates are silently deduplicated. Each ID must start with lst_.

Response (200)

{
  "results": [
    {
      "id": "lst_01JX2K3M4N5P6Q7R8S9T0U",
      "slug": "hanse-315-2018-palma-de-mallorca",
      "title": "Hanse 315 (2018) — Palma de Mallorca",
      "category": {
        "id": "sailboats",
        "name": "Sailboats",
        "path": ["sailboats"]
      },
      "condition": "good",
      "type": "sale",
      "price": {
        "amount": 85000,
        "currency": "EUR",
        "negotiable": true
      },
      "location": {
        "country": "ES",
        "city": "Palma de Mallorca"
      },
      "image": "https://static.owning.pro/img/01.jpg",
      "year": 2018,
      "brand": "Hanse",
      "model": "315",
      "loa_m": 9.5,
      "beam_m": 3.2,
      "draft_m": 1.5,
      "displacement_kg": 3400,
      "hull_material": "fiberglass",
      "hull_type": "monohull",
      "engine_brand": "Volvo Penta",
      "engine_model": "D1-20",
      "engine_type": "inboard",
      "fuel_type": "diesel",
      "engine_power_hp": 20,
      "engine_hours": 850,
      "cabins": 2,
      "berths": 4,
      "heads": 1,
      "fuel_capacity_l": 80,
      "water_capacity_l": 120,
      "boat_specs": { "specs": { "dimensions": { "length_overall_m": 9.5 } } },
      "seller": {
        "id": "usr_01JX...",
        "name": "Marine Broker SL",
        "type": "broker",
        "verified": true
      },
      "status": "active",
      "created_at": "2026-07-15T10:30:00Z"
    }
  ],
  "count": 1,
  "requested": 1
}
Field Type Description
results ComparisonListing[] Array of comparison-optimized listing objects (in the same order as the input IDs)
count integer Number of listings returned (may be less than requested if some IDs were not found)
requested integer Number of unique IDs requested

ComparisonListing fields

Field Type Description
id string Listing ID (lst_...)
slug string URL-friendly slug
title string Listing title
category object { id, name, path } — category reference
condition string Item condition
type string sale or wanted
price object { amount, currency, negotiable }
location object { country, city } (values may be null)
image string \ null Primary image URL
year number \ null Build year (from boat_specs)
brand string \ null Boat brand (from boat_specs)
model string \ null Boat model (from boat_specs)
loa_m number \ null Length overall in meters
beam_m number \ null Beam in meters
draft_m number \ null Draft in meters
displacement_kg number \ null Displacement in kg
hull_material string \ null Hull material (e.g. fiberglass)
hull_type string \ null Hull type (e.g. monohull, catamaran)
engine_brand string \ null Engine brand
engine_model string \ null Engine model
engine_type string \ null Engine type (inboard, outboard)
fuel_type string \ null Fuel type (diesel, petrol, electric)
engine_power_hp number \ null Engine power in HP
engine_hours number \ null Engine hours
cabins number \ null Number of cabins
berths number \ null Number of berths
heads number \ null Number of heads (bathrooms)
fuel_capacity_l number \ null Fuel tank capacity in liters
water_capacity_l number \ null Water tank capacity in liters
boat_specs object \ null Full boat_specs JSON for any additional fields
seller object { id, name, type, verified } — seller info
status string Listing status (active, paused, sold)
created_at datetime Creation timestamp

Example

# Compare 3 listings
curl "https://api.owning.pro/api/listings/compare?ids=lst_01JX...,lst_02JX...,lst_03JX..."

Error responses

Status Code Message Details
400 bad_request "Missing required parameter: ids" { hint, max_listings: 4 }
400 bad_request "No valid listing IDs provided in ids parameter"
400 bad_request "Too many listing IDs: N. Maximum is 4." { max_listings: 4, provided: N }
400 bad_request "Invalid listing ID format. IDs must start with 'lst_'." { invalid_ids: [...] }

Endpoints — Categories

Source: services/api/src/routes/categories.ts

GET /api/categories — List all categories (tree)

Returns all categories as a hierarchical tree with listing counts. Public.

Query parameters

Parameter Type Default Description
hide_empty boolean false Prune categories with 0 items (keeps parents with non-empty children)

Response (200)

{
  "categories": [
    {
      "id": "sailboats",
      "name": "Sailboats",
      "slug": "sailboats",
      "count": 1240,
      "children": [
        { "id": "sailboats-cruising", "name": "Cruising", "slug": "cruising", "count": 800 }
      ]
    }
  ],
  "total": 9
}
Counts: Parent category counts are aggregate (include all descendant listings). Counts use the same deduplication logic as the listings endpoint (DISTINCT ON title, seller_id, price_amount) to avoid inflated numbers.

GET /api/categories/flat — Flat category list

Returns all categories as a flat array with counts. Public.

Response (200)

{
  "categories": [
    { "id": "sailboats", "name": "Sailboats", "slug": "sailboats", "parentId": null, "path": ["sailboats"], "count": 1240 },
    { "id": "sailboats-cruising", "name": "Cruising", "slug": "cruising", "parentId": "sailboats", "path": ["sailboats", "cruising"], "count": 800 }
  ],
  "total": 15
}

GET /api/categories/:id — Get a single category

Get category details by ID or slug. Public.

Response (200)

{
  "id": "sailboats",
  "name": "Sailboats",
  "slug": "sailboats",
  "path": ["sailboats"],
  "parentId": null,
  "count": 1240,
  "children": [
    { "id": "sailboats-cruising", "name": "Cruising", "slug": "cruising", "count": 800 }
  ]
}

Source: services/api/src/routes/search.ts

GET /api/search/suggest — Autocomplete suggestions

Returns autocomplete suggestions for a partial search query. Designed for client-side search bars with debounce (200-300ms recommended). Public.

Query parameters

Parameter Type Default Description
q string required Partial search query (min 2 chars, max 100)
limit integer 8 Max suggestions (max: 10)

Response (200)

{
  "query": "han",
  "suggestions": [
    { "type": "category", "text": "Sailboats", "category_id": "sailboats", "category_path": ["sailboats"] },
    { "type": "listing", "text": "Hanse 315 (2018) — Palma de Mallorca", "listing_id": "lst_...", "slug": "hanse-315-2018-palma-de-mallorca" },
    { "type": "term", "text": "hanse 315", "count": 42 }
  ]
}

Suggestion sources (merged, deduplicated, max limit total):

  1. Categories — prefix match on category names/IDs (in-memory, instant)
  2. Popular listing titles — trigram-accelerated ILIKE, ranked by view count
  3. Frequent search terms — from analytics, grouped by frequency (last 30 days)

Performance: Target <100ms. Category matching is in-memory. DB queries run in parallel via Promise.all. Uses GIN trigram indexes for fast ILIKE.


Endpoints — Saved Searches

Source: services/api/src/routes/searches.ts

R58 — New. CRUD endpoints for saved searches with structured query params. These endpoints reuse the saved_searches table (shared with /api/me/saved-searches) but expose a typed, structured query_params shape: { category, brand, location, price_min, price_max }. All endpoints require authentication (API key or JWT).

POST /api/searches — Create a saved search

Save a named search with structured filter criteria for the authenticated user. Requires auth.

Request body

{
  "name": "Hanse sailboats under €100K in Spain",
  "query_params": {
    "category": "sailboats",
    "brand": "Hanse",
    "location": "ES",
    "price_min": 0,
    "price_max": 100000
  }
}
Field Type Required Description
name string (1-100) Yes User-friendly name for the saved search
query_params object Yes Structured search criteria (see below)
query_params.category string (max 100) No Category ID (e.g. sailboats, motorboats)
query_params.brand string (max 100) No Boat brand (e.g. Hanse, Beneteau)
query_params.location string (max 200) No Location filter (country code, city, or region)
query_params.price_min number (≥ 0) No Minimum price
query_params.price_max number (≥ 0) No Maximum price

Response (201)

{
  "id": "ssrch_01JX2K3M4N5P6Q7R8S9T0U",
  "name": "Hanse sailboats under €100K in Spain",
  "query_params": {
    "category": "sailboats",
    "brand": "Hanse",
    "location": "ES",
    "price_min": 0,
    "price_max": 100000
  },
  "created_at": "2026-08-01T09:45:00Z"
}

Example

curl -X POST https://api.owning.pro/api/searches \
  -H "Authorization: Bearer own_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Hanse sailboats under €100K in Spain",
    "query_params": { "category": "sailboats", "brand": "Hanse", "location": "ES", "price_max": 100000 }
  }'

Error responses

Status Code Message Details
400 validation_error Validation failed (Zod) Field-level errors from Zod schema validation
401 unauthorized "Authentication required" Missing or invalid auth token
500 internal_error "Failed to save search" Database insert failed

GET /api/searches — List saved searches

List the authenticated user's saved searches, paginated and ordered by creation date (newest first). Requires auth.

Query parameters

Parameter Type Default Description
page integer 1 Page number (min: 1)
limit integer 20 Results per page (min: 1, max: 50)

Response (200)

{
  "results": [
    {
      "id": "ssrch_01JX2K3M4N5P6Q7R8S9T0U",
      "name": "Hanse sailboats under €100K in Spain",
      "query_params": {
        "category": "sailboats",
        "brand": "Hanse",
        "location": "ES",
        "price_min": 0,
        "price_max": 100000
      },
      "created_at": "2026-08-01T09:45:00Z"
    },
    {
      "id": "ssrch_01JX2K3M4N5P6Q7R8S9T0V",
      "name": "Motorboats in Mediterranean",
      "query_params": {
        "category": "motorboats",
        "location": "Mediterranean"
      },
      "created_at": "2026-07-28T14:20:00Z"
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "total": 2,
    "pages": 1,
    "has_more": false,
    "next_page": null,
    "next_cursor": null,
    "cursor_mode": false
  }
}
Field Type Description
results SavedSearch[] Array of saved search objects
pagination Pagination Pagination metadata (see below)

SavedSearch object:

Field Type Description
id string Saved search ID (ssrch_...)
name string User-friendly name
query_params object Structured search criteria { category?, brand?, location?, price_min?, price_max? }
created_at datetime Creation timestamp (ISO 8601)

Pagination object:

Field Type Description
page integer Current page number
limit integer Results per page
total integer Total results count
pages integer Total page count
has_more boolean Whether more pages exist
next_page integer \ null Next page number (null if no more pages)

Example

curl "https://api.owning.pro/api/searches?page=1&limit=20" \
  -H "Authorization: Bearer own_..."

Error responses

Status Code Message Details
400 validation_error Validation failed (Zod) Invalid page or limit values
401 unauthorized "Authentication required" Missing or invalid auth token

Delete a saved search. Only the owner can delete their own saved searches. Requires auth.

Path parameters

Parameter Type Description
id string Saved search ID (e.g. ssrch_01JX...)

Response (200)

{
  "id": "ssrch_01JX2K3M4N5P6Q7R8S9T0U",
  "deleted": true
}

Example

curl -X DELETE https://api.owning.pro/api/searches/ssrch_01JX2K3M4N5P6Q7R8S9T0U \
  -H "Authorization: Bearer own_..."

Error responses

Status Code Message Details
400 validation_error Validation failed (Zod) Invalid ID format
401 unauthorized "Authentication required" Missing or invalid auth token
403 forbidden "You can only delete your own saved searches" Authenticated user is not the owner
404 not_found "Saved search not found" No saved search with that ID exists

Endpoints — Blog (Frontend)

Note: Blog posts are served by the Next.js frontend (owning.pro), not the API service. There are no /api/blog/* API endpoints. Blog content is defined as TypeScript objects in apps/web/lib/blog/ and rendered as SSR pages by Next.js.

Blog post pages

URL Description
GET /blog Blog index page (lists all posts)
GET /blog/:slug Individual blog post page

Current blog posts (R47 — 10 posts)

Source: apps/web/lib/blog/posts-phase25.tsx

# Slug Title
1 boating-in-spain-complete-guide Boating in Spain: Complete Guide
2 boating-in-france-complete-guide Boating in France: Complete Guide
3 boating-in-the-netherlands-complete-guide Boating in the Netherlands: Complete Guide
4 boating-in-mallorca-ultimate-guide Boating in Mallorca: The Ultimate Guide
5 boating-in-italy-complete-guide Boating in Italy: Complete Guide
6 boating-in-the-uk-complete-guide Boating in the UK: Complete Guide
7 boat-valuation-guide-how-to-value-a-used-boat Boat Valuation Guide
8 affordable-sailboats-best-budget-sailboats-for-cruising Affordable Sailboats
9 boat-transport-cost-how-much-does-it-cost-to-transport-a-boat Boat Transport Cost
10 catamarans-for-sale-what-to-know-before-buying Catamarans for Sale

Blog post data structure

interface BlogPost {
  slug: string;
  title: string;
  description: string;
  category: "guide" | "news" | "market";
  excerpt: string;
  publishedAt: string;       // ISO date
  readingTime: number;       // minutes
  author: string;
  keywords: string[];
  content: () => JSX.Element; // React component
}
Adding a blog post: See docs/contributor-guide.md for step-by-step instructions.

Endpoints — Rentals

Status (R52):Live — both web and API are deployed. The API was redeployed in R49 (commit 1b16706f), making the rentals endpoints available at api.owning.pro/api/rentals. Verified: GET /api/rentals returns 200. Migration 0038 (R52 — APPLIED): The rental_rates, availability, rental_terms, rental_location, and max_passengers columns and the rental_bookings table are now live in production. Migration 0038 was missing from the Drizzle _journal.json (fixed by Atlas in commit 2202514d), and was deployed successfully in R51 close (commit 7d63a4e5, deploy 8075aa01). The 1,875 rewriting failures caused by the missing rental_rates column are now resolved. The listing_type enum has been extended with the rental value. Migration 0040 (R52 — APPLIED): Adds the last_error column to the migration_state table, enabling the dedup background job to track and report errors per group. Deployed in commit 08d3b1f9 (deploy ad5a12c4).

Source: services/api/src/routes/rentals.ts

GET /api/rentals — Search rental listings

Search and filter rental listings with date-based availability filtering. Public, no auth required.

Supports two response formats:

  • JSON (default): GET /api/rentals
  • Markdown: GET /api/rentals.md or Accept: text/markdown

Query parameters

Parameter Type Default Description
date_from date (ISO) Available from this date (e.g., 2026-08-01)
date_to date (ISO) Available until this date (e.g., 2026-08-07)
price_per_day_min number Minimum price per day
price_per_day_max number Maximum price per day
location string Location filter (city, country, or region)
boat_type string Boat type (e.g., sailboat, motorboat, yacht, catamaran)
country string ISO 3166-1 alpha-2 country code
sort enum newest newest, oldest, price_asc, price_desc
page integer 1 Page number
limit integer 20 Results per page (max: 100)

Response (200)

{
  "results": [
    {
      "id": "rent_01JX...",
      "slug": "beneteau-oceanis-40-rental-palma",
      "title": "Beneteau Oceanis 40 — Rental in Palma de Mallorca",
      "boat_type": "sailboat",
      "price_per_day": 450,
      "currency": "EUR",
      "location": { "country": "ES", "city": "Palma de Mallorca" },
      "availability": { "available_from": "2026-08-01", "available_to": "2026-08-31" },
      "images": [{ "url": "https://...", "alt": "Beneteau Oceanis 40" }],
      "status": "active",
      "created_at": "2026-07-28T10:00:00Z"
    }
  ],
  "pagination": { "page": 1, "limit": 20, "total": 0, "pages": 0 }
}
Note: Rental listings ingestion is still pending — the total may be 0 until rental data is ingested. The endpoint is live and functional.

Example

# Search for sailboat rentals in Spain
curl "https://api.owning.pro/api/rentals?boat_type=sailboat&country=ES&limit=10"

# Search by date range
curl "https://api.owning.pro/api/rentals?date_from=2026-08-01&date_to=2026-08-07&limit=10"

# Markdown format for AI agents
curl "https://api.owning.pro/api/rentals.md?limit=5"

GET /api/rentals/:id — Get a single rental listing

Retrieve a single rental listing by ID or slug. Public, no auth required.

curl https://api.owning.pro/api/rentals/rent_01JX...

POST /api/rentals — Create a rental listing

Create a new rental listing. Requires auth (write permission).

Request body

{
  "title": "Beneteau Oceanis 40 — Rental in Palma de Mallorca",
  "boat_type": "sailboat",
  "price_per_day": 450,
  "currency": "EUR",
  "location": { "country": "ES", "city": "Palma de Mallorca" },
  "availability": { "available_from": "2026-08-01", "available_to": "2026-08-31" },
  "images": [{ "url": "https://cdn.owning.pro/img/01.jpg", "alt": "Exterior" }],
  "description": "Well-maintained cruising sailboat available for weekly rental..."
}

GET /api/rentals/availability — Check availability

Check rental availability for a specific date range. Public.

Query parameters

Parameter Type Description
id string Rental listing ID
date_from date (ISO) Start date
date_to date (ISO) End date
curl "https://api.owning.pro/api/rentals/availability?id=rent_01JX...&date_from=2026-08-01&date_to=2026-08-07"

Endpoints — Schema & Discovery

GET /api/schema — Full API schema

Machine-readable description of the entire API surface: all endpoints, parameters, auth requirements, response formats, and core data types. Public.

curl https://api.owning.pro/api/schema

GET /api/schema/listing — JSON Schema

JSON Schema (draft 2020-12) for the Listing data type. Public.

curl https://api.owning.pro/api/schema/listing

GET /api/schema/listing.md — Schema as Markdown

Markdown documentation of the listing schema. Public.

curl https://api.owning.pro/api/schema/listing.md

GET /api/openapi.json — OpenAPI 3.1 spec

Full OpenAPI 3.1 specification as JSON. Consumable by Scalar, Swagger UI, Postman, or any OpenAPI-compatible tool. Public.

GET /api/openapi.yaml — OpenAPI 3.1 as YAML

Same spec as JSON, in YAML format. Public.

GET /api/docs — Scalar API Reference UI

Interactive API reference rendered by Scalar. Public.

GET /.well-known/ai.json — Agent discovery

Agent discovery document per RFC 8615. Lets AI agents auto-discover the API surface, auth scheme, rate limits, and schema references. Public.

curl https://owning.pro/.well-known/ai.json

GET /robots.txt — Robots

Standard robots.txt. Public.


Endpoints — Auth & Users

Source: services/api/src/routes/auth.ts

POST /api/auth/register — Register

Create a new user account. Public.

{ "email": "you@example.com", "password": "...", "name": "Your Name" }

Response (201): { "user": {...}, "token": "<jwt>" }

POST /api/auth/login — Login

Login with email and password. Public.

{ "email": "you@example.com", "password": "..." }

Response (200): { "user": {...}, "token": "<jwt>" }

POST /api/api-keys — Create API key

Create a new API key. Requires JWT auth. Returns the plaintext key once.

{ "name": "My Agent", "permissions": "write" }

Response (201): { "id": "...", "key": "own_...", "key_prefix": "own_aBc", "label": "My Agent", "permissions": "write", "created_at": "...", "message": "..." }

GET /api/api-keys — List API keys

List your API keys (plaintext never returned). Requires JWT auth.

DELETE /api/api-keys/:id — Revoke API key

Soft-delete an API key (stops working immediately). Requires JWT auth.

GET /api/me — Get current user

Get the authenticated user's profile. Requires auth.

GET /api/me/listings — Get my listings

List the authenticated user's listings. Requires auth.

GET /api/users/:id — Public user profile

Get a public user profile (name, verified, rating, listing count). Public.

GET /api/users/:id/listings — User's active listings

Get a user's active listings. Public.


Endpoints — User Collections

Source: services/api/src/routes/user-collections.ts

Endpoint Method Auth Description
/api/me/favorites GET Yes List user's favorited listings
/api/me/favorites POST Yes Add a listing to favorites
/api/me/favorites/:listingId DELETE Yes Remove a favorite
/api/me/saved-searches GET Yes List saved searches
/api/me/saved-searches POST Yes Save a search
/api/me/saved-searches/:id DELETE Yes Delete a saved search

Endpoints — Messaging

Source: services/api/src/routes/messaging.ts

Endpoint Method Auth Description
/api/conversations GET Yes List user's conversations
/api/conversations/:id GET Yes Conversation detail with messages
/api/conversations POST Yes Create conversation + first message
/api/conversations/:id/messages POST Yes Add a message
/api/conversations/:id/read PATCH Yes Mark messages as read

Endpoints — Pricing

Source: services/api/src/routes/pricing.ts

GET /api/pricing/recommend — Price recommendation

Get price statistics for a category + condition. Public.

curl "https://api.owning.pro/api/pricing/recommend?category=sailboats&condition=good"

GET /api/pricing/assess — Assess a price

Assess a specific price against the market. Public.


Endpoints — Reviews

Source: services/api/src/routes/reviews.ts

Endpoint Method Auth Description
/api/listings/:id/reviews POST Yes Create review (buyer only)
/api/listings/:id/reviews GET No List reviews for a listing
/api/users/:id/reviews GET No Reviews received by a seller
/api/users/:id/rating GET No Aggregate seller rating

Endpoints — Newsletter

Source: services/api/src/routes/newsletter.ts

POST /api/newsletter/subscribe — Subscribe

Collect email for the waitlist. Public, no auth, idempotent.

{ "email": "you@example.com" }

GET /api/newsletter/count — Subscriber count

Get the total subscriber count. Public.


Endpoints — Webhooks

Source: services/api/src/routes/webhooks.ts

Endpoint Method Auth Description
/api/webhooks POST Yes Create a webhook subscription
/api/webhooks GET Yes List my webhooks
/api/webhooks/:id DELETE Yes Delete a webhook
/api/webhooks/:id/test POST Yes Send a test event

Endpoints — Health

GET /health — Health check

Returns 200 when healthy, 503 when degraded. Includes DB connectivity, pool stats, cache stats, memory usage, and response time. Not rate-limited.

curl https://api.owning.pro/health

GET /health/db — Database health

Lightweight DB-only diagnostic. Returns connectivity, pool status, query latency. Not rate-limited.


Endpoints — Dedup Background Job (R55 — nearly complete)

Status (R55):Nearly complete — Kai refactored the dedup engine to a background job. Migration 0040 (adding last_error to migration_state) and migration 0041 (partial btree index for dedup query acceleration) are both deployed. Per the /api/admin/stats endpoint, 9,313 groups have been merged with only 60 detected groups remaining (down from 788 in R52, 5,253 in R51). The unique listings count is 23,788 (80% unique rate). The background job endpoints are ready to trigger for the final 60 groups. Kai is tasked with adding a dedicated /api/admin/dedup/status endpoint and triggering the final dedup job in R55. DISTINCT ON → dedup_status optimization (R55 — planned): The getListings() function uses a DISTINCT ON (title, seller_id, price_amount) subquery to deduplicate listings on every cache miss. This scans all ~26K active listings and takes 710-945ms on cold cache. Kai is tasked with replacing this with a dedup_status filter (or materialized view) that pre-computes dedup groups, eliminating the subquery entirely. Target: <300ms cold-cache (down from 710ms). The 60s in-memory cache mitigates the latency impact for warm requests (~90ms). See the performance table in the Infrastructure Status section of docs/project-status-r55.md.

Source: services/api/src/services/dedup-service.ts (968 lines), services/api/src/routes/admin.ts

Current state (R55)

  • 60 dedup groups detected (down from 788 in R52, 5,253 in R51, 7,590 in R50 — nearly complete)
  • 9,313 groups merged total (per /api/admin/stats endpoint — comprehensive count)
  • 398 groups rejected (per admin stats)
  • 23,788 unique listings (80% unique rate after dedup — per admin stats)
  • Migration 0040 appliedlast_error column on migration_state for error tracking
  • Migration 0041 applied — Partial btree index listings_dedup_active_title_seller_price_created_idx on (title, seller_id, price_amount, created_at DESC) WHERE status = 'active' — accelerates the DISTINCT ON dedup subquery
  • DISTINCT ON → dedup_status optimization planned (R55) — Kai tasked with replacing the DISTINCT ON subquery with a dedup_status filter or materialized view to eliminate 710-945ms cold-cache latency (target: <300ms)

Planned background job endpoints

Endpoint Method Auth Description
POST /api/admin/dedup/job/start POST Admin Start a background dedup job (processes pending groups in batches)
POST /api/admin/dedup/job/stop POST Admin Stop the running dedup job
GET /api/admin/dedup/job/status GET Admin Get background job status (running, progress, groups processed, ETA)
GET /api/admin/dedup/job/history GET Admin List past dedup job runs
GET /api/admin/dedup/status GET Admin PLANNED (R55) — Dedicated dedup status endpoint (groups detected, merged, pending, last run) — Kai tasked with adding this in R55

Existing synchronous endpoints (admin)

These endpoints are already deployed and work for smaller datasets:

Endpoint Method Description
/api/admin/nautical/dedup GET List duplicate groups (paginated)
/api/admin/nautical/dedup/stats GET Dedup statistics (groups detected, merged, pending)
/api/admin/nautical/dedup/scan POST Run a dedup scan (synchronous — may time out on large datasets)
/api/admin/nautical/dedup/:id/merge POST Merge a specific duplicate group
/api/admin/nautical/dedup/:id/reject POST Reject a duplicate group
/api/admin/nautical/dedup/scan-and-merge POST Scan + auto-merge (synchronous — may time out)
/api/admin/nautical/dedup/bulk-merge POST Bulk merge multiple groups

Example: Check dedup stats

curl -H "Authorization: Bearer $ADMIN_TOKEN" \
  https://api.owning.pro/api/admin/nautical/dedup/stats | jq
{
  "groups_detected": 60,
  "groups_merged": 9313,
  "groups_pending": 60,
  "groups_rejected": 398,
  "last_scan_at": "2026-07-29T17:10:00Z"
}

Endpoints — Rewriting Migration (R55 — BLOCKED by OpenRouter API key exhaustion)

Status (R55): 🔴 BLOCKED — The rewriting migration endpoints are live, but the background migration process has been paused because the OpenRouter API key has exceeded its monthly limit. All rewrite attempts fail with HTTP 403 "Key limit exceeded (monthly limit)." The migration was stopped to prevent wasting quota on 403 errors. Progress before block: 6,471/26K listings processed (25%) per the migration counter, with ~19,539 actual completions (per /api/admin/stats endpoint). The 1,875 failures from R51 (missing rental_rates column) are now resolved — migration 0038 is deployed and the column exists in production. ~8,558 listings remain pending (per admin stats — down from ~9,085 in R54 as some were reprocessed). 1,590 failures recorded (up from 910 in R54 — new failures from attempts before the migration was paused plus new listings added by R54 scrapers). Action needed: Pablo must upgrade or replace the OpenRouter API key to unblock the pipeline. See contributor guide §5 "How to handle OpenRouter API key exhaustion" for details.

Source: services/api/src/services/rewriting-service.ts (1,406 lines), services/api/src/routes/admin.ts

Rewriting migration endpoints (admin)

Endpoint Method Description
POST /api/admin/rewrite-migration/start POST Start the rewriting migration background process
POST /api/admin/rewrite-migration/stop POST Stop the running rewriting migration
GET /api/admin/rewrite-migration/status GET Get migration status (progress, current batch, speed, ETA)

Single/batch rewriting endpoints (admin)

Endpoint Method Description
POST /api/admin/rewrite POST Rewrite a single listing by ID
POST /api/admin/rewrite-batch POST Rewrite a batch of listings
GET /api/admin/rewrite/status GET Rewrite job status
GET /api/admin/rewrite/service GET Rewriting service health/status

Example: Check migration status

curl -H "Authorization: Bearer $ADMIN_TOKEN" \
  https://api.owning.pro/api/admin/rewrite-migration/status | jq
{
  "running": false,
  "paused_reason": "OpenRouter API key monthly limit exceeded (403)",
  "total": 29750,
  "migrated": 6471,
  "progress_percent": 25,
  "current_batch": 0,
  "failed": 1590,
  "failure_reason": "OpenRouter API returned 403: Key limit exceeded (monthly limit)",
  "speedup_implemented": true,
  "last_error": "OpenRouter API returned 403: Key limit exceeded (monthly limit)",
  "pending_restart": true,
  "restart_condition": "OpenRouter API key upgraded or replaced by Pablo"
}

Example: Start the migration

curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
  https://api.owning.pro/api/admin/rewrite-migration/start

Public rewriting endpoint

Endpoint Method Auth Description
POST /api/rewriting/migrate POST API Key Trigger rewriting for eligible listings (agent-facing)

Endpoints — Admin (Requires Admin Auth)

All admin endpoints require JWT auth with role='admin'. The requireAdmin middleware validates this on every request. These power the internal dashboard at owning.pro/admin. R55 additions: DQ-006 normalizer fix deployed (commit 227d94b1 — extracts brand/model/year from listing titles for boat24.com listings that had 0% specs). Admin analytics P0 issue FIXED — endpoint now returns 401 (auth required) instead of 500. R54 commit cleanup (uncommitted rentals + admin analytics committed as 28884844). Railway webhook (web) appears stable — all R54 pages confirmed live. DISTINCT ON → dedup_status optimization planned (target <300ms cold-cache, down from 710ms). Planned: /api/admin/dedup/status endpoint. API commit 227d94b1. Carries forward from R53: migration 0041, 2 admin endpoints (/stats + /health), rate limit 1000/min, caching expansion, dedup/rewriting status.

Source: services/api/src/routes/admin.ts (41+ endpoints)

Core admin

Endpoint Method Description
/api/admin/stats GET NEW (R53) — Dashboard summary: listings, dedup, rewriting, scrapers, cache stats in one response. All 5 queries run in parallel via Promise.all (~700ms total).
/api/admin/health GET NEW (R53) — Lightweight system health: status, uptime, DB latency, migration status, memory usage. Alternative to /health-detailed for health widgets.
/api/admin/metrics GET Listing counts by status, source, category
/api/admin/inventory GET Inventory quality metrics (images, price, etc.)
/api/admin/validation-queue GET Draft listings pending review
/api/admin/flagged GET Flagged listings for moderation
/api/admin/listings/:id/approve POST Approve a flagged/draft listing
/api/admin/listings/:id/reject POST Reject a flagged/draft listing
/api/admin/health-detailed GET Detailed system health
/api/admin/recent-requests GET Recent API requests (log)
/api/admin/image-stats GET Image coverage statistics
/api/admin/zero-image-listings GET Listings without images
/api/admin/zero-image-listings DELETE Delete listings without images
/api/admin/localize-images POST Start image localization migration
/api/admin/backup POST Trigger a manual DB backup
/api/admin/expire-listings POST Expire old listings
/api/admin/notify-expiring POST Send expiring-listing notifications

Example: GET /api/admin/stats (R53 — new)

Returns a single response with all key operational metrics — designed for Luna's admin dashboard widgets. One API call populates all widgets instead of 4-5 separate calls.

curl -H "Authorization: Bearer $ADMIN_TOKEN" \
  https://api.owning.pro/api/admin/stats | jq
{
  "timestamp": "2026-07-30T10:05:00.000Z",
  "listings": {
    "total": 29750,
    "by_status": { "active": 25826, "draft": 893, "deleted": 3027, "paused": 4 },
    "with_boat_specs": 21380,
    "with_images": 28995
  },
  "dedup": {
    "totalListings": 29750, "uniqueListings": 23788, "mergedListings": 5962,
    "totalGroups": 9771, "detectedGroups": 60, "mergedGroups": 9313,
    "rejectedGroups": 398, "uniquePercent": 80
  },
  "rewriting": {
    "total": 29750, "pending": 8558, "processing": 43,
    "completed": 19539, "failed": 1590, "skipped": 20
  },
  "scrapers": { "total": 0, "active": 0, "total_listings_seen": 0, "last_run_at": null },
  "cache": { "entries": 2, "max_entries": 500 }
}

Example: GET /api/admin/health (R53 — new)

curl -H "Authorization: Bearer $ADMIN_TOKEN" \
  https://api.owning.pro/api/admin/health | jq
{
  "status": "ok",
  "timestamp": "2026-07-29T17:10:56.242Z",
  "uptime_seconds": 20,
  "database": { "connected": true, "latency_ms": 88 },
  "migrations": { "applied": 1, "latest": "bd7469a6aaf4" },
  "queues": null,
  "memory": { "rss_mb": 149, "heap_used_mb": 16, "heap_total_mb": 14 }
}

Scraper management

Endpoint Method Description
/api/admin/scrapers GET List all configured scrapers
/api/admin/scrapers POST Create a scraper configuration
/api/admin/scrapers/:id PATCH Update a scraper (status, schedule)
/api/admin/scrapers/:id/runs GET List runs for a scraper
/api/admin/scrapers/:id/runs POST Record a scraper run
/api/admin/runs GET Recent runs across all scrapers

Nautical analytics & tracking

Endpoint Method Description
/api/admin/nautical/analytics GET Market analytics: price by type/length/year, trends
/api/admin/nautical/tracking GET Listings sold, price changes, new listings
/api/admin/nautical/scrape-stats GET Scraper coverage, freshness, new listings/day
/api/admin/nautical/listings GET Nautical inventory with advanced filters

Dedup management

Endpoint Method Description
/api/admin/nautical/dedup GET List duplicate groups
/api/admin/nautical/dedup/stats GET Dedup statistics
/api/admin/nautical/dedup/scan POST Run a dedup scan
/api/admin/nautical/dedup/:id/merge POST Merge a duplicate group
/api/admin/nautical/dedup/:id/reject POST Reject a duplicate group
/api/admin/nautical/dedup/scan-and-merge POST Scan + auto-merge
/api/admin/nautical/dedup/bulk-merge POST Bulk merge multiple groups

Rewriting management

Endpoint Method Description
/api/admin/rewrite POST Rewrite a single listing
/api/admin/rewrite-batch POST Rewrite a batch of listings
/api/admin/rewrite/status GET Rewrite job status
/api/admin/rewrite/service GET Rewriting service status
/api/admin/rewrite-migration/start POST Start rewrite migration
/api/admin/rewrite-migration/stop POST Stop rewrite migration
/api/admin/rewrite-migration/status GET Rewrite migration status

KPIs & Analytics

Endpoint Method Description
/api/admin/kpis GET All 5 KPI categories in one response
/api/admin/kpis/:category GET Individual KPI category
/api/admin/analytics GET Analytics dashboard data
/metrics GET Operational metrics (admin only)

Ingestion

Source: services/api/src/routes/ingest.ts

Endpoint Method Description
/api/ingest POST Ingest scraped records (auth required)
/api/ingest/publish-bulk POST Publish multiple drafts to active
/api/ingest/refresh/next GET Get next URLs to refresh
/api/ingest/refresh/process POST Process a refresh batch
/api/ingest/refresh/stats GET Refresh queue statistics

Boat Schema (~80 fields)

The boat schema is the comprehensive data model for nautical listings. It is stored as structured JSON in the boat_specs JSONB column and as rewritten content in the seo_data JSONB column. Not every listing has every field — scrapers fill what they can. The schema is designed to be as complete as possible.

Source: docs/PIVOT-PLAN-NAUTICAL.md §6, services/api/src/routes/schema.ts

Top-level fields

Field Type Required Description
type string Yes Always "boat" for nautical listings
category string Yes Category ID: sailboats, motorboats, yachts, etc.
subcategory string No Subcategory: cruising, racing, flybridge, etc.
listing_type enum Yes sale or wanted
condition enum Yes new, like_new, good, fair, poor, refurbished
status enum Yes active, paused, sold, expired, draft, flagged
brand string No Manufacturer (e.g., Hanse, Beneteau, Azimut)
model string No Model name (e.g., 315, Oceanis 40)
variant string No Variant/trim (e.g., Performance, Owner's Version)
year integer No Year of manufacture
hull_id string No Hull identification number (HIN)

Price

Field Type Description
price.amount number Price amount (0 = free)
price.currency string ISO 4217 currency code (EUR, USD, GBP)
price.negotiable boolean Whether the price is negotiable
price.vat_included boolean Whether VAT is included
price.vat_rate number VAT rate percentage
price.price_history[] array Price change history: { date, amount, currency }

Location

Field Type Description
location.country string ISO 3166-1 alpha-2 country code
location.country_name string Full country name
location.city string City name
location.region string Region/state/province
location.marina string Marina or port name
location.lat number Latitude (-90 to 90)
location.lng number Longitude (-180 to 180)

Specs — Dimensions

Field Type Description
specs.dimensions.length_overall_m number Length overall (LOA) in meters
specs.dimensions.length_waterline_m number Length at waterline (LWL) in meters
specs.dimensions.beam_m number Beam (width) in meters
specs.dimensions.draft_m number Draft in meters
specs.dimensions.draft_shallow_m number Shallow draft (swing keel) in meters
specs.dimensions.air_draft_m number Air draft (mast height above waterline) in meters
specs.dimensions.displacement_kg number Displacement in kilograms
specs.dimensions.ballast_kg number Ballast weight in kilograms

Specs — Construction

Field Type Description
specs.construction.material string Hull material: fiberglass, steel, aluminum, wood, carbon, concrete
specs.construction.hull_type string monohull, catamaran, trimaran
specs.construction.keel_type string fixed, lifting, swing, twin, fin, full, bulb
specs.construction.keel_material string cast iron, lead, steel, composite
specs.construction.rudder_type string spade, skeg, balanced, transom
specs.construction.deck_material string Deck material (e.g., fiberglass/teak)
specs.construction.builder string Builder/shipyard name
specs.construction.designer string Designer name

Specs — Rigging (sailboats)

Field Type Description
specs.rigging.rig_type string sloop, cutter, ketch, yawl, schooner, cat
specs.rigging.mast_type string aluminum, carbon, wood, rotating
specs.rigging.mainsail_type string battened, full-batten, classic, square-top
specs.rigging.furling_system string headsail, main, none
specs.rigging.sail_area_main_m2 number Mainsail area in m²
specs.rigging.sail_area_jib_m2 number Jib area in m²
specs.rigging.spinnaker boolean Has spinnaker
specs.rigging.genoa boolean Has genoa

Specs — Accommodation

Field Type Description
specs.accommodation.cabins integer Number of cabins
specs.accommodation.berths integer Number of berths/sleeping places
specs.accommodation.heads integer Number of heads (toilets)
specs.accommodation.saloon boolean Has saloon
specs.accommodation.galley boolean Has galley (kitchen)
specs.accommodation.nav_station boolean Has navigation station
specs.accommodation.interior_material string Interior wood/material
specs.accommodation.heating string Heating system (e.g., Webasto)
specs.accommodation.air_conditioning boolean Has air conditioning

Specs — Engine

Field Type Description
specs.engine.engine_brand string Engine manufacturer
specs.engine.engine_model string Engine model
specs.engine.engine_type string inboard, outboard, sterndrive, pod, saildrive
specs.engine.fuel_type string diesel, petrol, electric, hybrid
specs.engine.engine_power_hp number Power in horsepower
specs.engine.engine_power_kw number Power in kilowatts
specs.engine.engine_hours number Engine hours
specs.engine.engine_year integer Engine year of manufacture
specs.engine.propulsion_type string shaft drive, saildrive, outdrive, jet, pod
specs.engine.propeller_type string 3-blade fixed, 2-blade, folding, feathering, variable-pitch
specs.engine.max_speed_kn number Maximum speed in knots
specs.engine.cruise_speed_kn number Cruising speed in knots

Specs — Tanks

Field Type Description
specs.tanks.fuel_capacity_l number Fuel tank capacity in liters
specs.tanks.water_capacity_l number Water tank capacity in liters
specs.tanks.holding_tank_l number Holding tank capacity in liters

Specs — Electrical

Field Type Description
specs.electrical.battery_type string AGM, gel, lithium, lead-acid
specs.electrical.battery_count integer Number of batteries
specs.electrical.battery_capacity_ah number Total battery capacity in Ah
specs.electrical.shore_power boolean Has shore power connection
specs.electrical.shore_power_v number Shore power voltage
specs.electrical.solar_panels boolean Has solar panels
specs.electrical.wind_generator boolean Has wind generator
specs.electrical.generator boolean Has diesel generator
specs.electrical.inverter boolean Has inverter
specs.electrical.inverter_w number Inverter power in watts

Specs — Electronics

Field Type Description
specs.electronics.chartplotter string Chartplotter model
specs.electronics.autopilot string Autopilot model
specs.electronics.vhf string VHF radio model
specs.electronics.ais boolean Has AIS
specs.electronics.radar boolean Has radar
specs.electronics.depth_sounder boolean Has depth sounder
specs.electronics.wind_instruments boolean Has wind instruments
specs.electronics.log_speed boolean Has log/speed instrument
specs.electronics.wifi boolean Has WiFi

Specs — Safety

Field Type Description
specs.safety.life_raft boolean Has life raft
specs.safety.life_raft_capacity integer Life raft capacity (persons)
specs.safety.life_raft_expiry string Life raft service expiry date
specs.safety.life_jackets integer Number of life jackets
specs.safety.fire_extinguishers integer Number of fire extinguishers
specs.safety.flares boolean Has flares
specs.safety.epirb boolean Has EPIRB
specs.safety.first_aid boolean Has first aid kit
specs.safety.bilge_pumps integer Number of bilge pumps
specs.safety.category string Design category (A, B, C, D)

Specs — Deck Equipment

Field Type Description
specs.deck_equipment.anchor_type string Anchor type (e.g., CQR, Delta, Rocna)
specs.deck_equipment.anchor_weight_kg number Anchor weight in kg
specs.deck_equipment.anchor_chain_m number Anchor chain length in meters
specs.deck_equipment.windlass string electric, manual, hydraulic, none
specs.deck_equipment.davits boolean Has davits
specs.deck_equipment.swim_platform boolean Has swim platform
specs.deck_equipment.bimini boolean Has bimini top
specs.deck_equipment.sprayhood boolean Has sprayhood
specs.deck_equipment.dodger boolean Has dodger
specs.deck_equipment.cockpit_table boolean Has cockpit table
specs.deck_equipment.cockpit_cushions boolean Has cockpit cushions

Specs — Additional

Field Type Description
specs.additional.trailer_included boolean Trailer included in sale
specs.additional.mooring_available boolean Mooring available
specs.additional.tax_paid boolean Tax/VAT paid
specs.additional.ce_certified boolean CE certified
specs.additional.flag string Registered flag state
specs.additional.registration string Registration number

Images

Field Type Description
images[].url string Image URL
images[].type string exterior, interior, deck, engine, plan
images[].order integer Display order

Source metadata

Field Type Description
source.portals[] string[] Source portals (e.g., ["boat24.com", "yachtworld.com"])
source.original_urls[] string[] Original listing URLs
source.original_descriptions[] string[] Original descriptions from each portal
source.dedup_id string Dedup group ID
source.first_seen datetime First scraped date
source.last_scraped datetime Last scraped date
source.scrape_count integer Number of times scraped

SEO data

Field Type Description
seo.slug string SEO-optimized URL slug
seo.meta_title string Meta title (max 70 chars)
seo.meta_description string Meta description (max 160 chars)
seo.keywords[] string[] SEO keywords
seo.rewritten_content object Per-locale rewritten descriptions: { en: "...", es: "...", ... }

Data Types Reference

Listing

Field Type Readonly Description
id string (lst_...) Yes ULID-based listing ID
slug string Yes URL-friendly slug generated from title
title string (5-120) No Listing title
description string (20-5000) No Listing description (rewritten content)
category CategoryRef No Category reference
condition enum No Item condition
type enum No sale or wanted
price Price No Price object
location Location No Location object
images Image[] (max 10) No Listing images
attributes object No Flexible key-value per category
seller Seller Yes Seller info (server-generated)
status enum Yes Listing status
created_at datetime Yes Creation timestamp
updated_at datetime Yes Last update timestamp
expires_at datetime Yes Expiration timestamp
views integer Yes View count
tags string[] (max 10) No Tags
meta ListingMeta Yes Metadata

Pagination

Field Type Description
page integer Current page number
limit integer Results per page
total integer Total results count
pages integer Total page count