Owning.pro — API Reference
Status: Live (production) Base URL:https://api.owning.proVersion: 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 (
.mdsuffix), 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-Controlheaders.
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: MISSheaders 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:
- URL path prefix:
/es/listings/...,/fr/listings/...(frontend) Accept-Languageheader:Accept-Language: es(API)- Query parameter:
?lang=es(API, overrides header) - 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 againstFLAT_CATEGORIESin@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.mdorAccept: 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. Therelevancesort requires aqparameter (full-text search) — using it withoutqfalls back tonewest.
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 usingattr[...]filters without acategoryparameter, 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 draft → active. 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 }
]
}
Endpoints — Search
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):
- Categories — prefix match on category names/IDs (in-memory, instant)
- Popular listing titles — trigram-accelerated ILIKE, ranked by view count
- 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 thesaved_searchestable (shared with/api/me/saved-searches) but expose a typed, structuredquery_paramsshape:{ 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 /api/searches/:id — Delete a saved search
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 inapps/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 atapi.owning.pro/api/rentals. Verified:GET /api/rentalsreturns 200. Migration 0038 (R52 — APPLIED): Therental_rates,availability,rental_terms,rental_location, andmax_passengerscolumns and therental_bookingstable are now live in production. Migration 0038 was missing from the Drizzle_journal.json(fixed by Atlas in commit2202514d), and was deployed successfully in R51 close (commit7d63a4e5, deploy8075aa01). The 1,875 rewriting failures caused by the missingrental_ratescolumn are now resolved. Thelisting_typeenum has been extended with therentalvalue. Migration 0040 (R52 — APPLIED): Adds thelast_errorcolumn to themigration_statetable, enabling the dedup background job to track and report errors per group. Deployed in commit08d3b1f9(deployad5a12c4).
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.mdorAccept: 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 (addinglast_errortomigration_state) and migration 0041 (partial btree index for dedup query acceleration) are both deployed. Per the/api/admin/statsendpoint, 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/statusendpoint and triggering the final dedup job in R55. DISTINCT ON → dedup_status optimization (R55 — planned): ThegetListings()function uses aDISTINCT 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 adedup_statusfilter (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 ofdocs/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/statsendpoint — comprehensive count) - 398 groups rejected (per admin stats)
- 23,788 unique listings (80% unique rate after dedup — per admin stats)
- Migration 0040 applied —
last_errorcolumn onmigration_statefor error tracking - Migration 0041 applied — Partial btree index
listings_dedup_active_title_seller_price_created_idxon(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_statusfilter 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/statsendpoint). The 1,875 failures from R51 (missingrental_ratescolumn) 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 withrole='admin'. TherequireAdminmiddleware validates this on every request. These power the internal dashboard atowning.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/statusendpoint. API commit227d94b1. 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 |
Related Documents
- Pivot Plan — Nautical — master plan with schema design
- Product Schema — original schema spec
- Schema Mapping — Bright Data to Owning field mapping
- Boat24 Field Mapping — boat24.com field mapping example
- Contributor Guide — how to contribute code and content
- Onboarding Guide — new team member guide
- Admin Dashboard Guide — admin dashboard documentation
- Scraper Pipeline — scraper architecture
- Architecture — system architecture