{"openapi":"3.0.3","info":{"title":"GridHearth Sudoku — Public API v2","version":"2.0.0","description":"Read-only public API over GridHearth's Sudoku puzzle pool. Every operation is a GET of non-sensitive puzzle data (the same data the live site shows any visitor) — nothing here writes anything.\n\n**Versioning**: this document describes the `/api/v2/*` surface introduced in Phase 9.1. The pre-9.1 unversioned paths (`/api/puzzles/...`, no `/v2/`) and their `/api/v1/...` alias keep working exactly as before, without rate limiting or API-key checks — they exist so the existing web UI (Phase 8, `web/play/`) never breaks, but aren't the surface meant for external/programmatic callers going forward.\n\n**Rate limiting**: every `/api/v2/*` response carries `X-RateLimit-Limit`/`X-RateLimit-Remaining`/`X-RateLimit-Reset` headers. Exceeding the limit returns 429 with a `Retry-After` header. Limits are per minute, fixed-window: 60 requests/min anonymous, 600/min with a valid API key.\n\n**API key (optional)**: send `X-API-Key` (or `?api_key=`) to get the higher rate-limit tier. Omit it entirely and you're still served, just at the anonymous limit. A key that doesn't resolve to an issued one is rejected with 401 rather than silently falling back to anonymous. Keys are issued out of band by the project operator (`sudoku-engine apikey create --name ...`) — there's no self-serve signup endpoint.","contact":{"name":"GridHearth","url":"https://gridhearth.com"}},"servers":[{"url":"https://sudoku-gen.gridhearth.com","description":"Production edge API"}],"components":{"securitySchemes":{"ApiKeyAuth":{"type":"apiKey","in":"header","name":"X-API-Key","description":"Optional. Omit for the anonymous rate-limit tier."}},"parameters":{"PuzzleId":{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"A puzzle's canonical_id, a.k.a. its shareable \"puzzle code\"."}},"headers":{"RateLimitLimit":{"description":"Requests allowed per window at the caller's current tier.","schema":{"type":"integer"}},"RateLimitRemaining":{"description":"Requests left in the current window.","schema":{"type":"integer"}},"RateLimitReset":{"description":"Seconds until the current window resets.","schema":{"type":"integer"}}},"schemas":{"Error":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]},"RateLimitError":{"type":"object","properties":{"error":{"type":"string","example":"rate limit exceeded"},"retry_after_seconds":{"type":"integer"}}},"PuzzlePublicRecord":{"type":"object","description":"A puzzle without its answer — see /solution for that. Mirrors sudoku_engine.pool_export.pool_record() minus solution/solution_canonical.","properties":{"canonical_id":{"type":"string"},"puzzle_id":{"type":"string"},"puzzle":{"type":"string","description":"81 characters, row-major, '.' for an empty cell."},"canonical":{"type":"string"},"difficulty":{"type":"string","example":"Easy"},"difficulty_level":{"type":"integer"},"clue_count":{"type":"integer"},"symmetric":{"type":"boolean"},"symmetry":{"type":"string"},"seed":{"type":["integer","null"]},"created_at":{"type":"integer"},"tags":{"type":"array","items":{"type":"string"}},"score":{"type":["number","null"]},"tier":{"type":["string","null"]},"stars":{"type":["integer","null"]},"hardest_technique":{"type":["string","null"]},"techniques":{"type":"array","items":{"type":"string"}},"solve_path":{"type":["array","null"],"items":{"type":"object"},"description":"Step-by-step solving explanation (sudoku_engine.explain.solve_path), or null if this puzzle hasn't been analyzed."},"source":{"type":"string"},"volume":{"type":["string","null"]},"used_at":{"type":["integer","null"]},"synced_at":{"type":"integer"}}},"SolutionResponse":{"type":"object","properties":{"canonical_id":{"type":"string"},"solution":{"type":"string","description":"81 characters, row-major, fully solved."}}},"GlobalStats":{"type":"object","properties":{"total":{"type":"integer"},"by_difficulty":{"type":"object","additionalProperties":{"type":"integer"}},"last_synced_at":{"type":["integer","null"]}}},"PoolExportsResponse":{"type":"object","properties":{"objects":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string"},"size":{"type":"integer"},"uploaded":{"type":"string","format":"date-time"}}}},"truncated":{"type":"boolean"}}}}},"security":[{},{"ApiKeyAuth":[]}],"paths":{"/api/v2/health":{"get":{"summary":"Liveness check","operationId":"getHealthV2","responses":{"200":{"description":"The API is up.","headers":{"X-RateLimit-Limit":{"$ref":"#/components/headers/RateLimitLimit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/RateLimitRemaining"}},"content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}}}}}}}}},"/api/v2/stats":{"get":{"summary":"Pool-wide puzzle counts","operationId":"getStatsV2","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GlobalStats"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}}}}}},"/api/v2/puzzles/random":{"get":{"summary":"Pick a random puzzle","operationId":"getRandomPuzzleV2","parameters":[{"name":"difficulty","in":"query","required":false,"schema":{"type":"string","enum":["easy","medium","hard","expert"]},"description":"Case-insensitive. Omit for an unfiltered pick, weighted by the pool's current difficulty mix."}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PuzzlePublicRecord"}}}},"404":{"description":"No puzzles available for that difficulty (or at all)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}}}}}},"/api/v2/puzzles/{id}":{"get":{"summary":"Fetch a specific puzzle by its canonical_id","operationId":"getPuzzleV2","parameters":[{"$ref":"#/components/parameters/PuzzleId"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PuzzlePublicRecord"}}}},"404":{"description":"No puzzle with that id","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v2/puzzles/{id}/solution":{"get":{"summary":"Fetch a puzzle's solution","operationId":"getPuzzleSolutionV2","parameters":[{"$ref":"#/components/parameters/PuzzleId"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SolutionResponse"}}}},"404":{"description":"No puzzle with that id","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v2/puzzles/{id}/image":{"get":{"summary":"Render a puzzle (or its solution) as a PNG","operationId":"getPuzzleImageV2","parameters":[{"$ref":"#/components/parameters/PuzzleId"},{"name":"variant","in":"query","required":false,"schema":{"type":"string","enum":["puzzle","solution"]},"description":"Defaults to the puzzle grid; \"solution\" renders the filled-in grid instead."}],"responses":{"200":{"description":"A PNG image","content":{"image/png":{"schema":{"type":"string","format":"binary"}}}},"404":{"description":"No puzzle with that id","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v2/pool-exports":{"get":{"summary":"List the pool's NDJSON export snapshots in R2","operationId":"getPoolExportsV2","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PoolExportsResponse"}}}}}}},"/api/v2/openapi.json":{"get":{"summary":"This document","operationId":"getOpenApiSpecV2","security":[],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object"}}}}}}}}}