kanji-buddy
Building a client
The flows that matter, as the web app actually performs them — including the ordering rules it got wrong three times — and the gotchas that will otherwise cost you an afternoon.
First run
GET /user → username, level, timezone, preferences
PUT /user { timezone: … } → if timezone is null, set it from the device
GET /subjects?levels=1,2,3 → cache; this is static content
GET /dashboard → draw the home screenCache subjects aggressively. There are ~9,400 of them and they effectively never change. Store them locally, keyed by id, and refresh with ?updated_after=<last sync> rather than re-downloading. A mobile client that re-fetches every launch is wasting the user’s data on content that has not moved since 2012.
Everything else — assignments, statistics, the dashboard — is per-user and changes constantly. Do not cache it beyond a screen.
Set the timezone on first run
It is null until something writes it, and it decides what “today” means for the streak, the heat map, the calendar periods on /review_stats and the quiet hours of push notifications. Read it from the device and PUT /user once.
A lesson session
GET /assignments?immediately_available_for_lessons
GET /subjects?ids=… → the items to teach
GET /study_materials?subject_ids=… → the learner's own synonyms
… teach, then quiz …
PUT /assignments/{id}/start → once per item, at the END of the batchStart the whole batch at the end, not item by item. Abandoning a quiz half-way then leaves the batch exactly as it was, rather than stranding three items in reviews and two in lessons. A batch is at most ten items, so little is at risk.
The picker’s queue may reorder items, but it must be intersected with what the API actually offers. A stale queue may shrink a session; it must never inject an item the API did not return.
What order lessons come in
The server hands you assignments; deciding what to teach first is the client’s job, and it is the part most worth copying exactly. The web app got it wrong three separate times, each time by ordering the list a second time somewhere out of sight of the screen that promised the order.
Teaching order
Sort the subjects — not the assignments — by level, then type (radical, kanji, vocabulary, kana_vocabulary), then data.lesson_position, falling back to subject id where that field is missing.
Neither id will do this for you
Assignment ids run in the order things were unlocked, which for anyone who has unlocked ahead of schedule is not the curriculum’s order at all. Subject ids are worse: WaniKani numbered all the radicals, then all the kanji, then all the vocabulary, so sorting by them is type-major rather than level-major — a level 5 radical would come before a level 2 word.
This means you must fetch the subjects for every candidate before you cut the batch, not for the batch after you have cut it. One extra request; it is the only way to know the order.
Interleaving
Interleaving threads the types together so a batch is a mix rather than five radicals. Place each item at the midpoint of its own type’s run — item i of n at (i + 0.5) / n — then sort by that fraction, breaking ties by type order so a radical still precedes the kanji it builds. Proportional, not round-robin: with 3 radicals and 30 vocabulary, round-robin gives a mixed opening and then 27 unbroken vocabulary, which is the monotony the option exists to avoid.
| Started from | Interleaved? |
|---|---|
| The dashboard's Start Lessons | Always. Not a setting — this is WaniKani's own behaviour |
| The Lesson Picker (Advanced) | Whatever its Interleave checkbox says, seeded from lessons_interleave in preferences |
So lessons_interleave is the picker’s default, not a global one. A client that applies it to the main path will let a user turn off something WaniKani never lets them turn off.
The picker’s queue
When someone picks items in the picker, store that selection and let it lead the next session — queued items first, in queue order, then everything else still in teaching order. The mistake to avoid is applying the queue to the raw assignment list: the picked items look deliberate at the front while the tail comes back in unlock order.
Intersect it with what the API returns, so a stale pick can shrink a session but never inject an item that is not really available. And give the user a way to see and clear it — a stored pick outranks every ordering decision made afterwards, and invisible state that overrides the list on screen is the bug that is hardest to report.
Batches
A batch is the head of that same sequence — take lessons_batch_size items off the front of the list you are already showing. Do not re-sort the selection among itself: interleaving three items is not the same as taking three out of an interleaved hundred, and the difference is a batch that starts in a different order from the one displayed.
Cap the dashboard’s batch by /lesson_allowance as well as the batch size; a batch started from the picker deliberately ignores the daily maximum, because choosing items yourself is how you do more or less than it.
When a batch ends, offer another one rather than returning to the home screen — only when something is left to fill it and the daily maximum is not spent. Re-fetch: the items just taught are no longer available for lessons, which is what makes the next batch the next batch.
Bringing burned items back
GET /assignments?burned=true → the burned pile
GET /assignments?resurrected=true&burned=false → what can be retired
GET /subjects?ids=… → to draw them
POST /burn_manager { action, subject_ids } → up to 200 at a time
→ read data.changed and data.skippedBuild each list from the filter that defines it and the UI cannot offer what the server would refuse. Show skipped rather than reporting the count you asked for: “12 done” when it did 9 is exactly how a bulk mistake goes unnoticed.
Filter by level range and type. A finished account has thousands of burned items and the ones a person wants are almost always “around the level I was on when I stopped”.
A review session
GET /assignments?immediately_available_for_review
GET /subjects?ids=…
GET /study_materials?subject_ids=…
… for each item, as it is answered …
POST /reviews { assignment_id, incorrect_meaning_answers, incorrect_reading_answers }
→ read resources_updated.assignment for the new stage and available_atSubmit each item as it is answered, not at the end. A review session can run to a hundred items; losing an hour of work to a dropped connection is unforgivable. This is the opposite of the lesson rule, and for the opposite reason.
srs_stage lives on the assignment, not the subject — keep it to hand if you want to order a session by stage.
Grading an answer
Bring together three sources, or you will mark correct answers wrong:
subject.data.meaningswhereaccepted_answeris truesubject.data.auxiliary_meaningswheretypeiswhitelist— also correct.type: "blacklist"is explicitly wrongstudy_material.meaning_synonyms— the learner’s own, fully correct
And a fourth rule that is not a list
A reading with accepted_answer: false is not wrong. It is a real reading of the character that is not being quizzed here. Nudge and let them retry without a penalty. Grading it as incorrect punishes someone for knowing more, which is the fastest way to make a learner distrust the app.
If you offer typo tolerance on meanings, keep readings exact. A wrong kana is a wrong reading, and accepting near-misses there teaches the wrong thing.
Offline queueing
Reviews are the only thing worth queueing, and POST /reviews takes created_at precisely so a queued answer can be submitted later with the time it actually happened.
It is not idempotent — two posts are two stage movements. Give each queued answer a local id, submit in order, and clear an entry only after a 2xx. On a 422 saying the assignment is not started, submit the PUT /assignments/{id}/start you queued first.
Polling
Use GET /summary — it is the cheapest call in the API and answers “is there anything to do?” directly. Do not poll /dashboard; it is built for a screen, not a heartbeat. On a phone, prefer a push token over polling at all.
Unlocking ahead of schedule
Two calls, and the client implements none of the rule. Which subjects qualify, when each would have unlocked on its own, and the order they come in are all decided on the server and answered from one definition — a preview computed by a second, similar query is a preview that eventually lies.
GET /unlockable → the tranche, already sorted soonest-first
POST /advance { subject_ids: [...] } → unlock a subset
POST /advance → unlock all of itRender candidates in the order given. It is sorted by unlocks_at, with the nulls — nothing left to wait for — ahead of everything, then by level and id so the order is stable between calls. Regrouping by subject type discards the only thing the ordering was telling you.
All you have to build is presentation: the tick state, a Select All, a primary meaning picked from meanings, and a relative-time label from unlocks_at. Round that label hard — it assumes every review is answered correctly the moment it comes due, so the real date is always later.
Four things that will bite
Send the ids, and omit subject_ids only when you mean “whatever the tranche holds by the time this arrives” — the two are different requests, not two spellings of one. Omitting picks up anything that qualified after you drew your list, which is right for a bare “open the next tranche” button and wrong for a selection, since it unlocks items the person never saw.
Do not reach for it as a shortcut for “everything is ticked”. The obvious test, chosen.size === candidates.length, is equally true of an empty list, so the shortcut fires when nothing is selected at all — asking the server to unlock the whole tranche at the exact moment the person chose none of it. The web app hit this, and was saved only by a disabled button guarding it from a different direction.
Freeze “now” when the list loads. Compute the labels against that one timestamp, or they drift while the dialog sits open.
Handle reason, not just the count. A 200 with outcome: "nothing" is the normal way this fails — most often lessons_pending, which means the user has untaken lessons and is the bottleneck themselves. Branch on outcome and reason; both are stable.
characters is null for the radicals that have no glyph — fall back to slug, which is what the web app renders in the chip. And the whole thing is a snapshot: ids that have gone stale by the time you post are ignored rather than rejected, so you never need to re-fetch before confirming.
Gotchas
| Trap | What to do |
|---|---|
url in a resource envelope is not necessarily the host you called | It is built from the server's API_BASE_URL, so a response from localhost may carry https://kanjibuddy.mblrc.me/…. Never build your next request from a resource's url. Do follow pages.next_url — that one is derived from your own request URL and is correct. |
/study returns camelCase | assignmentId / subjectId, uniquely in the whole API. Request bodies to the same endpoint are snake_case. Yes, really. |
per_page is not yours to set | Passing it does nothing. 1000 for subjects and reviews, 500 elsewhere. |
A 404 may mean “not yours” | Ids from another account are indistinguishable from ids that do not exist. That is deliberate — do not build a UI that reports "deleted" on a 404. |
GET /reviews is empty | By design, not broken. The review log is not queryable; use /review_stats and /heat_map for the aggregates. |
| Rate-limit headers are advisory | There is no enforcement today, and no 429. Do not depend on either fact. |
kana_vocabulary is a fourth subject type | Vocabulary written only in kana — ホテル, おはよう. No readings, quizzed on meaning alone, like a radical. Clients that switch on three types will silently mishandle it. item_spread on the dashboard folds it into vocabulary, but nothing else does. |
Radicals can have characters: null | Fall back to the SVG at /radicals/{slug}.svg on the web app's origin. |
| Mnemonic fields contain WaniKani markup | Not HTML. Parse <radical>, <kanji>, <vocabulary>, <reading>, <ja> and style them yourself rather than injecting them. |
Prefer audio/mpeg over audio/webm | iOS will not play webm. Most vocabulary carries both. |
Calendar periods depend on user.timezone | If it is null, "today" is not what your user thinks it is. Set it on first run. |
updated_after silently ignores an unparseable value | And returns everything. Check the format if a delta sync suddenly gets large. |
See also
- Endpoint reference — every endpoint, its parameters and its bodies
- Conventions — auth, paging, ETags, errors
- SRS engine — what
POST /reviewsactually does - WaniKani API docs — the reference these bodies follow
postman/in the repo — an importable collection for poking at endpoints by hand