{"openapi":"3.1.0","info":{"title":"Seikai content service","description":"\nThe content service behind **Seikai JLPT**. It serves versioned content packs\n(questions, passages, mock-test blueprints) to the app, the Telegram bot and the\nwebsite, streams the listening clips and pictures, and collects error reports.\n\n## How a client stays current\n\n1. `GET /v1/content/manifest` lists every pack with its `version` and `contentHash`.\n2. A pack the client has never seen: `GET /v1/content/packs/{id}` (the whole pack, 1–14 MB).\n3. A pack the client has at an older version: `GET /v1/content/packs/{id}/changes?since={v}`\n   returns only what changed (upserts carry the full row with a higher `revision`,\n   deletes are id lists). `204` means the client is current; `410` means `v` is older\n   than the retained history and the whole pack should be fetched.\n4. After applying a change set the client recomputes `contentHash` (FNV-1a 64 over\n   `p|q|t <id> <revision>` lines, each group sorted by id) and refetches the pack on a mismatch.\n\nListening clips and pictures are not part of the packs: each question carries an\n`assetId` (`audio/n3/N3-2023-12-L1-01.webm`, `images/n5/N5-2024-07-L1-01.webp`) with\n`bytes` and `sha256`, and the file is served at `/{assetId}`.\n\nThe full pack schema is in the app repository, `docs/CONTENT-MODEL.md`.\n","contact":{"name":"Seikai JLPT"},"license":{"name":"Content is not licensed for reuse"},"version":"0.1.0"},"paths":{"/healthz":{"get":{"tags":["service"],"summary":"Liveness check","operationId":"healthz_healthz_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}}}}},"/v1/content/manifest":{"get":{"tags":["content"],"summary":"Every pack with its version and hash","description":"The first call of every sync. Compare each pack's `version` with the local one.","operationId":"manifest_v1_content_manifest_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Manifest"}}}},"404":{"description":"Nothing published yet"}}}},"/v1/content/packs/{pack_id}":{"get":{"tags":["content"],"summary":"One whole pack","description":"1–14 MB of JSON. Fetched when a pack is new to the client or its history is too old for a change set.","operationId":"pack_v1_content_packs__pack_id__get","parameters":[{"name":"pack_id","in":"path","required":true,"schema":{"type":"string","pattern":"^[a-z0-9-]+$","description":"Pack id from the manifest, `jlpt-n5` … `jlpt-n1`.","examples":["jlpt-n3"],"title":"Pack Id"},"description":"Pack id from the manifest, `jlpt-n5` … `jlpt-n1`."}],"responses":{"200":{"description":"The pack: `id`, `level`, `version`, `title`, `contentHash`, `passages[]`, `questions[]`, `tests[]` (schema: app `docs/CONTENT-MODEL.md`).","content":{"application/json":{"schema":{}}}},"404":{"description":"No such pack"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/content/packs/{pack_id}/changes":{"get":{"tags":["content"],"summary":"What changed since a version","description":"Upserts carry the full row with its new `revision`; a client ignores an upsert whose revision is not higher than its own.","operationId":"changes_v1_content_packs__pack_id__changes_get","parameters":[{"name":"pack_id","in":"path","required":true,"schema":{"type":"string","pattern":"^[a-z0-9-]+$","description":"Pack id from the manifest, `jlpt-n5` … `jlpt-n1`.","examples":["jlpt-n3"],"title":"Pack Id"},"description":"Pack id from the manifest, `jlpt-n5` … `jlpt-n1`."},{"name":"since","in":"query","required":false,"schema":{"type":"integer","minimum":0,"description":"The pack version the client has.","examples":[1],"default":0,"title":"Since"},"description":"The pack version the client has."}],"responses":{"200":{"description":"`{packId, fromVersion, toVersion, upsertPassages[], upsertQuestions[], upsertTests[], deletePassageIds[], deleteQuestionIds[], deleteTestIds[]}`","content":{"application/json":{"schema":{}}}},"204":{"description":"The client is already at the current version"},"404":{"description":"No such pack"},"410":{"description":"`since` is older than the retained history: fetch the whole pack"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/reports":{"post":{"tags":["feedback"],"summary":"Report a problem with a question","description":"Queued by the app and sent with the next content check. Read on the server with `seikai reports`. No user identifier is sent or stored beyond the client address.","operationId":"post_report_v1_reports_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportIn"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"413":{"description":"Body over 8 KB"},"422":{"description":"Missing or oversized field"},"429":{"description":"More than 30 requests per minute from one address"}}}},"/v1/votes":{"post":{"tags":["feedback"],"summary":"Ask for an explanation (kept for the bot)","description":"The app no longer sends these (every question has an explanation since October 2026); the endpoint stays for other clients.","operationId":"post_vote_v1_votes_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VoteIn"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"413":{"description":"Body over 8 KB"},"429":{"description":"Rate limited"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}}},"components":{"schemas":{"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"Manifest":{"properties":{"packs":{"items":{"$ref":"#/components/schemas/PackInfo"},"type":"array","title":"Packs"}},"type":"object","required":["packs"],"title":"Manifest"},"Ok":{"properties":{"ok":{"type":"boolean","title":"Ok","default":true}},"type":"object","title":"Ok"},"PackInfo":{"properties":{"id":{"type":"string","title":"Id","examples":["jlpt-n3"]},"level":{"type":"string","title":"Level","examples":["N3"]},"version":{"type":"integer","title":"Version","description":"Goes up by one on every publish that changed anything in this pack.","examples":[2]},"title":{"type":"string","title":"Title","examples":["N3 mock tests"]},"contentHash":{"type":"string","title":"Contenthash","description":"FNV-1a 64 over the pack's ids and revisions; the client recomputes it after a sync.","examples":["14db2d83c1b6ebb3"]},"questions":{"type":"integer","title":"Questions","examples":[2856]},"tests":{"type":"integer","title":"Tests","examples":[28]}},"type":"object","required":["id","level","version","title","contentHash","questions","tests"],"title":"PackInfo"},"ReportIn":{"properties":{"questionId":{"type":"string","maxLength":80,"title":"Questionid","description":"The question's stable id.","examples":["N3-2023-12-v001"]},"message":{"type":"string","maxLength":2000,"minLength":1,"title":"Message","description":"What is wrong, in the learner's words.","examples":["The explanation says 3 is right but the key says 2."]},"createdAt":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Createdat","description":"Client clock, epoch milliseconds.","examples":[1760000000000]},"appVersion":{"anyOf":[{"type":"string","maxLength":40},{"type":"null"}],"title":"Appversion","examples":["0.1 (1)"]},"questionRevision":{"anyOf":[{"type":"integer","minimum":0.0},{"type":"null"}],"title":"Questionrevision","description":"The question's `revision` on the phone when the report was written: which text was reported.","examples":[3]},"packVersion":{"anyOf":[{"type":"integer","minimum":0.0},{"type":"null"}],"title":"Packversion","description":"The pack `version` on the phone at that time.","examples":[4]}},"type":"object","required":["questionId","message"],"title":"ReportIn"},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"VoteIn":{"properties":{"questionId":{"type":"string","maxLength":80,"title":"Questionid","examples":["N3-2023-12-v001"]},"createdAt":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Createdat","examples":[1760000000000]}},"type":"object","required":["questionId"],"title":"VoteIn"}}},"tags":[{"name":"content","description":"Versioned content packs: manifest, full packs and change sets."},{"name":"feedback","description":"What learners send back: error reports on a question, and explanation votes (kept for the bot)."},{"name":"media","description":"Listening clips and pictures. In production Caddy serves these as static files; the API serves them in development."},{"name":"service","description":"Health and this page."}]}