{"openapi":"3.0.0","info":{"title":"GrailWatch API","version":"1.0.0","description":"Information-first watch reliability, quality, and value reference API. Provides semantic search, reference data, photo submissions, and admin functions.","contact":{"name":"GrailWatch Support","email":"support@grailatlas.com"},"license":{"name":"Proprietary","url":"https://grailatlas.com/legal/terms"}},"servers":[{"url":"https://grailatlas.com","description":"Production"},{"url":"http://localhost:3000","description":"Development"}],"tags":[{"name":"Browse & Search","description":"Public search endpoints for watch discovery"},{"name":"References","description":"Watch reference data and details"},{"name":"Photos","description":"Community photo submissions and credits"},{"name":"Admin","description":"Admin-only endpoints (authentication required)"},{"name":"Utility","description":"Health checks and system status"}],"paths":{"/api/browse/search":{"get":{"tags":["Browse & Search"],"summary":"Natural language semantic search","operationId":"browseSearch","description":"Search the watch catalog using natural language queries. Returns ranked references sorted by semantic similarity. Supports queries like \"gold dress watch under 5k\" or \"diver with date window\".","parameters":[{"name":"q","in":"query","required":true,"description":"Natural language query string (max 500 characters)","schema":{"type":"string","minLength":1,"maxLength":500,"example":"small gold dress watch under 5000"}}],"responses":{"200":{"description":"Search results with ranked references","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BrowseSearchResponse"},"example":{"query":"gold dress watch under 5000","count":12,"results":[{"refId":"r-cartier-ronde-solo","similarity":0.92,"percentile":98},{"refId":"r-chopard-alpine-eagle-41","similarity":0.88,"percentile":95}],"availableTotal":543,"embedding_dim":1024}}}},"400":{"description":"Invalid query (empty or malformed)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded (100 requests per minute per IP)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"count":0,"results":[],"availableTotal":0,"error":"Rate limit exceeded"}}}},"500":{"description":"Server error (gracefully returns empty results)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BrowseSearchResponse"},"example":{"query":"gold dress watch","count":0,"results":[],"availableTotal":543,"error":"Search unavailable"}}}}},"x-rateLimit":{"limit":100,"window":"1 minute","scope":"IP"}}},"/api/search":{"get":{"tags":["Browse & Search"],"summary":"Full-text search","operationId":"search","description":"Full-text search across watch references and editorial content. Same scoring as the /search HTML page. Returns structured results with metadata.","parameters":[{"name":"q","in":"query","required":true,"description":"Search query (max 200 characters)","schema":{"type":"string","minLength":1,"maxLength":200,"example":"Rolex Submariner"}}],"responses":{"200":{"description":"Full-text search results","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchResponse"},"example":{"count":34,"results":[{"id":"r-rolex-sub-124060","type":"reference","brand":"Rolex","model":"Submariner","score":0.95}]}}}},"400":{"description":"Invalid query","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded (100 requests per minute per IP)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"x-rateLimit":{"limit":100,"window":"1 minute","scope":"IP"}}},"/api/reference/{id}":{"get":{"tags":["References"],"summary":"Get watch reference details","operationId":"getReference","description":"Fetch detailed information for a specific watch reference including market data, movement classification, momentum, and editorial availability.","parameters":[{"name":"id","in":"path","required":true,"description":"Reference ID (e.g., r-rolex-sub-124060)","schema":{"type":"string","example":"r-cartier-ronde-solo"}}],"responses":{"200":{"description":"Reference detail with market and classification data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReferenceDetail"},"example":{"asOf":"2026-05-28T14:30:00.000Z","reference":{"id":"r-cartier-ronde-solo","brand":"Cartier","model":"Ronde Solo","reference":"W6700155","caliber":"Cartier 690","caseSizeMm":42,"yearsProduced":"2007-present","categories":["dress"]},"classification":{"brandTier":"tier-1","era":"modern","movementTier":"in-house-manufacture"},"market":{"medianUsd":7200,"lowUsd":6800,"highUsd":8500,"confidence":"high","compCount":23},"momentum":{"trend":"stable","slopePct":1.2,"regime":"normal","scoreable":true},"editorialAvailable":true}}}},"404":{"description":"Reference not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"not_found"}}}},"429":{"description":"Rate limit exceeded (100 requests per minute per IP)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"x-rateLimit":{"limit":100,"window":"1 minute","scope":"IP"},"x-cache":{"ttl":600,"description":"Results cached for 10 minutes"}}},"/api/photos/submit":{"post":{"tags":["Photos"],"summary":"Submit a community photo","operationId":"submitPhoto","description":"Submit a high-quality photo of a watch reference. Photos are reviewed by the admin team and credited in the reference page. Rate limited: 5 submissions per hour per IP.","requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/PhotoSubmissionRequest"},"example":{"reference_id":"r-rolex-sub-124060","photographer_name":"John Smith","photographer_email":"john@example.com","submission_notes":"Lume shot under blacklight","photo":"file (JPEG/PNG/WebP, max 10MB)"}}}},"responses":{"201":{"description":"Photo submission received","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PhotoSubmissionResponse"},"example":{"submission_id":"550e8400-e29b-41d4-a716-446655440000","status":"pending","message":"Submission received. You will hear back within 24-48 hours."}}}},"400":{"description":"Invalid request (missing fields, invalid reference, file too large)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"File too large (max 10MB, got 12.5MB)"}}}},"429":{"description":"Rate limit exceeded (5 submissions per hour per IP)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"Rate limit exceeded. Maximum 5 submissions per hour."}}}},"500":{"description":"Server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"x-rateLimit":{"limit":5,"window":"1 hour","scope":"IP"}}},"/api/photos/credits/leaderboard":{"get":{"tags":["Photos"],"summary":"Get photographer leaderboard","operationId":"getPhotographerLeaderboard","description":"Ranked list of photographers by approved submission count. Updated daily. Public endpoint with no authentication required.","parameters":[{"name":"limit","in":"query","required":false,"description":"Number of photographers to return (default 10, max 50)","schema":{"type":"integer","minimum":1,"maximum":50,"default":10,"example":20}}],"responses":{"200":{"description":"Ranked photographers by contribution","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PhotographerLeaderboardResponse"},"example":{"leaderboard":[{"rank":1,"photographer_name":"Sarah Chen","approved_submissions_count":47,"last_approved_at":"2026-05-27T18:30:00.000Z"}],"timestamp":"2026-05-28T14:30:00.000Z"}}}},"400":{"description":"Invalid query parameters","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"x-cache":{"ttl":300,"description":"Results cached for 5 minutes"}}},"/api/admin/blog/brainstorm":{"get":{"tags":["Admin"],"summary":"Get brainstorm candidates","operationId":"adminGetBrainstormCandidates","description":"Returns today's brainstorm candidates with coverage metrics and trends. Requires admin authentication.","security":[{"adminAuth":[]}],"responses":{"200":{"description":"Brainstorm candidates with metrics","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BrainstormResponse"},"example":{"candidates":[{"refId":"r-omega-seamaster-300m","score":0.89,"brand":"Omega","model":"Seamaster 300M","reasoning":"High search volume, no recent editorial"}],"metrics":{"byBrand":{"Rolex":42,"Omega":28},"byCategory":{"dive":45,"dress":38},"byPriceTier":{"under-5k":30,"5k-10k":45},"byComplications":{"gmt":18,"chronograph":25}},"trends":[{"trend":"increasing_demand","category":"sports","strength":"high"}],"generatedAt":"2026-05-28T08:00:00.000Z","jobDurationMs":2340,"errors":[]}}}},"401":{"description":"Unauthorized (missing or invalid admin credentials)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"Unauthorized"}}}},"500":{"description":"Server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/admin/blog/brainstorm/{candidateId}/save-draft":{"post":{"tags":["Admin"],"summary":"Save brainstorm draft as blog post","operationId":"adminSaveBrainstormDraft","description":"Convert a brainstorm candidate into a draft blog post. Requires admin authentication.","security":[{"adminAuth":[]}],"parameters":[{"name":"candidateId","in":"path","required":true,"description":"Brainstorm candidate ID","schema":{"type":"string","example":"r-omega-seamaster-300m"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"title":{"type":"string","description":"Blog post title","example":"The Omega Seamaster 300M: Tool Watch Perfection"},"content":{"type":"string","description":"Markdown blog post content"}},"required":["title","content"]}}}},"responses":{"200":{"description":"Draft saved successfully","content":{"application/json":{"schema":{"type":"object","properties":{"draft_id":{"type":"string","example":"draft-abc123"},"status":{"type":"string","example":"pending_review"},"created_at":{"type":"string","format":"date-time"}}}}}},"400":{"description":"Invalid request (missing fields)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/admin/photos/qa":{"get":{"tags":["Admin"],"summary":"List photo submissions for QA","operationId":"adminListPhotoSubmissions","description":"Get paginated list of photo submissions for review. Requires admin authentication. Rate limited to 100 requests per hour per admin user.","security":[{"adminAuth":[]}],"parameters":[{"name":"status","in":"query","required":false,"description":"Filter by submission status (default: pending)","schema":{"type":"string","enum":["pending","approved","rejected"],"default":"pending"}},{"name":"page","in":"query","required":false,"description":"Page number (default: 1)","schema":{"type":"integer","minimum":1,"default":1}}],"responses":{"200":{"description":"Paginated list of submissions","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PhotoSubmissionsListResponse"},"example":{"submissions":[{"id":"550e8400-e29b-41d4-a716-446655440000","reference_id":"r-rolex-sub-124060","photographer_name":"John Smith","photographer_email":"john@example.com","submission_notes":"Lume shot","status":"pending","created_at":"2026-05-28T12:00:00.000Z","references":{"id":"r-rolex-sub-124060","brand":"Rolex","model":"Submariner"}}],"pagination":{"page":1,"limit":20,"total":42,"pages":3}}}}},"400":{"description":"Invalid query parameters","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded (100 requests per hour)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Rate limit service unavailable","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"tags":["Admin"],"summary":"Approve or reject a submission","operationId":"adminQaPhotoSubmission","description":"Update the status of a photo submission (approve or reject). Requires admin authentication. Increments photographer credits on approval.","security":[{"adminAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PhotoQARequest"},"example":{"action":"approve","submission_id":"550e8400-e29b-41d4-a716-446655440000"}}}},"responses":{"200":{"description":"Submission status updated","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["approved","rejected"]},"submission_id":{"type":"string"}}},"example":{"status":"approved","submission_id":"550e8400-e29b-41d4-a716-446655440000"}}}},"400":{"description":"Invalid request (missing fields, invalid action)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Submission not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded (100 requests per hour per admin user)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/health":{"get":{"tags":["Utility"],"summary":"Health check","operationId":"healthCheck","description":"System health status endpoint. No authentication required.","responses":{"200":{"description":"System is healthy","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","example":"ok"},"timestamp":{"type":"string","format":"date-time"}}}}}},"503":{"description":"Service unavailable","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}},"components":{"schemas":{"BrowseSearchResponse":{"type":"object","required":["count","results","availableTotal"],"properties":{"query":{"type":"string","description":"Normalized query string","example":"gold dress watch under 5000"},"count":{"type":"integer","description":"Number of results returned","example":12},"results":{"type":"array","description":"Ranked search results","items":{"type":"object","properties":{"refId":{"type":"string","description":"Watch reference ID","example":"r-cartier-ronde-solo"},"similarity":{"type":"number","format":"float","description":"Semantic similarity score (0-1)","minimum":0,"maximum":1,"example":0.92},"percentile":{"type":"integer","description":"Percentile rank among all references","minimum":0,"maximum":100,"example":98}},"required":["refId","similarity"]}},"availableTotal":{"type":"integer","description":"Total number of references in the catalog","example":543},"embedding_dim":{"type":"integer","description":"Embedding vector dimensions","example":1024},"error":{"type":"string","description":"Error message (if search failed gracefully)","example":"Search unavailable"}}},"SearchResponse":{"type":"object","required":["count","results"],"properties":{"count":{"type":"integer","description":"Number of results","example":34},"results":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Result ID","example":"r-rolex-sub-124060"},"type":{"type":"string","description":"Result type (reference, editorial, brand)","enum":["reference","editorial","brand"]},"brand":{"type":"string","example":"Rolex"},"model":{"type":"string","example":"Submariner"},"score":{"type":"number","format":"float","description":"Search score (0-1)","example":0.95}},"required":["id","type","score"]}}}},"ReferenceDetail":{"type":"object","required":["asOf","reference","classification","momentum"],"properties":{"asOf":{"type":"string","format":"date-time","description":"Timestamp when data was generated"},"reference":{"type":"object","required":["id","brand","model"],"properties":{"id":{"type":"string","description":"Reference ID","example":"r-cartier-ronde-solo"},"brand":{"type":"string","example":"Cartier"},"model":{"type":"string","example":"Ronde Solo"},"reference":{"type":"string","description":"Manufacturer reference number","example":"W6700155"},"caliber":{"type":"string","description":"Movement caliber","example":"Cartier 690"},"caseSizeMm":{"type":"number","description":"Case diameter in millimeters","example":42},"yearsProduced":{"type":"string","description":"Production years","example":"2007-present"},"categories":{"type":"array","items":{"type":"string","enum":["dive","dress","chronograph","pilot","gmt-worldtime","sport","integrated-bracelet"]},"description":"Watch categories","example":["dress"]}}},"classification":{"type":"object","required":["brandTier","era"],"properties":{"brandTier":{"type":"string","description":"Brand position tier","enum":["tier-1","tier-2","independent"],"example":"tier-1"},"era":{"type":"string","description":"Production era","enum":["vintage","modern"],"example":"modern"},"movementTier":{"type":"string","description":"Movement classification","example":"in-house-manufacture"}}},"market":{"type":"object","nullable":true,"description":"Market data (null if insufficient comps)","properties":{"medianUsd":{"type":"integer","description":"Median price in USD","example":7200},"lowUsd":{"type":"integer","description":"Low price in USD","example":6800},"highUsd":{"type":"integer","description":"High price in USD","example":8500},"confidence":{"type":"string","enum":["high","medium","low"],"example":"high"},"compCount":{"type":"integer","description":"Number of comparable sales","example":23}}},"momentum":{"type":"object","properties":{"trend":{"type":"string","enum":["up","down","stable"],"example":"stable"},"slopePct":{"type":"number","format":"float","description":"Price trend slope as percentage","example":1.2},"regime":{"type":"string","enum":["normal","elevated","depressed"],"example":"normal"},"scoreable":{"type":"boolean","example":true}}},"editorialAvailable":{"type":"boolean","description":"Whether editorial content exists for this reference","example":true}}},"PhotoSubmissionRequest":{"type":"object","required":["reference_id","photographer_name","photographer_email"],"properties":{"reference_id":{"type":"string","description":"Watch reference ID","example":"r-rolex-sub-124060"},"photographer_name":{"type":"string","description":"Photographer name","example":"John Smith"},"photographer_email":{"type":"string","format":"email","description":"Photographer email","example":"john@example.com"},"submission_notes":{"type":"string","description":"Optional notes about the photo","example":"Lume shot under blacklight"},"photo":{"type":"string","format":"binary","description":"Photo file (JPEG, PNG, or WebP, max 10MB)"}}},"PhotoSubmissionResponse":{"type":"object","required":["submission_id","status","message"],"properties":{"submission_id":{"type":"string","format":"uuid","description":"Unique submission ID","example":"550e8400-e29b-41d4-a716-446655440000"},"status":{"type":"string","enum":["pending","approved","rejected"],"example":"pending"},"message":{"type":"string","example":"Submission received. You will hear back within 24-48 hours."}}},"PhotographerLeaderboardResponse":{"type":"object","required":["leaderboard","timestamp"],"properties":{"leaderboard":{"type":"array","items":{"type":"object","properties":{"rank":{"type":"integer","example":1},"photographer_name":{"type":"string","example":"Sarah Chen"},"approved_submissions_count":{"type":"integer","example":47},"last_approved_at":{"type":"string","format":"date-time","example":"2026-05-27T18:30:00.000Z"}}}},"timestamp":{"type":"string","format":"date-time","description":"When the leaderboard was generated"}}},"PhotoSubmissionsListResponse":{"type":"object","required":["submissions","pagination"],"properties":{"submissions":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"reference_id":{"type":"string"},"photographer_name":{"type":"string"},"photographer_email":{"type":"string","format":"email"},"submission_notes":{"type":"string","nullable":true},"status":{"type":"string","enum":["pending","approved","rejected"]},"created_at":{"type":"string","format":"date-time"},"references":{"type":"object","properties":{"id":{"type":"string"},"brand":{"type":"string"},"model":{"type":"string"}}}}}},"pagination":{"type":"object","required":["page","limit","total","pages"],"properties":{"page":{"type":"integer","example":1},"limit":{"type":"integer","example":20},"total":{"type":"integer","example":42},"pages":{"type":"integer","example":3}}}}},"PhotoQARequest":{"type":"object","required":["action","submission_id"],"properties":{"action":{"type":"string","enum":["approve","reject"],"description":"Action to perform","example":"approve"},"submission_id":{"type":"string","format":"uuid","description":"Submission ID to action","example":"550e8400-e29b-41d4-a716-446655440000"},"feedback":{"type":"string","description":"Rejection feedback (required for reject action)","example":"Image quality too low, focus is soft"}}},"BrainstormResponse":{"type":"object","required":["candidates","metrics","trends","generatedAt"],"properties":{"candidates":{"type":"array","items":{"type":"object","properties":{"refId":{"type":"string","example":"r-omega-seamaster-300m"},"score":{"type":"number","format":"float","description":"Brainstorm score (0-1)","example":0.89},"brand":{"type":"string","example":"Omega"},"model":{"type":"string","example":"Seamaster 300M"},"reasoning":{"type":"string","description":"Why this was selected","example":"High search volume, no recent editorial"}}}},"metrics":{"type":"object","properties":{"byBrand":{"type":"object","additionalProperties":{"type":"integer"},"description":"Coverage count by brand","example":{"Rolex":42,"Omega":28}},"byCategory":{"type":"object","additionalProperties":{"type":"integer"},"description":"Coverage count by category"},"byPriceTier":{"type":"object","additionalProperties":{"type":"integer"},"description":"Coverage count by price tier"},"byComplications":{"type":"object","additionalProperties":{"type":"integer"},"description":"Coverage count by complications"}}},"trends":{"type":"array","items":{"type":"object","properties":{"trend":{"type":"string","example":"increasing_demand"},"category":{"type":"string","example":"sports"},"strength":{"type":"string","enum":["low","medium","high"],"example":"high"}}}},"generatedAt":{"type":"string","format":"date-time"},"jobDurationMs":{"type":"integer","description":"Job execution time in milliseconds","example":2340},"errors":{"type":"array","items":{"type":"string"},"description":"Any errors encountered during generation"}}},"Error":{"type":"object","required":["error"],"properties":{"error":{"type":"string","description":"Error message or code"},"details":{"type":"object","description":"Additional error details","additionalProperties":true}}}},"securitySchemes":{"adminAuth":{"type":"http","scheme":"bearer","description":"Admin authentication via Bearer token or environment-based email verification. Current implementation: admin email from ADMIN_EMAIL env var + Supabase auth session."}}},"x-rateLimit":{"defaultLimit":"varies by endpoint","description":"Rate limiting is applied per endpoint, not globally. /api/browse/search, /api/search, /api/reference/{id} and /api/photos/submit are limited by client IP; /api/admin/photos/qa by authenticated admin user. Each endpoint holds its own bucket, so traffic to one does not consume another. Where a limit is exceeded the response carries a Retry-After header."},"x-security":{"description":"Public endpoints require no authentication. Admin endpoints require valid admin session (ADMIN_EMAIL verification). All endpoints log security events for monitoring."}}