{"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 expire 12 months from purchase.\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`.\n\n**Content safety block.** Moderation runs on every photo before analysis. If an image is blocked, this endpoint still returns **200** (deliberately not a 4xx) with `output.moderation.safe: false`, a human-readable `output.moderation.reason`, `meta.safetyBlockInfo`, `usage.creditsCharged: 0`, and `Cache-Control: no-store, private`. Check `output.moderation.safe` before trusting any analysis fields. See the `moderation-block` example.","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"object":{"type":"string","enum":["analysis"]},"livemode":{"type":"boolean"},"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"},"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","livemode","schemaVersion","createdAt","output","outputSchema","usage","warnings","meta"]},"examples":{"full-analysis":{"summary":"Photo analysis result (fresh LLM run)","value":{"livemode":true,"id":"an_01j9kqm7x8n2f4","object":"analysis","schemaVersion":"2.1.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"}},"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":{"livemode":true,"id":"an_02k7lqn5y9p3g5","object":"analysis","schemaVersion":"2.1.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":{"livemode":true,"id":"an_02m8rtn5y9p3g5","object":"analysis","schemaVersion":"1.2.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"}}},"moderation-block":{"summary":"Content blocked by moderation (still HTTP 200)","value":{"livemode":true,"id":"an_03n9svp6z0q4h6","object":"analysis","schemaVersion":"2.1.0","createdAt":"2026-05-31T12:00:00Z","output":{"moderation":{"safe":false,"reason":"Image flagged by the content safety screen.","flags":["content_safety_block"],"provider_block":true}},"outputSchema":"photo","usage":{"inputTokens":0,"outputTokens":0,"totalTokens":0,"modulesUsed":[],"creditsCharged":0},"warnings":[],"meta":{"processingTimeMs":240,"provider":"moderation-screen","promptHash":null,"requestId":"req_block_1","safetyBlockInfo":{"blockType":"content","blockReason":"SAFETY","finishReason":null,"safetyCategories":[]},"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/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`. A content-safety block returns **200** with `output.moderation.safe: false` (same contract as `/v1/analyze`; see its `moderation-block` example).","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"object":{"type":"string","enum":["analysis"]},"livemode":{"type":"boolean"},"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"},"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","livemode","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### When to use\nCall this before `POST /v2/analyze` whenever you have image inputs. Any photo already in the registry (by exact SHA-256 or perceptual hash) comes back here for free, so you only spend credits on genuine cache misses. This is the registry-first path that makes repeat work cost zero.\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":"Fast-path single-photo registry lookup for when you already hold a fingerprint. Free, no credits charged. Provide exactly one of `sha256` (exact match) or `pHash` (perceptual match within `threshold`, default 5, max 64).\n\n### When to use\nReach for this (instead of `POST /v2/lookup`) when you have already computed the SHA-256 or pHash and want to skip re-sending the image bytes. Like the POST form, call it before `POST /v2/analyze` to reuse a prior result for free.\n\n### Response shape\nReturns the same `LookupResponse` as the POST form: `results` keyed by the submitted fingerprint, each carrying the full `PhotoRecord` whose `lenses` map holds every cached lens output (`output`, `version`, `producedAt`, `coRunHash`, `provider`). A `none` match omits `photo`.","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. The enriched bytes carry their structured intelligence in standard metadata fields any downstream tool can read, with no further Phototology call required to access the analysis.\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, which is 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","count"],"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 spendable credit balance. `total` is the single number to show (community + purchased); effective spendable is `total - reserved`. The `community` / `purchased` split is retained for back-compat and refund-to-origin accounting: 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 `reserved` credits currently held 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"},"total":{"type":"integer","description":"Single spendable credit balance (community + purchased). This is the number to show and reason about; effective spendable credit is `total - reserved`. Added 2026-05-29 (additive, non-breaking). The `community` / `purchased` split below is retained for back-compat and refund-to-origin accounting, not as two balances the holder manages.","example":5743},"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":"Display-only lifetime referral earnings (cumulative credits a referrer has earned). NOT a spendable sub-balance: the actual referral grant lands in the purchased pool as spendable credit. Kept for backward compatibility.","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","total","community","purchased","reserved"]},"example":{"tier":"starter","total":5743,"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"]}]}