{"openapi":"3.1.0","info":{"title":"Phototology API","version":"1.1.0","description":"**Persistent memory for visual intelligence.** Send an image, get structured data back, then get it back free forever.\n\nPhototology decomposes photo analysis into a catalog of composable **lenses**, each focused on one analysis dimension (dating, location, condition, people, atmosphere, and more). Pick a curated **stack** for a domain workflow, build a **custom stack** from individual lenses, or augment a curated stack with extras. Moderation runs free on every photo, always.\n\nFor the live lens catalog and curated stack list, see `GET /v1/modules`.\n\n## Naming note\n\nThe concept words are **lens** (a single analysis dimension) and **stack** (a curated or custom bundle of lenses). On the wire, the request body uses the field names `modules` and `preset` for backward compatibility with existing SDK callers. Both naming conventions refer to the same units.\n\n## Quick start\n\n1. Get your API key at [phototology.com/dashboard](https://phototology.com/dashboard)\n2. Send a photo to `POST /v1/analyze` with a curated stack (the `preset` field on the wire)\n3. Get structured JSON back in ~2-5 seconds\n\n```bash\ncurl -X POST https://api.phototology.com/v1/analyze \\\n  -H \"Authorization: Bearer pt_live_YOUR_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"imageUrl\": \"https://example.com/photo.jpg\", \"preset\": \"full-analysis\"}'\n```\n\n## Pricing\n\nPricing: $0.01 per credit. Packs at $10/$100/$1,000 for 1,000/10,000/100,000 credits. New users start with 5,000 free credits via the signup ladder (1,000 for verifying an email, 4,000 for adding a card-on-file). The card is never charged automatically. Cache hits and lookup_photo are always free.\n\nEach billable lens costs 1 credit per image. Moderation is always on and always free.\n\n| Pack | Credits | Price | Per-credit |\n|------|---------|-------|------------|\n| Starter | 1,000 | $10 | $0.01 |\n| Pro | 10,000 | $100 | $0.01 |\n| Business | 100,000 | $1,000 | $0.01 |\n\nFirst-purchase bonus: your first credit pack ships **2x** the credits at the same price. No subscriptions; credits never expire.\n\nThe registry projection (Registry v2) means the second analyze call for a photo bills zero credits for any lens already run, so your effective cost drops the more you reuse photos. See **Delta billing** below.\n\n## Rate limits\n\n| Caller | Rate limit |\n|--------|------------|\n| Authenticated keys (any tier) | 600 req/min per user |\n| Anonymous test keys (`pt_test_*` without user account) | 2 req/min + 10 req/day per IP |\n\nEvery paying user shares the same governor; tiers do not change rate limits today. The anonymous cap exists for anti-abuse on the free sandbox. When a limit is exceeded the API returns `429` with a `Retry-After` header indicating seconds until reset.\n\n## Authentication\n\nAll requests require a Bearer token in the `Authorization` header. Keys use the prefix `pt_live_` (production) or `pt_test_` (sandbox).\n\nTest keys return fixture data instantly. No AI call, no billing. Use them in development and CI.\n\n## Delta billing (Registry v2)\n\nPhototology remembers photos per API key. The second call for the same photo, identified by SHA-256, bills zero credits for any lens already run. Pass `refresh: true` on the request body to bypass the cache and force a fresh LLM run. A full cache hit returns `meta.cacheHit: true` and `usage.creditsCharged: 0`.\n\n## Lookup endpoint\n\n`GET /v2/lookup?sha256=<hex>` returns a `photo` record keyed by lens, with full output for every lens ever run on that image. Free to call, no credits charged.\n\n## Errors\n\nAll error responses share a common envelope: `{ error: { code, message, retryable, requestId } }`. The `code` is one of: `VALIDATION_FAILED` (400), `AUTH_FAILED` (401), `PLAN_LIMIT_EXCEEDED` (402; extra `credits` object on the error), `IMAGE_INVALID` / `IMAGE_TOO_LARGE` (422), `RATE_LIMITED` (429), `PROVIDER_UNAVAILABLE` / `PROVIDER_ERROR` (502). The 402 body includes `credits.needed`, `credits.community`, `credits.purchased`, `credits.total`, and `credits.resetsInDays`.","contact":{"name":"Phototology","url":"https://phototology.com","email":"hello@phototology.com"},"license":{"name":"Proprietary"}},"servers":[{"url":"https://api.phototology.com","description":"Production"},{"url":"http://localhost:3002","description":"Local development"}],"tags":[{"name":"Analysis","description":"Core analysis endpoints. Send images and receive structured AI results."},{"name":"Registry","description":"Photo registry lookup: fetch previously analyzed results by SHA-256 or pHash fingerprint. Free, no credits charged."},{"name":"Discovery","description":"Explore available lenses and curated stacks, or fetch the agent-discovery llms.txt."},{"name":"Usage","description":"Read the authenticated user's dual-pool credit balance and reserve state."},{"name":"System","description":"Health checks, OpenAPI spec, docs, and internal-secret operations."}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"Your Phototology API key. Keys use the prefix `pt_live_` (production) or `pt_test_` (sandbox, which returns fixture data with no billing). Get your key at [phototology.com/dashboard](https://phototology.com/dashboard)."},"internalSecret":{"type":"apiKey","in":"header","name":"x-internal-secret","description":"Internal shared secret for server-to-server calls (Phototology dashboard, cleanup cron). Pairs with `?userId=` on `/v1/usage`. Not exposed to SDK users."}},"schemas":{},"parameters":{}},"paths":{"/v1/analyze":{"post":{"operationId":"analyzePhoto","summary":"Analyze a photo","description":"Send one or more images and receive structured AI analysis. Pick a curated **stack** for a domain-specific workflow, compose your own **custom stack** from individual lenses, or augment a curated stack with extra lenses.\n\n### Image input\nProvide a single image via `imageUrl` or `imageBase64`, or multiple images via the `images` array (max 50). Do not mix single and multi-image inputs.\n\n### Stack and lens selection\n- `preset`: pick a curated stack by name (e.g. `full-analysis`, `vehicle-condition`). The field is named `preset` on the wire for back-compat; the concept is a stack.\n- `modules`: pass an explicit lens list to build a custom stack (overrides `preset`). The field is named `modules` on the wire for back-compat; the concept is lenses.\n- `modulesAdd` / `modulesRemove`: augment a curated stack with extra lenses, or strip a lens you do not need.\n\n### Delta billing\nRepeat calls on the same image hit the per-user-per-photo lens projection. Already-analyzed lenses are served from the projection at zero cost; only newly-requested lenses are billed and sent to the LLM. Pass `refresh: true` to bypass the cache and re-bill every requested lens. A full cache hit returns `meta.cacheHit: true` and `usage.creditsCharged: 0`.\n\n### Test sandbox\nRequests with a `pt_test_` key return fixture data instantly. No AI call is made, no usage is billed. Use this for development and CI.","tags":["Analysis"],"security":[{"bearerAuth":[]}],"requestBody":{"description":"Image(s) to analyze with optional module composition and domain context.","required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"imageUrl":{"type":"string","maxLength":2048,"format":"uri"},"imageBase64":{"type":"string","minLength":1,"maxLength":67000000},"images":{"type":"array","items":{"type":"object","properties":{"url":{"type":"string","maxLength":2048,"format":"uri"},"base64":{"type":"string","minLength":1,"maxLength":67000000}}},"minItems":1,"maxItems":50},"preset":{"type":"string"},"modules":{"type":"array","items":{"type":"string"}},"modulesAdd":{"type":"array","items":{"type":"string"}},"modulesRemove":{"type":"array","items":{"type":"string"}},"moduleOptions":{"type":"object","additionalProperties":{"type":"object","additionalProperties":{}}},"context":{"type":"object","properties":{"knownPeople":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","maxLength":200},"birthYear":{"type":"integer"},"deathYear":{"type":"integer"},"role":{"type":"string","maxLength":100}},"required":["name"]},"maxItems":50},"vehicle":{"type":"object","properties":{"vin":{"type":"string","maxLength":20},"mileage":{"type":"integer"},"year":{"type":"integer"},"make":{"type":"string","maxLength":100},"model":{"type":"string","maxLength":100}}},"customInstructions":{"type":"string","maxLength":2000}}},"options":{"type":"object","properties":{"includeEmbedding":{"type":"boolean"},"includeFingerprint":{"type":"boolean"}}},"refresh":{"type":"boolean","default":false}}},"examples":{"single-url":{"summary":"Single image by URL","value":{"imageUrl":"https://example.com/photos/living-room.jpg","preset":"full-analysis"}},"single-base64":{"summary":"Single image by base64","value":{"imageBase64":"/9j/4AAQSkZJRg...","modules":["dating","people","location"]}},"multi-image-vehicle":{"summary":"Multi-image vehicle assessment","value":{"images":[{"url":"https://example.com/car/front.jpg"},{"url":"https://example.com/car/rear.jpg"},{"url":"https://example.com/car/interior.jpg"}],"preset":"vehicle-condition","context":{"vehicle":{"vin":"1HGBH41JXMN109186","mileage":45000}}}},"custom-modules":{"summary":"Preset with module overrides","value":{"imageUrl":"https://example.com/photos/family-1985.jpg","preset":"full-analysis","modulesRemove":["moderation"],"context":{"knownPeople":[{"name":"John Smith","birthYear":1955,"role":"father"}]}}},"refresh-rebill":{"summary":"Force a fresh LLM run (bypass delta cache)","value":{"imageUrl":"https://example.com/photos/family-1985.jpg","preset":"full-analysis","refresh":true}}}}}},"responses":{"200":{"description":"Analysis completed successfully. The `output` object contains module-specific results keyed by module name. On a registry cache hit, `meta.cacheHit` is `true` and `usage.creditsCharged` is `0`.","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"object":{"type":"string","enum":["analysis"]},"schemaVersion":{"type":"string"},"createdAt":{"type":"string"},"output":{"type":"object","additionalProperties":{}},"outputSchema":{"type":"string","enum":["photo","vehicle"]},"usage":{"type":"object","properties":{"inputTokens":{"type":"number"},"outputTokens":{"type":"number"},"totalTokens":{"type":"number"},"modulesUsed":{"type":"array","items":{"type":"string"}},"creditsCharged":{"type":"integer"}},"required":["inputTokens","outputTokens","totalTokens","modulesUsed"]},"warnings":{"type":"array","items":{"type":"string"}},"meta":{"type":"object","properties":{"processingTimeMs":{"type":"number"},"provider":{"type":"string"},"promptHash":{"type":["string","null"]},"requestId":{"type":"string"},"cacheHit":{"type":"boolean"},"ai_generated":{"type":"boolean","enum":[true]},"model":{"type":"string"},"vendor":{"type":"string","enum":["google","openai","anthropic"]}},"required":["processingTimeMs","provider","promptHash","requestId","ai_generated","model","vendor"]},"embedding":{"type":"array","items":{"type":"number"}},"fingerprint":{"type":"object","properties":{"pHash":{"type":"string"},"dHash":{"type":"string"},"sha256":{"type":"string"}},"required":["pHash","dHash","sha256"]}},"required":["id","object","schemaVersion","createdAt","output","outputSchema","usage","warnings","meta"]},"examples":{"full-analysis":{"summary":"Photo analysis result (fresh LLM run)","value":{"id":"an_01j9kqm7x8n2f4","object":"analysis","schemaVersion":"1.0.0","createdAt":"2026-05-17T18:30:00Z","output":{"estimatedDate":{"year":1985,"confidence":0.82,"reasoning":"Clothing styles and car models suggest mid-1980s"},"people":{"count":3,"items":[{"age":"30-40","gender":"male","clothing":"Polo shirt, khaki shorts"}]},"location":{"type":"outdoor","setting":"Suburban residential","region":"United States"},"searchableTags":["family","outdoor","summer","1980s","suburban","backyard"]},"outputSchema":"photo","usage":{"inputTokens":1247,"outputTokens":892,"totalTokens":2139,"modulesUsed":["dating","people","location","atmosphere"],"creditsCharged":4},"warnings":[],"meta":{"processingTimeMs":2340,"provider":"gemini","promptHash":"a1b2c3d4","requestId":"req_x7k9m2n4","ai_generated":true,"model":"gemini-3.1-flash-lite","vendor":"google"}}},"cache-hit":{"summary":"Full registry cache hit (zero credits)","value":{"id":"an_02k7lqn5y9p3g5","object":"analysis","schemaVersion":"1.0.0","createdAt":"2026-05-17T18:31:00Z","output":{"estimatedDate":{"year":1985,"confidence":0.82},"people":{"count":3}},"outputSchema":"photo","usage":{"inputTokens":0,"outputTokens":0,"totalTokens":0,"modulesUsed":["dating","people"],"creditsCharged":0},"warnings":[],"meta":{"processingTimeMs":18,"provider":"cache","promptHash":"cache-hit","requestId":"req_x7k9m2n5","cacheHit":true,"ai_generated":true,"model":"gemini-3.1-flash-lite","vendor":"google"}}},"vehicle-condition":{"summary":"Vehicle condition result","value":{"id":"an_02m8rtn5y9p3g5","object":"analysis","schemaVersion":"1.0.0","createdAt":"2026-05-17T18:35:00Z","output":{"overallCondition":{"grade":3.8,"confidence":0.75,"summary":"Good condition with minor cosmetic wear"},"observations":[{"signal":"scratches","severity":"minor","location":"driver_door","confidence":0.85},{"signal":"tire_tread_low","severity":"moderate","location":"front_left","confidence":0.72}],"vehicleContext":{"identifiedMake":"Honda","identifiedModel":"Accord","identifiedYear":2019}},"outputSchema":"vehicle","usage":{"inputTokens":4820,"outputTokens":1560,"totalTokens":6380,"modulesUsed":["vehicle-condition","multi-image","multi-image-vehicle"],"creditsCharged":1},"warnings":[],"meta":{"processingTimeMs":8920,"provider":"gemini","promptHash":"e5f6g7h8","requestId":"req_p3q5r7s9","ai_generated":true,"model":"gemini-3.1-flash-lite","vendor":"google"}}}}}}},"400":{"description":"Request validation failed. Common causes: no image provided, both single and multi-image inputs used, invalid URL format, base64 string too large (max 67 MB), unknown module name.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"retryable":{"type":"boolean"},"requestId":{"type":"string"}},"required":["code","message","retryable","requestId"]}},"required":["error"]},"example":{"error":{"code":"VALIDATION_FAILED","message":"Provide either imageUrl/imageBase64 OR images array, not both","retryable":false,"requestId":"req_a1b2c3d4"}}}}},"401":{"description":"Missing or invalid API key. Ensure your `Authorization` header is `Bearer pt_live_...` or `Bearer pt_test_...`.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"retryable":{"type":"boolean"},"requestId":{"type":"string"}},"required":["code","message","retryable","requestId"]}},"required":["error"]},"example":{"error":{"code":"AUTH_FAILED","message":"Invalid API key","retryable":false,"requestId":"req_e5f6g7h8"}}}}},"402":{"description":"Insufficient credits. Response body includes a `credits` object with the per-pool breakdown (`needed`, `community`, `purchased`, `total`) plus `resetsInDays` (relative integer; never an absolute timestamp). The route also sets `Cache-Control: no-store, private` so intermediaries do not cache the credit breakdown.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["PLAN_LIMIT_EXCEEDED"]},"message":{"type":"string"},"retryable":{"type":"boolean"},"requestId":{"type":"string"},"credits":{"type":"object","properties":{"needed":{"type":"integer"},"community":{"type":"integer"},"purchased":{"type":"integer"},"total":{"type":"integer"},"resetsInDays":{"type":"integer"}},"required":["needed","community","purchased","total","resetsInDays"]}},"required":["code","message","retryable","requestId","credits"]}},"required":["error"]},"example":{"error":{"code":"PLAN_LIMIT_EXCEEDED","message":"Insufficient credits. 16 needed, 3 available (community: 3, purchased: 0). Buy credits at https://phototology.com/dashboard/wallet","retryable":false,"requestId":"req_m3n4o5p6","credits":{"needed":16,"community":3,"purchased":0,"total":3,"resetsInDays":0}}}}}},"422":{"description":"Image fetch failed or image payload invalid (e.g. unreachable URL, unsupported format, exceeds size cap).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"retryable":{"type":"boolean"},"requestId":{"type":"string"}},"required":["code","message","retryable","requestId"]}},"required":["error"]},"example":{"error":{"code":"IMAGE_INVALID","message":"Image fetch failed: HTTP 404","retryable":false,"requestId":"req_q7r8s9t0"}}}}},"429":{"description":"Rate limit exceeded. All authenticated keys share a single 600 req/min cap. Anonymous `pt_test_*` keys (no user account) are capped at 2 req/min plus 10 req/day per IP for anti-abuse. Retry after the `Retry-After` header value (seconds).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"retryable":{"type":"boolean"},"requestId":{"type":"string"}},"required":["code","message","retryable","requestId"]}},"required":["error"]},"example":{"error":{"code":"RATE_LIMITED","message":"Too many requests. Retry after 45 seconds.","retryable":true,"requestId":"req_i9j0k1l2"}}}}},"502":{"description":"Upstream AI provider unavailable or returned an error. Retryable. The platform automatically falls back across providers; a 502 means every configured provider failed.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"retryable":{"type":"boolean"},"requestId":{"type":"string"}},"required":["code","message","retryable","requestId"]}},"required":["error"]},"example":{"error":{"code":"PROVIDER_UNAVAILABLE","message":"All AI providers failed","retryable":true,"requestId":"req_u1v2w3x4"}}}}}}}},"/v2/analyze":{"post":{"operationId":"analyzePhotoV2","summary":"Analyze a photo (v2)","description":"Registry v2 analyze endpoint. Superset of `/v1/analyze` with bespoke extraction (`extract.prompt` / `extract.schema` / `extract.schemaId`).\n\n### Delta billing\nThe registry projection tracks which lenses have already been analyzed for each `(userId, sha256)` photo. By default only newly-requested lenses are billed; previously-analyzed lenses are served from the projection at zero cost.\n\nSet `refresh: true` to force every requested lens to re-run (full billing). Bespoke extraction modes (`extract.*`) always bypass the registry path and bill normally: 5 credits per call regardless of cache state.\n\n### Bespoke extraction\nPass `extract.prompt` (natural-language) for AI-generated schemas, `extract.schema` for an inline JSON Schema, or `extract.schemaId` for a previously-saved schema. Exactly one of the three is required when using `extract`.","tags":["Analysis"],"security":[{"bearerAuth":[]}],"requestBody":{"description":"Image(s) to analyze with optional module composition, domain context, bespoke extraction, and delta-billing refresh flag.","required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"imageUrl":{"type":"string","maxLength":2048,"format":"uri"},"imageBase64":{"type":"string","minLength":1,"maxLength":67000000},"images":{"type":"array","items":{"type":"object","properties":{"url":{"type":"string","maxLength":2048,"format":"uri"},"base64":{"type":"string","minLength":1,"maxLength":67000000}}},"minItems":1,"maxItems":50},"preset":{"type":"string"},"modules":{"type":"array","items":{"type":"string"}},"modulesAdd":{"type":"array","items":{"type":"string"}},"modulesRemove":{"type":"array","items":{"type":"string"}},"moduleOptions":{"type":"object","additionalProperties":{"type":"object","additionalProperties":{}}},"context":{"type":"object","properties":{"knownPeople":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","maxLength":200},"birthYear":{"type":"integer"},"deathYear":{"type":"integer"},"role":{"type":"string","maxLength":100}},"required":["name"]},"maxItems":50},"vehicle":{"type":"object","properties":{"vin":{"type":"string","maxLength":20},"mileage":{"type":"integer"},"year":{"type":"integer"},"make":{"type":"string","maxLength":100},"model":{"type":"string","maxLength":100}}},"customInstructions":{"type":"string","maxLength":2000}}},"options":{"type":"object","properties":{"includeEmbedding":{"type":"boolean"},"includeFingerprint":{"type":"boolean"}}},"extract":{"type":"object","properties":{"prompt":{"type":"string","minLength":1,"maxLength":500},"schema":{"type":"object","properties":{"type":{"type":"string","enum":["object"]},"properties":{"type":"object","additionalProperties":{"type":"object","description":"Recursive bespoke-field JSON Schema node (type + optional description, enum, items, properties, required)."}},"required":{"type":"array","items":{"type":"string"}}},"required":["type","properties"]},"schemaId":{"type":"string","pattern":"^bsk_[0-9a-f]{32}$"}}},"refresh":{"type":"boolean","default":false}}}}}},"responses":{"200":{"description":"Analysis completed successfully. Response shape matches `/v1/analyze`; bespoke output appears under `output.bespoke`. On a full cache hit, `meta.cacheHit` is `true` and `usage.creditsCharged` is `0`.","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"object":{"type":"string","enum":["analysis"]},"schemaVersion":{"type":"string"},"createdAt":{"type":"string"},"output":{"type":"object","additionalProperties":{}},"outputSchema":{"type":"string","enum":["photo","vehicle"]},"usage":{"type":"object","properties":{"inputTokens":{"type":"number"},"outputTokens":{"type":"number"},"totalTokens":{"type":"number"},"modulesUsed":{"type":"array","items":{"type":"string"}},"creditsCharged":{"type":"integer"}},"required":["inputTokens","outputTokens","totalTokens","modulesUsed"]},"warnings":{"type":"array","items":{"type":"string"}},"meta":{"type":"object","properties":{"processingTimeMs":{"type":"number"},"provider":{"type":"string"},"promptHash":{"type":["string","null"]},"requestId":{"type":"string"},"cacheHit":{"type":"boolean"},"ai_generated":{"type":"boolean","enum":[true]},"model":{"type":"string"},"vendor":{"type":"string","enum":["google","openai","anthropic"]}},"required":["processingTimeMs","provider","promptHash","requestId","ai_generated","model","vendor"]},"embedding":{"type":"array","items":{"type":"number"}},"fingerprint":{"type":"object","properties":{"pHash":{"type":"string"},"dHash":{"type":"string"},"sha256":{"type":"string"}},"required":["pHash","dHash","sha256"]}},"required":["id","object","schemaVersion","createdAt","output","outputSchema","usage","warnings","meta"]}}}},"400":{"description":"Request validation failed.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"retryable":{"type":"boolean"},"requestId":{"type":"string"}},"required":["code","message","retryable","requestId"]}},"required":["error"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"retryable":{"type":"boolean"},"requestId":{"type":"string"}},"required":["code","message","retryable","requestId"]}},"required":["error"]}}}},"402":{"description":"Insufficient credits. Same shape as `/v1/analyze`: `error.credits` carries `needed`, `community`, `purchased`, `total`, and `resetsInDays`. Route sets `Cache-Control: no-store, private`.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["PLAN_LIMIT_EXCEEDED"]},"message":{"type":"string"},"retryable":{"type":"boolean"},"requestId":{"type":"string"},"credits":{"type":"object","properties":{"needed":{"type":"integer"},"community":{"type":"integer"},"purchased":{"type":"integer"},"total":{"type":"integer"},"resetsInDays":{"type":"integer"}},"required":["needed","community","purchased","total","resetsInDays"]}},"required":["code","message","retryable","requestId","credits"]}},"required":["error"]}}}},"404":{"description":"Saved bespoke schema not found (only emitted when `extract.schemaId` is used).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"retryable":{"type":"boolean"},"requestId":{"type":"string"}},"required":["code","message","retryable","requestId"]}},"required":["error"]},"example":{"error":{"code":"SCHEMA_NOT_FOUND","message":"Schema not found or not accessible","retryable":false,"requestId":"req_y5z6a7b8"}}}}},"422":{"description":"Image fetch failed or payload invalid.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"retryable":{"type":"boolean"},"requestId":{"type":"string"}},"required":["code","message","retryable","requestId"]}},"required":["error"]}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"retryable":{"type":"boolean"},"requestId":{"type":"string"}},"required":["code","message","retryable","requestId"]}},"required":["error"]}}}},"502":{"description":"Upstream AI provider unavailable, returned an error, or bespoke extraction failed at the provider.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"retryable":{"type":"boolean"},"requestId":{"type":"string"}},"required":["code","message","retryable","requestId"]}},"required":["error"]}}}}}}},"/v2/lookup":{"post":{"operationId":"lookupPhotos","summary":"Look up photos in the user registry","description":"Submit one or more images (URL or base64) and receive the full `PhotoRecord` for each. The record is the registry projection keyed by lens. Free endpoint, no credits charged.\n\n### Match tiers\n- **exact**: SHA-256 equality\n- **fuzzy**: pHash Hamming distance within `threshold` (default 5, max 64)\n- **none**: no registry entry; `photo` is omitted\n\n### Response shape\nThe `results` object is keyed by SHA-256. Each `PhotoRecord.lenses` is a map from lens name to the latest lens output (`output`, `version`, `producedAt`, `coRunHash`, `provider`).","tags":["Registry"],"security":[{"bearerAuth":[]}],"requestBody":{"description":"Images to look up. Provide either `images` (URLs) or `images_base64` (inline data). Both arrays max 50 entries.","required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"images":{"type":"array","items":{"type":"string","format":"uri"},"minItems":1,"maxItems":50},"images_base64":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":50},"threshold":{"type":"integer","minimum":0,"maximum":64,"default":5}}}}}},"responses":{"200":{"description":"Lookup complete. Each result carries the full `PhotoRecord` on exact/fuzzy matches.","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["lookup"]},"results":{"type":"object","additionalProperties":{"type":"object","properties":{"matchType":{"type":"string","enum":["exact","fuzzy","none"]},"hammingDistance":{"type":"integer"},"photo":{"type":"object","properties":{"sha256":{"type":"string"},"pHash":{"type":"string"},"dHash":{"type":"string"},"firstAnalyzedAt":{"type":"string"},"lastAnalyzedAt":{"type":"string"},"totalCreditsSpent":{"type":"integer"},"analyzeCallCount":{"type":"integer"},"lenses":{"type":"object","additionalProperties":{"type":"object","properties":{"eventId":{"type":"string"},"output":{"type":"object","additionalProperties":{}},"version":{"type":"string"},"producedAt":{"type":"string"},"coRunHash":{"type":"string"},"provider":{"type":"string"}},"required":["eventId","output","version","producedAt","coRunHash","provider"]}}},"required":["sha256","pHash","dHash","firstAnalyzedAt","lastAnalyzedAt","totalCreditsSpent","analyzeCallCount","lenses"]}},"required":["matchType"]}},"meta":{"type":"object","properties":{"imagesSubmitted":{"type":"integer"},"imagesMatched":{"type":"integer"},"processingTimeMs":{"type":"integer"},"requestId":{"type":"string"}},"required":["imagesSubmitted","imagesMatched","processingTimeMs","requestId"]}},"required":["object","results","meta"]}}}},"400":{"description":"Validation failed (missing images, bad URL, SSRF-blocked host).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"retryable":{"type":"boolean"},"requestId":{"type":"string"}},"required":["code","message","retryable","requestId"]}},"required":["error"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"retryable":{"type":"boolean"},"requestId":{"type":"string"}},"required":["code","message","retryable","requestId"]}},"required":["error"]}}}},"429":{"description":"Lookup rate limit exceeded (300 req/min per key).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"retryable":{"type":"boolean"},"requestId":{"type":"string"}},"required":["code","message","retryable","requestId"]}},"required":["error"]}}}}}},"get":{"operationId":"lookupPhotoByFingerprint","summary":"Look up a single photo by fingerprint","description":"Query a single photo record by SHA-256 or pHash. Provide exactly one of `sha256` or `pHash`. `sha256` matches exactly; `pHash` uses Hamming distance within `threshold`.","tags":["Registry"],"security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":64,"maxLength":64,"pattern":"^[0-9a-f]+$"},"required":false,"name":"sha256","in":"query"},{"schema":{"type":"string","minLength":16,"maxLength":16,"pattern":"^[0-9a-f]+$"},"required":false,"name":"pHash","in":"query"},{"schema":{"type":["integer","null"],"minimum":0,"maximum":64,"default":5},"required":false,"name":"threshold","in":"query"}],"responses":{"200":{"description":"Lookup complete. Results keyed by the submitted fingerprint.","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["lookup"]},"results":{"type":"object","additionalProperties":{"type":"object","properties":{"matchType":{"type":"string","enum":["exact","fuzzy","none"]},"hammingDistance":{"type":"integer"},"photo":{"type":"object","properties":{"sha256":{"type":"string"},"pHash":{"type":"string"},"dHash":{"type":"string"},"firstAnalyzedAt":{"type":"string"},"lastAnalyzedAt":{"type":"string"},"totalCreditsSpent":{"type":"integer"},"analyzeCallCount":{"type":"integer"},"lenses":{"type":"object","additionalProperties":{"type":"object","properties":{"eventId":{"type":"string"},"output":{"type":"object","additionalProperties":{}},"version":{"type":"string"},"producedAt":{"type":"string"},"coRunHash":{"type":"string"},"provider":{"type":"string"}},"required":["eventId","output","version","producedAt","coRunHash","provider"]}}},"required":["sha256","pHash","dHash","firstAnalyzedAt","lastAnalyzedAt","totalCreditsSpent","analyzeCallCount","lenses"]}},"required":["matchType"]}},"meta":{"type":"object","properties":{"imagesSubmitted":{"type":"integer"},"imagesMatched":{"type":"integer"},"processingTimeMs":{"type":"integer"},"requestId":{"type":"string"}},"required":["imagesSubmitted","imagesMatched","processingTimeMs","requestId"]}},"required":["object","results","meta"]}}}},"400":{"description":"Validation failed: missing both `sha256` and `pHash`, or malformed fingerprint.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"retryable":{"type":"boolean"},"requestId":{"type":"string"}},"required":["code","message","retryable","requestId"]}},"required":["error"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"retryable":{"type":"boolean"},"requestId":{"type":"string"}},"required":["code","message","retryable","requestId"]}},"required":["error"]}}}},"429":{"description":"Lookup rate limit exceeded (300 req/min per key).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"retryable":{"type":"boolean"},"requestId":{"type":"string"}},"required":["code","message","retryable","requestId"]}},"required":["error"]}}}}}}},"/v2/enrich":{"post":{"operationId":"enrichPhotoMetadata","summary":"Write cached lens output into a photo's EXIF/IPTC/XMP metadata","description":"Embeds cached lens output (from a prior `/v2/analyze` call) into the photo's EXIF/IPTC/XMP metadata blocks so the analysis travels with the file. Operationalizes the apex \"Vendor-portable\" pillar: enriched bytes carry their structured intelligence in standard metadata fields any downstream tool can read.\n\n### Source-of-truth gate\nRequires a cached registry entry for the `(userId, sha256)`. If the photo has not been analyzed before, returns 404 `PHOTO_NOT_IN_REGISTRY`. The endpoint does NOT trigger analysis — call `POST /v2/analyze` first.\n\n### Cost\n5 credits per call. Matches the bespoke schema extraction tier. Bills regardless of cache state — the write-back work is its own billable operation.\n\n### C2PA signing\nDeferred. `c2pa` in the `formats` array is rejected at validation time with a structured deferred-feature message. Provenance signing requires the Railway `c2patool` binary, a CA-chained cert, and a KMS-backed key (see `packages/phototology/CLAUDE.md` Sprint 3 follow-ups). Use `[\"exif\", \"iptc\", \"xmp\"]` for now.","tags":["Registry"],"security":[{"bearerAuth":[]}],"requestBody":{"description":"Image (by URL or base64) plus the metadata block formats to write.","required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"imageUrl":{"type":"string","maxLength":2048,"format":"uri"},"imageBase64":{"type":"string","minLength":1,"maxLength":67000000},"formats":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":4}},"required":["formats"]},"examples":{"xmp-only":{"summary":"Write XMP only (smallest, most portable)","value":{"imageUrl":"https://example.com/family-1985.jpg","formats":["xmp"]}},"all-blocks":{"summary":"Write all three blocks (EXIF + IPTC + XMP)","value":{"imageBase64":"/9j/4AAQSkZJRg...","formats":["exif","iptc","xmp"]}}}}}},"responses":{"200":{"description":"Enriched photo returned as base64 in the response body. The caller decodes and saves to disk.","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["enrichment"]},"imageBase64":{"type":"string"},"formatsWritten":{"type":"array","items":{"type":"string","enum":["exif","iptc","xmp"]}},"lensVersions":{"type":"object","additionalProperties":{"type":"string"}},"sha256":{"type":"string"},"meta":{"type":"object","properties":{"requestId":{"type":"string"},"processingTimeMs":{"type":"integer"},"creditsCharged":{"type":"integer"},"ai_generated":{"type":"boolean","enum":[true]}},"required":["requestId","processingTimeMs","creditsCharged","ai_generated"]}},"required":["object","imageBase64","formatsWritten","lensVersions","sha256","meta"]}}}},"400":{"description":"Validation failed (missing image, both image inputs supplied, unknown format, or `c2pa` requested — deferred).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"retryable":{"type":"boolean"},"requestId":{"type":"string"}},"required":["code","message","retryable","requestId"]}},"required":["error"]}}}},"401":{"description":"Missing or invalid API key, or key lacks a userId.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"retryable":{"type":"boolean"},"requestId":{"type":"string"}},"required":["code","message","retryable","requestId"]}},"required":["error"]}}}},"402":{"description":"Insufficient credits (5 needed). Same shape as analyze: `error.credits` carries the breakdown.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["PLAN_LIMIT_EXCEEDED"]},"message":{"type":"string"},"retryable":{"type":"boolean"},"requestId":{"type":"string"},"credits":{"type":"object","properties":{"needed":{"type":"integer"},"community":{"type":"integer"},"purchased":{"type":"integer"},"total":{"type":"integer"},"resetsInDays":{"type":"integer"}},"required":["needed","community","purchased","total","resetsInDays"]}},"required":["code","message","retryable","requestId","credits"]}},"required":["error"]}}}},"404":{"description":"Photo not in the registry for this account. Call `POST /v2/analyze` first.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"retryable":{"type":"boolean"},"requestId":{"type":"string"}},"required":["code","message","retryable","requestId"]}},"required":["error"]},"example":{"error":{"code":"PHOTO_NOT_IN_REGISTRY","message":"Analyze this photo first before requesting enrichment. The photo has no cached lens output for this account.","retryable":false,"requestId":"req_enrich_miss"}}}}},"422":{"description":"Image bytes invalid or fetch failed.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"retryable":{"type":"boolean"},"requestId":{"type":"string"}},"required":["code","message","retryable","requestId"]}},"required":["error"]}}}}}}},"/v1/modules":{"get":{"operationId":"listModules","summary":"List lenses and stacks","description":"Returns all available lenses (single analysis dimensions) and curated stacks (pre-bundled lenses for specific domains/use-cases). Use lens names in the `modules`, `modulesAdd`, or `modulesRemove` fields of the analyze request. Use stack names in the `preset` field. Users can combine lenses with a curated stack via `modulesAdd`/`modulesRemove`, or build a custom stack from scratch by passing `modules` directly.\n\n**Naming note.** The API surface uses the wire field names `modules` (individual lenses) and `presets` (curated stacks) for backward compatibility with existing SDK callers. The MCP server exposes the same units as **lenses** and **stacks**. The concept words are lens / stack; the wire field names are `modules` / `preset` and stay that way.","tags":["Discovery"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Available lenses and curated stacks.","content":{"application/json":{"schema":{"type":"object","properties":{"modules":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","description":"Module identifier. Use this in the `modules` array.","example":"dating"},"description":{"type":"string","description":"What this module analyzes","example":"Estimate photo date from visual cues, technology anchors, and temporal markers"},"category":{"type":"string","description":"Module category","example":"temporal"},"outputFields":{"type":"array","items":{"type":"string"},"description":"Fields this module adds to the `output` object","example":["estimatedDate","dateAnchors","season"]},"billable":{"type":"boolean","description":"Whether running this lens charges a credit. `moderation` is the only non-billable lens (always-on safety, always free). Cache hits cost zero credits regardless of `billable`.","example":true},"defaultColumns":{"type":"array","items":{"type":"object","properties":{"label":{"type":"string","description":"User-visible spreadsheet column header.","example":"estimated_year"},"jsonPath":{"type":"string","description":"Dotted path into `analysisResult` for this column's value.","example":"dating.estimatedYear"},"format":{"type":"string","enum":["string","number","date","percentage","tags","hex"],"description":"Cell rendering hint for the export pipeline.","example":"number"}},"required":["label","jsonPath"]},"description":"Default spreadsheet columns this lens produces when projected to a tabular export. Used by analyze.phototology.com and any SDK/MCP consumer that wants a default tabular projection without re-implementing the mapping."},"internal":{"type":"boolean","enum":[true],"description":"`true` if the lens is real (emits output, has columns) but NOT directly selectable via `modules: []` on /v1/analyze. Reachable only through a curated stack (e.g. `vehicle-condition` is reachable via the `vehicle-condition` stack). Omitted for the 15 directly-selectable lenses."}},"required":["name","description","category","outputFields","billable","defaultColumns"]},"description":"Individual analysis lenses. Each lens adds specific fields to the analysis output and a default tabular projection."},"presets":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","description":"Stack identifier (the curated bundle name). Use this in the `preset` field of the analyze request.","example":"full-analysis"},"description":{"type":"string","description":"What this curated stack is designed for","example":"Full photo analysis with every available lens"},"modules":{"type":"array","items":{"type":"string"},"description":"Lenses included in this curated stack","example":["dating","people","location","atmosphere"]}},"required":["name","description","modules"]},"description":"Curated stacks — pre-bundled lenses for specific domains and use-cases."}},"required":["modules","presets"]},"example":{"modules":[{"name":"dating","description":"Estimate photo date from visual cues, technology anchors, and temporal markers","category":"temporal","outputFields":["estimatedDate","dateAnchors","season","holiday"],"billable":true,"defaultColumns":[{"label":"estimated_year","jsonPath":"dating.estimatedYear","format":"number"},{"label":"era","jsonPath":"dating.era","format":"string"},{"label":"confidence","jsonPath":"dating.confidence","format":"percentage"}]},{"name":"moderation","description":"Content safety check for nudity, violence, and sensitive material","category":"safety","outputFields":["moderation"],"billable":false,"defaultColumns":[]},{"name":"vehicle-condition","description":"Multi-image vehicle grading, damage signals, accident indicators, seller summary.","category":"general","outputFields":["photos","overallCondition","observations","componentGrades","accidentIndicators","photoQuality","missingViews","vehicleContext"],"billable":false,"defaultColumns":[{"label":"overall_grade","jsonPath":"vehicleCondition.overallGrade","format":"string"},{"label":"damage_count","jsonPath":"vehicleCondition.observations.length","format":"number"},{"label":"seller_summary","jsonPath":"vehicleCondition.sellerSummary","format":"string"}],"internal":true}],"presets":[{"name":"full-analysis","description":"Full photo analysis with all available modules","modules":["dating","people","location","atmosphere","photo-quality","entities","composition","text-content","accessibility","describe","condition","authenticity","color-palette","automobile"]},{"name":"vehicle-condition","description":"Multi-image vehicle condition assessment with damage detection","modules":["multi-image","multi-image-vehicle","vehicle-condition"]},{"name":"quick-scan","description":"Moderation-only safety scan (no billable lenses, since moderation is always free)","modules":["moderation"]}]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"retryable":{"type":"boolean"},"requestId":{"type":"string"}},"required":["code","message","retryable","requestId"]}},"required":["error"]}}}}}}},"/v1/usage":{"get":{"operationId":"getUsage","summary":"Get credit balance","description":"Returns the authenticated user's dual-pool credit balance: community credits (signup grant for new users; legacy monthly refill for accounts created before the pricing v1 cutoff), purchased credits (carry forward across months), and credits currently reserved by in-flight analyze calls.\n\n### Dual auth\nSupports both Bearer API key (external callers) and `x-internal-secret` header plus `?userId=<id>` query param (server-to-server, for dashboard integrations).","tags":["Usage"],"security":[{"bearerAuth":[]},{"internalSecret":[]}],"parameters":[{"schema":{"type":"string","description":"User ID. Required only when using `x-internal-secret` auth.","example":"usr_01j9kqm7x8n2f4"},"required":false,"name":"userId","in":"query"},{"schema":{"type":"string","enum":["starter"],"description":"Legacy tier query param. Only `starter` is valid; the Growth subscription tier was retired in 2026-05-17. The field is accepted for backward compatibility with phototology-web server actions that may still pass it; the response tier is always `starter`.","example":"starter"},"required":false,"name":"tier","in":"query"}],"responses":{"200":{"description":"Current credit balance for the authenticated user.","content":{"application/json":{"schema":{"type":"object","properties":{"tier":{"type":"string","description":"API key tier. Historically `starter` or `growth`; the Growth subscription tier was retired in pricing v1 (2026-05-17) so this field now always returns `starter`. The field is kept in the response shape for SDK backward compatibility and may be removed in a future major version.","example":"starter"},"community":{"type":"object","properties":{"balance":{"type":"integer","description":"Remaining community credits. Signup-grant landing pool: new accounts receive 1,000 for verifying an email and 4,000 for adding a card-on-file (one-time, not recurring). The monthly auto-refill was retired in pricing v1 (cutover 2026-05-18).","example":743},"monthlyAllowance":{"type":"integer","description":"Legacy field. Was the monthly grant amount (e.g. 1,000 for starter). Reports 0 for accounts created after the pricing v1 cutoff (2026-05-18). Kept for backward compatibility.","example":0},"referralBonus":{"type":"integer","description":"Extra credits earned through referrals (added to the community pool).","example":0},"resetsInDays":{"type":"integer","description":"Legacy field. Was the days-until-monthly-refill counter. Reports 0 once the pricing v1 cutoff has bound the account. Relative integer (never an absolute timestamp). Kept for backward compatibility.","example":0}},"required":["balance","monthlyAllowance","resetsInDays"]},"purchased":{"type":"object","properties":{"balance":{"type":"integer","description":"Remaining purchased credits. Carries over across months. Spent only after community pool is exhausted.","example":5000}},"required":["balance"]},"reserved":{"type":"integer","description":"Credits currently held in reserve for in-flight analyze calls.","example":0}},"required":["tier","community","purchased","reserved"]},"example":{"tier":"starter","community":{"balance":743,"monthlyAllowance":0,"referralBonus":0,"resetsInDays":0},"purchased":{"balance":5000},"reserved":0}}}},"400":{"description":"Invalid tier or other validation failure on the internal-secret path.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"retryable":{"type":"boolean"},"requestId":{"type":"string"}},"required":["code","message","retryable","requestId"]}},"required":["error"]}}}},"401":{"description":"Missing or invalid auth (Bearer token rejected; `x-internal-secret` does not match).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"retryable":{"type":"boolean"},"requestId":{"type":"string"}},"required":["code","message","retryable","requestId"]}},"required":["error"]}}}}}}},"/v1/llms.txt":{"get":{"operationId":"getLlmsTxt","summary":"AI agent discovery (llms.txt)","description":"Static `llms.txt` file describing the API to LLM-powered agents and crawlers. Lists endpoints, lens / stack names, delta-billing behavior, and SDK / MCP install hints. No authentication required.","tags":["Discovery"],"responses":{"200":{"description":"Plain text llms.txt file.","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/v1/internal/evict":{"post":{"operationId":"evictKeyCache","summary":"Evict an API key from the in-memory cache (server-to-server)","description":"Internal endpoint used by Phototology dashboard server actions and webhook hooks to evict revoked API keys from the Express service's LRU cache immediately, rather than waiting for the 5-minute TTL. Provide either `userId` (evicts all keys for that user) or `keyHash` (evicts one key). Auth is via `x-internal-secret` header.","tags":["System"],"security":[{"internalSecret":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"userId":{"type":"string","description":"Evict all keys for this user. Provide `userId` OR `keyHash`, not both.","example":"usr_01j9kqm7x8n2f4"},"keyHash":{"type":"string","description":"SHA-256 hash of a single key to evict."}}}}}},"responses":{"200":{"description":"Eviction completed.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]}},"required":["ok"]},"example":{"ok":true}}}},"400":{"description":"Neither `userId` nor `keyHash` was provided."},"401":{"description":"Missing or invalid `x-internal-secret` header."}}}},"/v1/health":{"get":{"operationId":"healthCheck","summary":"Health check","description":"Returns service status. No authentication required. Use this for uptime monitoring.","tags":["System"],"responses":{"200":{"description":"Service is healthy and ready to accept requests.","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"],"description":"Always `ok` when the service is healthy"},"service":{"type":"string","description":"Service identifier","example":"phototology-api"},"version":{"type":"string","description":"API version","example":"v1"},"timestamp":{"type":"string","description":"ISO 8601 timestamp","example":"2026-05-17T18:30:00.000Z"}},"required":["status","service","version","timestamp"]},"example":{"status":"ok","service":"phototology-api","version":"v1","timestamp":"2026-05-17T18:30:00.000Z"}}}}}}},"/v1/openapi.json":{"get":{"operationId":"getOpenApiSpec","summary":"OpenAPI 3.1 spec (this document)","description":"Returns this OpenAPI specification as JSON. Useful for SDK code-generators and API explorers.","tags":["System"],"responses":{"200":{"description":"OpenAPI 3.1 document.","content":{"application/json":{"schema":{"type":"object","properties":{}}}}}}}},"/v1/docs":{"get":{"operationId":"getDocs","summary":"Interactive API documentation","description":"Rendered API reference (Scalar). Browse endpoints, schemas, and examples in a UI.","tags":["System"],"responses":{"200":{"description":"HTML page rendering the Scalar API reference.","content":{"text/html":{"schema":{"type":"string"}}}}}}}},"webhooks":{},"x-tagGroups":[{"name":"Core","tags":["Analysis","Registry"]},{"name":"Reference","tags":["Discovery","Usage"]},{"name":"Operations","tags":["System"]}]}