Image-First Listing — AI Generate Guide
Endpoint:POST /api/listings/ai-generateStatus: ✅ Production-tested (Ronda 17, 2026-07-12) Model:anthropic/claude-opus-4.8via OpenRouter
Overview
Owning.pro supports an image-first listing creation flow. Instead of asking the seller to fill out a form field by field, the user uploads photos of the item they want to sell, and the AI automatically generates a complete listing draft — title, description, category, estimated price range, tags, condition, and category-specific attributes. All fields remain fully editable.
This is the core differentiator of the sell experience: snap photos → get a listing → review → publish.
How it works
User uploads photos
│
▼
POST /api/auth/register (or /login) ──► JWT token
│
▼
POST /api/listings/ai-generate
Authorization: Bearer <JWT>
Body: { "images": ["https://...", "https://..."] }
│
▼
API sends images to OpenRouter (Claude Opus 4.8 vision)
│
▼
Model analyzes images → returns structured JSON draft
│
▼
API validates against schema → returns to client
│
▼
Client pre-fills the sell form (all fields editable)
│
▼
User reviews/edits → POST /api/listings (create listing)
Architecture
| Component | Location | Role |
|---|---|---|
| Route | services/api/src/routes/listings.ts |
POST /ai-generate handler |
| AI Service | services/api/src/services/ai-service.ts |
OpenRouter API call + JSON parsing |
| Rate Limiter | services/api/src/services/rate-limit-service.ts |
DB-based, 5/hour per user |
| Schema | packages/shared/src/schemas/index.ts |
Request/response validation (Zod) |
| Frontend | apps/web/components/sell/sell-wizard.tsx |
3-step wizard (Photos → Details → Review) |
Stub mode
When OPENROUTER_API_KEY is not set, the endpoint runs in stub mode — it returns a mock draft immediately ({ _stub: true, ... }) without calling the AI provider. This allows frontend development without the API key. When the key is added to Railway, the endpoint automatically starts generating real drafts.
Current status: OPENROUTER_API_KEY is set in Railway (verified W022).
The endpoint is in active mode — generating real drafts.
Authentication
The endpoint requires a valid JWT token, obtained via registration or login.
Register
curl -X POST https://api.owning.pro/api/auth/register \
-H "Content-Type: application/json" \
-d '{
"email": "seller@example.com",
"password": "your-password-min-8-chars",
"name": "Jane Seller"
}'
Response (201):
{
"user": {
"id": "01KXB1YT1DE003JMMX8MQ6VXSY",
"email": "seller@example.com",
"name": "Jane Seller",
"type": "human",
"role": "user"
},
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer",
"expires_in": 604800
}
Login (existing users)
curl -X POST https://api.owning.pro/api/auth/login \
-H "Content-Type: application/json" \
-d '{ "email": "seller@example.com", "password": "your-password" }'
AI Generate — Request
POST /api/listings/ai-generate
Authorization: Bearer <JWT token>
Content-Type: application/json
Request body
| Field | Type | Required | Description |
|---|---|---|---|
images |
string[] (URLs) |
✅ Yes | Array of image URLs (1–10). Must be publicly accessible — the AI provider downloads them server-side. |
curl -X POST https://api.owning.pro/api/listings/ai-generate \
-H "Content-Type: application/json" \
-H "Authorization: Bearer eyJhbGci..." \
-d '{
"images": [
"https://static.owning.pro/images/my-listing-slug/1.webp",
"https://static.owning.pro/images/my-listing-slug/2.webp"
]
}'
⚠️ Important: Image URLs must be publicly downloadable. The AI provider
(OpenRouter → Anthropic Claude) fetches the images server-side to feed them to
the vision model. URLs that return 404 or require authentication will cause a
503 ai_unavailable error. See Known issues below.
AI Generate — Response
Success (200)
{
"title": "Canon EOS Rebel T3i DSLR Camera with Pentax-M 50mm f/2 Lens",
"description": "Canon EOS Rebel T3i (600D) digital SLR camera in black, shown with an adapted vintage SMC Pentax-M 50mm f/2 prime lens by Asahi Optical Co. The camera body features Canon's classic ergonomic grip and control layout. The Rebel T3i is an 18MP APS-C DSLR with a vari-angle LCD screen and Full HD video recording, making it a versatile choice for both photography enthusiasts and beginners. The manual-focus Pentax-M 50mm lens delivers a pleasing image character and is mounted via an adapter, ideal for creative and vintage-style shooting. Body shows good used condition with minor wear consistent with normal handling.",
"category": "electronics",
"estimatedPriceRange": {
"min": 150,
"max": 300,
"currency": "EUR"
},
"tags": ["canon", "dslr", "eosrebelt3i", "pentax", "50mmlens", "camera", "photography", "600d"],
"condition": "good",
"attributes": {
"brand": "Canon",
"model": "EOS Rebel T3i (600D)",
"type": "DSLR Camera",
"color": "Black",
"lens": "SMC Pentax-M 50mm f/2",
"lensMount": "Adapted manual focus",
"sensorType": "APS-C"
}
}
Response fields
| Field | Type | Constraints | Description |
|---|---|---|---|
title |
string |
5–120 chars | Concise, descriptive title (includes brand/model if visible) |
description |
string |
20–5000 chars | Detailed description based on what's visible in the images |
category |
string |
enum | One of: boats, cars, motorcycles, electronics, fashion, home-garden, real-estate, other |
estimatedPriceRange |
object |
— | { min, max, currency } — realistic second-hand market value |
estimatedPriceRange.min |
number |
≥ 0 | Estimated minimum value in EUR |
estimatedPriceRange.max |
number |
≥ 0 | Estimated maximum value in EUR |
estimatedPriceRange.currency |
string |
3 chars (ISO 4217) | Currency code, default EUR |
tags |
string[] |
1–10 items, ≤50 chars each | Lowercase search tags (brand, model, type, features) |
condition |
string |
enum | One of: new, like_new, good, fair, poor, refurbished |
attributes |
object |
free-form key-value | Category-specific details (e.g. {"brand": "Apple", "model": "iPhone 13"}) |
Error responses
| HTTP | Error code | When | Message example |
|---|---|---|---|
| 401 | unauthorized |
Missing or invalid JWT | Authentication required |
| 422 | validation_error |
Invalid request body (e.g. empty images array, non-URL values) | Zod validation error |
| 429 | rate_limited |
More than 5 generations in the current hour | Limit exceeded for ai_generate. Limit: 5/hour. You have used 6 this hour. |
| 503 | ai_unavailable |
OpenRouter call failed (image 404, timeout, API error, no credits) | AI service temporarily unavailable |
429 — Rate limited
{
"error": {
"code": "rate_limited",
"message": "Limit exceeded for ai_generate. Limit: 5/hour. You have used 6 this hour."
}
}
503 — AI unavailable
{
"error": {
"code": "ai_unavailable",
"message": "AI service temporarily unavailable"
}
}
Rate limiting
| Action | Limit | Window | Enforcement |
|---|---|---|---|
ai_generate |
5 generations | per hour (UTC hour window) | DB-based (rate_limit_counters table) |
- The counter is incremented atomically via a PostgreSQL UPSERT on
(user_id, action, window_start) — safe under concurrent requests.
- The window resets at the top of each UTC hour (not a rolling 60 minutes).
- The counter increments before the AI generation runs. If the generation
fails (e.g. image 404 → 503), the slot is still consumed.
- Other rate-limited actions:
listing_create(10/day),image_upload(50/day).
Client retry strategy
On 429, wait until the next UTC hour window and retry. On 503, verify the image URLs are publicly accessible, then retry with a different image or fill manually. The frontend sell wizard handles both cases gracefully — on 429 it shows a message and lets the user fill the form manually; on 503 it falls back to manual entry.
AI provider details
| Property | Value |
|---|---|
| Provider | OpenRouter (openrouter.ai) |
| Model | anthropic/claude-opus-4.8 (resolves to anthropic/claude-4.8-opus-20260528) |
| API | OpenRouter Chat Completions (/api/v1/chat/completions) |
| Vision | Images sent as image_url content blocks |
| Max tokens | 2000 |
| Timeout | 30 seconds (AbortController) |
| Env vars | OPENROUTER_API_KEY (required), OPENROUTER_MODEL (default: anthropic/claude-opus-4.8) |
| Headers | Authorization: Bearer <key>, HTTP-Referer: https://owning.pro, X-Title: Owning.pro |
System prompt
The model receives a system prompt instructing it to analyze the images and respond with only a valid JSON object matching the response schema (no markdown, no code fences). The response parser strips code fences if present and extracts the first {...} block as a fallback before validating against the Zod schema.
Response time
Typical generation time: 8–12 seconds per request (varies by image count and model load). The frontend shows a loading state during generation.
End-to-end test results (Ronda 17, 2026-07-12)
| Test | Result |
|---|---|
| Endpoint deployed (401 without auth, not 404) | ✅ Pass |
| Auth: register → JWT token | ✅ Pass (201) |
| AI generate with public image URL | ✅ Pass (200, full structured draft) |
| Response schema validation (title, description, category, price range, tags, condition, attributes) | ✅ Pass — all fields present and valid |
| Rate limiting: 429 after exceeding 5/hour | ✅ Pass (429 with rate_limited code) |
| OpenRouter key valid + credits | ✅ Pass ($299.88 remaining) |
Model anthropic/claude-opus-4.8 responds |
✅ Pass (text + vision) |
Test artifacts saved to: worklog/20260712_owning_assets/
ai_generate_response_camera.json— successful 200 responseai_generate_rate_limit_429.json— 429 rate limit response
Known issues
Scraped listing images return 404 on static.owning.pro
Status: Open (separate from AI generate endpoint)
Scraped listings store image URLs like https://static.owning.pro/images/{slug}/1.webp, but these URLs return 404 in production. The static service (services/static/src/index.ts) reads from a Railway volume at /data/images/{slug}/{n}.webp, and the scraped image files are not present on the volume.
Impact on AI generate: When a user passes a scraped listing's image URL to /api/listings/ai-generate, OpenRouter downloads the image server-side, gets a 404, and the generation fails with 503 ai_unavailable. The AI generate endpoint itself works perfectly with any publicly accessible image URL — the issue is in the scraper image pipeline (images not being uploaded to the static volume), not in the AI generate code.
Resolution: This is a scraper/image-pipeline issue, not an AI generate issue. The image upload step in the scraper pipeline needs to actually write files to the static service's Railway volume via POST /admin/upload or POST /admin/upload-batch.
Frontend integration
The sell wizard (apps/web/components/sell/sell-wizard.tsx) implements a 3-step flow:
- Photos — drag & drop upload + "Generate listing with AI" button
- Details — AI-generated listing, fully editable (
AiDetailsStep) - Review — summary + preview, then
POST /api/listings+ publish
The wizard calls callAiGenerate() which posts to /api/listings/ai-generate with the uploaded image URLs. If the response includes _stub: true (stub mode), fields stay empty with a hint banner. On 429 or 503, it falls back to manual entry.
Related documentation
- 10-API-REFERENCE.md — Full API reference
- 02-PRODUCT-SCHEMA.md — Product schema and endpoints
- 40-IMAGE-PIPELINE.md — Image pipeline architecture
- 16-ARCHITECTURE.md — System architecture