kanji-buddy
Endpoint reference
All thirty-six of them. Everything here is relative to https://kanjibuddy.mblrc.me/api/v1 and needs a bearer token.
The rules these all share — auth, paging, filtering, ETags, error shapes — are on Conventions and are not repeated per endpoint. Endpoints marked App-specific have no WaniKani equivalent.
| Group | Endpoints |
|---|---|
| Account | GET /user · PUT /user · GET /vacation · PUT /vacation |
| Content | GET /subjects · GET /subjects/{id} · GET /search · GET /spaced_repetition_systems · GET /voice_actors |
| Study state | GET /assignments · PUT /assignments/{id}/start · GET /review_statistics · GET /level_progressions · GET /resets |
| Notes | GET /study_materials · POST /study_materials · PUT /study_materials/{id} |
| Answering | POST /reviews |
| Screens · App-specific | GET /summary · GET /dashboard · GET /review_stats · GET /days_studied · GET /heat_map · GET /item_spread |
| Extra Study · App-specific | GET /study · POST /study |
| Pacing · App-specific | GET /lesson_allowance |
| Progression · App-specific | GET /unlockable · POST /advance |
| Burn Manager · App-specific | POST /burn_manager |
| Devices · App-specific | POST /push_tokens · DELETE /push_tokens |
Account
GET /user
The account, its level, and the client-side preferences blob.
{
"object": "user",
"url": "https://kanjibuddy.mblrc.me/api/v1/user",
"data_updated_at": "2026-08-20T09:51:12.120Z",
"data": {
"id": "49a9bb31-9915-4c4c-988e-8fc5709a71c6",
"username": "mblrc",
"level": 3,
"profile_url": null,
"started_at": "2026-07-23T06:29:04.713Z",
"current_vacation_started_at": null,
"timezone": "Asia/Manila",
"subscription": {
"type": "recurring",
"active": true,
"period_ends_at": null,
"max_level_granted": 60
},
"preferences": { "heat_map_view": "month" }
}
}idis a UUID string — the only id in the API that is not numeric.current_vacation_started_atis set while the SRS clock is paused. See GET /vacation — a client that serves lessons or reviews must check it, because the underlying rows stay due while it is set.subscriptionexists for WaniKani compatibility. This is a private server: it is always active at level 60 and there is nothing to buy. Do not gate features on it.timezoneis ours, not WaniKani’s — an IANA zone name,nulluntil set. It decides what “today” means for/review_stats,/days_studied, the study streak and the quiet hours of push notifications. A new install should set it.levelis the level you have actually reached — the highestlevelin/level_progressions, computed per request. It is not theusers.levelcolumn, which is set when an account is created and never advances. Before v1.2.1 this endpoint returned that column, so clients reading it showed Level 1 forever while/dashboardshowed the real number.
PUT /user
Update preferences, timezone, or both. Responds with the same body as GET /user. Accepts either a bare object or WaniKani’s { "user": { … } } wrapper.
curl -X PUT https://kanjibuddy.mblrc.me/api/v1/user \
-H "Authorization: Bearer $KANJIBUDDY_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"timezone": "Asia/Manila",
"preferences": { "lessons_batch_size": 8, "default_voice_gender": "female" }
}'preferences is merged, not replaced
Send only the keys you are changing; the rest are left alone. This is what lets two clients hold different opinions about different keys without one clobbering the other.
timezone is validated against the runtime’s own tz database — "GMT+8" and "Manila" are 422, "Asia/Manila" is fine.
Known preference keys
The server reads these. Anything else you store is yours to interpret — it is handed back untouched.
| Key | Type | Default |
|---|---|---|
lessons_batch_size | 3–10 | 5 |
max_daily_lessons | 0–100, where 0 means none | 100 |
lessons_interleave | boolean | false |
lessons_autoplay_audio | boolean | false |
reviews_presentation_order | shuffled · apprentice_first · lower_srs_first · lower_levels_first | shuffled |
reviews_autoplay_audio | boolean | false |
extra_study_autoplay_audio | boolean | false |
reviews_srs_indicator | boolean | true |
default_voice_gender | masculine_only · prefer_masculine · feminine_only · prefer_feminine · random | random |
correct_percentage_period | a period, never all_time | past_7_days |
reviews_completed_period | a period | past_7_days |
item_spread_view | table · graph | table |
recent_mistakes_mode | list · flashcard | list |
hidden_widgets | array of widget keys | [] |
heat_map_view | month · year | month |
Periods are today, past_7_days, this_month, past_28_days, this_year, past_365_days, all_time. Widget keys, in the order the dashboard shows them: forecast, level_progress, item_spread, extra_study, recent_mistakes, critical_items, recently_unlocked, recently_burned, study_streak, correct_percentage, reviews_completed.
Three of these changed shape
lessons_batch_size had a floor of 1 and now has WaniKani’s floor of 3 — a batch of one is not a batch, it is a quiz after every item. Stored values of 1 or 2 are raised to 3 on read.
default_voice_gender was any · male · female. The five values split “only this voice, or silence” from “prefer this voice, or whatever is there”, and add random. Old values are migrated on read rather than dropped: male → prefer_masculine, female → prefer_feminine, any → random.
max_daily_lessons is new. It caps what the app offers automatically; 0 means no new lessons at all while reviews carry on. It does not shrink /assignments or /summary — see GET /lesson_allowance.
hidden_widgets stores what is hidden, not what is shown
A widget added in a later release is therefore visible by default to accounts that saved their preferences before it existed — which is the opposite of what a list of inclusions would do. Keep it that way.
Unknown or invalid values are dropped on read rather than rejected on write, so a preference blob written by a newer client cannot break an older one.
GET /vacation · App-specific
Whether the SRS clock is paused, and for how long. days_away is whole days and is 0 on the first day.
{
"object": "report",
"url": "https://kanjibuddy.mblrc.me/api/v1/vacation",
"data_updated_at": "2026-08-28T04:10:02.881Z",
"data": {
"started_at": "2026-08-16T09:12:44.000Z",
"days_away": 12
}
}PUT /vacation · App-specific
Turn it on or off. The body is { "active": true } or { "active": false } — anything else is a 422. Responds with the same body as the GET.
curl -X PUT https://kanjibuddy.mblrc.me/api/v1/vacation \
-H "Authorization: Bearer $KANJIBUDDY_TOKEN" \
-H 'Content-Type: application/json' \
-d '{ "active": true }'Turning it off rewrites the whole schedule
Nothing is frozen while a vacation runs. Every available_at keeps its original value and the wall clock keeps moving, so from the database’s point of view the entire queue quietly falls due. A client must not serve lessons or reviews while started_at is set — the rows will happily say they are available.
On active: false the server pushes every timed assignment forward by exactly the time away. An item due in four hours when you left is due in four hours when you return, whether that was a weekend or three months. Overdue items shift too, so a backlog is preserved rather than forgiven.
Both directions are idempotent. Turning it on while already away does not restart the clock, which would silently discard the time banked.
Content
GET /subjects
Radicals, kanji, vocabulary. ~9,400 rows, shared by every user, effectively static since 2012. per_page is 1000 here.
| Filter | Example |
|---|---|
ids | ?ids=440,441 |
types | ?types=kanji,radical |
slugs | ?slugs=one,two |
levels | ?levels=1,2 |
hidden | ?hidden=false |
updated_after | ?updated_after=2026-07-01T00:00:00Z |
The four subject types are radical, kanji, vocabulary and kana_vocabulary.
{
"id": 440,
"object": "kanji",
"url": "https://kanjibuddy.mblrc.me/api/v1/subjects/440",
"data_updated_at": "2026-07-07T05:30:10.285Z",
"data": {
"slug": "一",
"level": 1,
"characters": "一",
"meanings": [
{ "meaning": "One", "primary": true, "accepted_answer": true }
],
"readings": [
{ "type": "onyomi", "primary": true, "reading": "いち", "accepted_answer": true },
{ "type": "onyomi", "primary": false, "reading": "いつ", "accepted_answer": true },
{ "type": "kunyomi", "primary": false, "reading": "ひと", "accepted_answer": false },
{ "type": "nanori", "primary": false, "reading": "かず", "accepted_answer": false }
],
"auxiliary_meanings": [ { "type": "whitelist", "meaning": "1" } ],
"component_subject_ids": [1],
"amalgamation_subject_ids": [2467, 2468, 2477],
"visually_similar_subject_ids": [],
"meaning_mnemonic": "Lying on the <radical>ground</radical> is …",
"meaning_hint": "To remember the meaning of <kanji>One</kanji> …",
"reading_mnemonic": "As you're sitting there next to <kanji>One</kanji> …",
"reading_hint": "Make sure you feel the ridiculously <reading>itchy</reading> …",
"document_url": "https://www.wanikani.com/kanji/%E4%B8%80",
"lesson_position": 25,
"spaced_repetition_system_id": 2,
"hidden_at": null,
"created_at": "2012-02-27T19:55:19.000000Z"
}
}The three fields that decide grading
Getting these wrong is the most common way to build a quiz that lies to the learner.
| Field | What it means |
|---|---|
accepted_answer: true | Count it correct. |
accepted_answer: false | A real reading of this character that is not what is being asked here — a kun'yomi when the on'yomi is quizzed, a nanori. Do NOT mark it wrong. Nudge ("that's a valid reading, but not the one we want") and let the learner retry without a penalty. |
primary: true | The one to show as THE answer. Exactly one meaning and one reading is primary. It has nothing to do with grading: a non-primary accepted_answer is fully correct. |
auxiliary_meanings carry type: "whitelist" (also accept it) and type: "blacklist" (explicitly wrong, usually a confusable near-synonym — mark it incorrect rather than nudging). Radicals and kana_vocabulary have no readings and are quizzed on meaning only.
Two fields WaniKani does not have
Both are scraped from subject pages, and both are omitted entirely when there is nothing to show — so data.patterns_of_use?.length is the whole test for whether to render the section, and a stock WaniKani client sees an unchanged payload.
| Key | On | Shape |
|---|---|---|
mnemonic_image_url | radicals | string |
patterns_of_use | vocabulary, kana_vocabulary | [{ pattern, collocations: [{ en, ja }] }] |
patterns_of_use is shaped for the two-pane layout WaniKani renders it in — the array is the left-hand list, in the page’s own order, and each entry’s collocations are the right-hand panel:
patterns_of_use.map((p) => p.pattern) // left pane
patterns_of_use[selected].collocations // right paneEach collocation is { en, ja } — deliberately the same shape as context_sentences, so one component renders both.
Vocabulary-only fields
| Key | Shape |
|---|---|
parts_of_speech | ["numeral"] |
context_sentences | [{ en, ja }] |
pronunciation_audios | See below. |
{
"url": "https://files.wanikani.com/r8jqaotmr178the7vwce82usz0ur",
"content_type": "audio/webm",
"metadata": {
"gender": "male",
"source_id": 2711,
"pronunciation": "いち",
"voice_actor_id": 2,
"voice_actor_name": "Kenichi",
"voice_description": "Tokyo accent"
}
}Prefer audio/mpeg over audio/webm
Safari and iOS do not play webm audio, and most vocabulary carries both. Filter by metadata.gender for a voice preference, but fall back to any available file rather than playing nothing — not every word is recorded in both voices.
The files stream straight from WaniKani’s CDN, which honours range requests and does not check Referer. It sends no CORS header, so on the web these must be played as a plain new Audio(url); fetching the bytes is blocked. Native mobile players are unaffected.
Two rendering traps
Radicals can have characters: null — they exist only as an image. Fall back to slug and fetch the SVG from /radicals/{slug}.svg on the web app’s origin.
Mnemonic fields contain WaniKani’s markup, not HTML. meaning_mnemonic, reading_mnemonic, meaning_hint and reading_hint carry inline <radical>, <kanji>, <vocabulary>, <reading>, <ja>, <b> and <i> tags. Parse them and style each in your own palette rather than injecting them — that colour-coding is how a learner tells a radical from a kanji mid-sentence.
GET /subjects/{id}
One subject as a resource envelope. Same data as above.
GET /search · App-specific
Subjects matching what someone typed, best first. WaniKani has no search endpoint and its /subjects filters only by id, slug, type and level — none of which help a person who remembers a word but not where it lives.
| Parameter | Notes |
|---|---|
q | Required. Trimmed, capped at 64 characters. One character searches only for scripts that carry meaning in one — a lone Latin letter returns nothing rather than thousands of rows |
limit | Default 12, capped at 30 |
{
"object": "report",
"url": "https://kanjibuddy.mblrc.me/api/v1/search",
"data_updated_at": null,
"total_count": 3,
"data": [
{
"id": 2604,
"object": "vocabulary",
"level": 3,
"characters": "お父さん",
"slug": "お父さん",
"meanings": ["Father", "Dad"],
"reading": "おとうさん"
}
]
}Four fields are matched, because there are four things a person remembers about an item: the characters, any meaning (not only the primary one), any reading, and the slug. Withdrawn subjects (hidden_at) never appear — they are unreachable everywhere else and would be a dead link from here.
Romaji counts as a reading
?q=otousan finds お父さん. The query is converted to kana with the same function that converts a typed reading answer, then searched alongside the raw text, so the two routes to a word agree. A client need do nothing for this — just send what was typed.
Ordering is exact match, then starts-with, then contains, and within each, the lower level first — you are likelier to want a word you have met. Render in the order given.
data_updated_at is deliberately null: the answer depends on the query string rather than on when content changed, and a timestamp here would let one search be served from another’s cache.
characters can be null
Nineteen radicals have no Unicode character and exist only as SVGs. Fall back to the slug for text, or draw /radicals/<slug>.svg as a mask filled with the current colour — loading it through an <img> renders it black, because the file is coloured by a CSS variable it cannot see from inside an isolated document.
GET /spaced_repetition_systems
The interval tables. Two systems: one for dictionary subjects, one for everything you study. Filters: ids, updated_after.
{
"id": 1,
"object": "spaced_repetition_system",
"data": {
"name": "Default system for dictionary subjects",
"stages": [
{ "position": 0, "interval": null, "interval_unit": "seconds" },
{ "position": 1, "interval": 14400, "interval_unit": "seconds" },
{ "position": 2, "interval": 28800, "interval_unit": "seconds" }
]
}
}Each subject names its system in data.spaced_repetition_system_id. A client never needs this to schedule anything — the server computes available_at on every review. Fetch it only to display an interval (“next review in 4 hours”).
GET /voice_actors
WaniKani’s pronunciation voices. Filters: ids, updated_after. Empty unless the voice actor data has been imported; the audio URLs themselves live on the vocabulary subject, in data.pronunciation_audios.
Study state
GET /assignments
The join between you and a subject: what you have unlocked, its SRS stage, and when it is next due. This is the table a study app actually runs on.
| Filter | Notes |
|---|---|
ids · subject_ids | |
subject_types | radical, kanji, vocabulary, kana_vocabulary |
srs_stages | ?srs_stages=1,2,3,4 |
levels | The SUBJECT's level, resolved through a subquery |
unlocked · started · passed · burned · hidden | Booleans |
resurrected | Boolean, app-specific. True = brought back from burned at least once. resurrected=true&burned=false is the set POST /burn_manager can retire |
immediately_available_for_review | Valueless flag — write it with no = |
immediately_available_for_lessons | Valueless flag |
in_review | Valueless flag |
updated_after |
{
"id": 76,
"object": "assignment",
"url": "https://kanjibuddy.mblrc.me/api/v1/assignments/76",
"data_updated_at": "2026-08-15T15:04:29.592Z",
"data": {
"created_at": "2026-08-13T11:51:02.790Z",
"subject_id": 440,
"subject_type": "kanji",
"srs_stage": 2,
"unlocked_at": "2026-08-11T11:51:02.790Z",
"started_at": "2026-08-14T15:53:54.404Z",
"passed_at": null,
"burned_at": null,
"available_at": "2026-08-15T14:53:54.404Z",
"resurrected_at": null,
"hidden": false
}
}Reading the lifecycle
The four timestamps are a state machine, and the combination tells you where an item is:
| State | Looks like |
|---|---|
| Locked | No row at all, or unlocked_at: null |
| Lesson | unlocked_at set, started_at null — waiting to be taught |
| Review | started_at set, available_at in the past |
| Waiting | started_at set, available_at in the future |
| Passed | passed_at set — reached Guru (stage 5) at least once |
| Burned | burned_at set — finished, never comes back |
srs_stage is 0 before a lesson is started, 1–8 in the cycle, 9 when burned. The dashboard groups them: Apprentice 1–4, Guru 5–6, Master 7, Enlightened 8, Burned 9. passed_at is sticky — a later wrong answer drops the stage but does not clear it. GET /assignments/{id} returns one.
PUT /assignments/{id}/start
Turn a lesson into a review item: stage 0 → the system’s starting stage, and available_at gets its first schedule. Call this once per item when a lesson batch is taught, before the first POST /reviews for it. Responds with the updated assignment.
The body is optional — all three of these work, and started_at defaults to now:
# nothing at all
curl -X PUT https://kanjibuddy.mblrc.me/api/v1/assignments/76/start \
-H "Authorization: Bearer $KANJIBUDDY_TOKEN"
# bare object
-d '{ "started_at": "2026-08-14T15:53:54.404Z" }'
# WaniKani's wrapper
-d '{ "assignment": { "started_at": "2026-08-14T15:53:54.404Z" } }'| Error | When |
|---|---|
404 | No such assignment, or not yours |
422 | "Assignment has already been started." |
Treat that 422 as success
It is what a retry after a flaky connection looks like. The item is started, which is what you wanted — do not surface it as an error.
GET /review_statistics
Per-subject accuracy, one row per subject you have reviewed.
Filters: ids, subject_ids, subject_types, hidden, updated_after, plus percentages_greater_than and percentages_less_than — both of which are inclusive despite their names, so ?percentages_less_than=75 returns rows at exactly 75 too.
{
"id": 19,
"object": "review_statistic",
"data": {
"created_at": "2026-08-13T11:52:47.473Z",
"subject_id": 440,
"subject_type": "kanji",
"meaning_correct": 2,
"meaning_incorrect": 0,
"meaning_max_streak": 2,
"meaning_current_streak": 2,
"reading_correct": 2,
"reading_incorrect": 0,
"reading_max_streak": 2,
"reading_current_streak": 2,
"percentage_correct": 100,
"hidden": false
}
}percentage_correct is over meaning and reading together, and is 100 on a row with no answers yet. Below 75 is what KanjiBuddy calls “critical” — the leeches. Radicals and kana_vocabulary never accrue reading counts. Rows are created by the first POST /reviews for a subject; there is no endpoint to create or edit one.
GET /level_progressions
When you reached each level. Filters: ids, updated_after.
{
"id": 1,
"object": "level_progression",
"data": {
"created_at": "2026-07-23T06:29:05.535Z",
"level": 1,
"unlocked_at": "2026-07-23T06:29:04.713Z",
"started_at": "2026-07-23T06:29:04.713Z",
"passed_at": null,
"completed_at": null,
"abandoned_at": null
}
}Your current level is the highest level here, which is what the server itself uses — the level on /user is a fallback for an account with no progressions yet.
GET /resets
Level resets. Filters: ids, updated_after. Empty unless you have reset, and nothing in the API creates one.
Notes
GET /study_materials
Your notes and personal synonyms. Filters: ids, subject_ids, subject_types, hidden, updated_after.
{
"id": 2,
"object": "study_material",
"data": {
"created_at": "2026-08-14T14:59:09.552Z",
"subject_id": 441,
"subject_type": "kanji",
"meaning_note": null,
"reading_note": "Ni",
"meaning_synonyms": [],
"hidden": false
}
}meaning_synonyms are graded as fully correct
A quiz client must load them alongside the subject and accept them the same way it accepts accepted_answer meanings, or a learner’s own synonym will be marked wrong.
POST /study_materials
Create the note for a subject — one per subject per user. Responds 201 with the created resource. Accepts the { "study_material": { … } } wrapper too.
curl -X POST https://kanjibuddy.mblrc.me/api/v1/study_materials \
-H "Authorization: Bearer $KANJIBUDDY_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"subject_id": 441,
"meaning_note": "Two lines, two things.",
"reading_note": "Ni",
"meaning_synonyms": ["a pair"]
}'| Error | When |
|---|---|
422 | "Subject not found." |
422 | "A study material already exists for this subject." → use PUT instead |
subject_type is filled in from the subject; do not send it.
PUT /study_materials/{id}
Update meaning_note, reading_note, meaning_synonyms. Omitted fields are left alone; null clears a note; [] clears the synonyms.
Note the id is the study material’s, not the subject’s — find it with GET /study_materials?subject_ids=441.
Answering
POST /reviews
The only endpoint that moves the SRS. Submit one answered item; the server runs the engine, updates the assignment, updates the statistics, writes the review log, and handles unlocks and level-ups in a single transaction.
curl -X POST https://kanjibuddy.mblrc.me/api/v1/reviews \
-H "Authorization: Bearer $KANJIBUDDY_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"assignment_id": 76,
"incorrect_meaning_answers": 0,
"incorrect_reading_answers": 1
}'| Field | Required | Notes |
|---|---|---|
assignment_id | One of the two | |
subject_id | One of the two | Resolved to your assignment for that subject |
incorrect_meaning_answers | Yes | Integer ≥ 0 |
incorrect_reading_answers | Yes | Integer ≥ 0 |
created_at | No | ISO-8601; defaults to now. Use it when flushing a queue recorded offline |
Send counts, not a verdict
The number is how many times the learner got it wrong before finally getting it right — that is what drives the penalty. Zero and zero is a clean answer. For radicals and kana_vocabulary, incorrect_reading_answers is ignored: there is no reading to quiz.
Response — 201
The created review, plus everything the write changed, so a client needs no follow-up reads:
{
"id": 412,
"object": "review",
"data": {
"created_at": "2026-08-15T15:04:29.592Z",
"assignment_id": 76,
"subject_id": 440,
"starting_srs_stage": 2,
"ending_srs_stage": 1,
"incorrect_meaning_answers": 0,
"incorrect_reading_answers": 1,
"spaced_repetition_system_id": 2
},
"resources_updated": {
"assignment": { "…": "the updated assignment resource" },
"review_statistic": { "…": "the updated review_statistic resource" }
}
}starting_srs_stage → ending_srs_stage is what to animate. resources_updated.assignment carries the new available_at.
The engine
- Correct → stage +1.
- Wrong →
stage − ceil(incorrect / 2) × (stage ≥ 5 ? 2 : 1), floored at the item’s starting stage. A wrong answer at Guru or above costs double. available_atis the new stage’s interval from now, floored to the top of the hour.- Reaching stage 5 sets
passed_at, which unlocks dependents — radicals unlock their kanji, kanji unlock their vocabulary — and, for kanji, may trigger a level-up once 90% of the level’s kanji are passed.
All of that happens server-side inside the transaction. A client submits an answer and reads back the result; it should never compute a stage itself. See SRS engine.
| Error | When |
|---|---|
404 | No such assignment, or not yours |
422 | "Assignment is not started and cannot be reviewed yet." — call /start first |
422 | Missing both assignment_id and subject_id |
Submitting is not idempotent
Two POSTs for one answered item are two reviews and two stage movements. Give each queued answer a local id, and clear it only after a 2xx.
GET /reviews returns nothing. Deprecated in WaniKani, and here it is an empty collection with total_count: 0 so a compatible client does not 404. The review log is not queryable — the aggregates it would be used for are served by /review_stats and /heat_map.
Screens
GET /summary
Lessons available now, and reviews bucketed into the next 24 hourly slots. WaniKani’s endpoint, unchanged.
{
"object": "report",
"data": {
"lessons": [
{ "available_at": "2026-08-21T18:00:00.000Z", "subject_ids": [447, 448, 449] }
],
"reviews": [
{ "available_at": "2026-08-21T18:00:00.000Z", "subject_ids": [471, 465, 440] },
{ "available_at": "2026-08-21T19:00:00.000Z", "subject_ids": [] }
],
"next_reviews_at": "2026-08-22T16:00:00.000Z"
}
}reviews is always 24 entries, one per hour starting at the current hour, and most are usually empty. The first entry holds everything already due — including items that came due days ago — so data.reviews[0].subject_ids.length is “reviews waiting right now”. next_reviews_at is when the next non-empty batch arrives, or null if nothing is scheduled.
This is the right endpoint to poll
It is the cheapest “is there anything to do?” call in the API and the one to hit on app resume. /dashboard is not.
GET /dashboard · App-specific
Everything the home screen needs in one request, instead of the ~15 round trips the resource endpoints would take to produce the same counts. The aggregation happens in Postgres and only the numbers come back.
{
"object": "report",
"data": {
"username": "mblrc",
"level": 3,
"lessons": { "available": 13, "subject_ids": [447, 448] },
"lesson_allowance": { "max": 15, "started_today": 3, "remaining": 12 },
"vacation": { "started_at": null, "days_away": 0 },
"reviews": { "available": 30, "subject_ids": [471, 465] },
"forecast": [
{ "available_at": "2026-08-22T16:00:00.000Z", "count": 1, "cumulative": 31 }
],
"level_progress": {
"level": 1,
"radicals": { "passed": 10, "total": 25 },
"kanji": { "passed": 0, "total": 18 },
"vocabulary": { "passed": 0, "total": 37 },
"kanji_to_level_up": 17
},
"item_spread": {
"apprentice": { "radical": 15, "kanji": 9, "vocabulary": 0, "total": 24 },
"guru": { "radical": 10, "kanji": 0, "vocabulary": 0, "total": 10 },
"master": { "radical": 0, "kanji": 0, "vocabulary": 0, "total": 0 },
"enlightened": { "radical": 0, "kanji": 0, "vocabulary": 0, "total": 0 },
"burned": { "radical": 0, "kanji": 0, "vocabulary": 0, "total": 0 }
},
"recent_mistakes": { "count": 0, "subject_ids": [] },
"extra_study": {
"recent_lessons": { "remaining": 24, "total": 24 },
"recent_mistakes": { "remaining": 0, "total": 0 },
"burned_items": { "remaining": 0, "total": 0 }
},
"days_studied": {
"days": 6,
"first_studied_on": "2026-08-12",
"last_studied_on": "2026-08-21"
},
"study_streak": {
"current": 1,
"best": 5,
"studied_today": true,
"offerings": 0,
"today": "2026-08-21",
"recent_days": [false, true, "…14 booleans, oldest first, ending today"]
},
"correct_percentage": {
"period": "past_7_days",
"current": { "reviews": 38, "correct": 36, "percentage": 95 },
"previous": { "reviews": 36, "correct": 36, "percentage": 100 }
},
"reviews_completed": { "…": "same shape, possibly a different period" },
"item_spread_view": "table",
"recent_mistakes_mode": "list",
"hidden_widgets": [],
"recently_unlocked": [
{
"subject": {
"id": 471,
"object": "kanji",
"data": {
"slug": "六", "level": 2, "characters": "六",
"meanings": [{ "meaning": "Six", "primary": true, "accepted_answer": true }]
}
},
"at": "2026-08-15T15:04:22.288Z"
}
],
"recently_burned": [],
"critical_items": [
{ "subject": { "id": 14, "object": "radical", "data": { "…": "as above" } },
"percentage_correct": 50 }
]
}
}lessons.availableis already trimmed tolesson_allowance.remaining— it is what the app will actually offer, not what is unlocked. The unlocked count is/summary.- On vacation,
lessonsandreviewsboth read zero,subject_idsare empty andforecastis[]— the clock is paused, so the next seven days mean nothing.vacation.started_atis what tells the two zeroes apart from having finished everything. item_spreadfoldskana_vocabularyintovocabulary— three columns, not four. If you need the four-way split, count/assignmentsyourself.forecastis the next 7 days.countis what arrives that day;cumulativeis the backlog you would face if you did nothing until then. It starts from reviews already waiting, so the firstcumulativeis usually larger than the firstcount.kanji_to_level_upis how many more of this level’s kanji must reach Guru — 90% of them, rounded up, is the threshold.recently_unlocked,recently_burnedandcritical_itemscarry at most 12 items each, and the subjects are trimmed toid,object,slug,level,charactersandmeanings— enough to draw a chip. They are not full subject envelopes. Fetch/subjects?ids=…if you need the rest.recent_mistakesis the last 24 hours, worst first.item_spread_view,recent_mistakes_modeandhidden_widgetsare the user’s preferences, echoed so the screen needs no second request to know how to render itself.study_streak.offeringsis how many missed days are still forgivable this week — the streak survives up to two, and the third breaks it.recent_daysis 14 booleans, oldest first, ending today.
Not cacheable. It ships an ETag, but the value is derived from “now” and will not match. Do not poll it in the background — use /summary for that, and fetch the dashboard when the screen is actually shown.
GET /review_stats · App-specific
Reviews completed in a window, how many were right, and the same figures for the window immediately before, to compare against. period defaults to past_7_days.
{
"object": "report",
"data": {
"period": "past_7_days",
"current": { "reviews": 38, "correct": 36, "percentage": 95 },
"previous": { "reviews": 36, "correct": 36, "percentage": 100 }
}
}percentage is null, not 0, when there were no reviews — “nothing to report” and “you got nothing right” are different claims. correct counts items answered right first time.
The two window kinds behave differently, on purpose:
| Kind | Periods | Means |
|---|---|---|
| Rolling | past_7_days · past_28_days · past_365_days | N days back from now. |
| Calendar | today · this_month · this_year | From local midnight, the 1st, or January 1st in the user's timezone. |
On the 3rd of a month, a review from a week ago is inside past_28_days and outside this_month. all_time has no previous window: previous comes back as zeros with a null percentage.
The dashboard already carries both cards’ numbers for the periods chosen in settings. This endpoint is for asking about a period the user has not chosen, without writing a preference.
GET /days_studied · App-specific
How many distinct days had at least one lesson or review, counted in the user’s timezone. All three fields are null/0 for an account that has never studied.
{
"object": "report",
"data": {
"days": 6,
"first_studied_on": "2026-08-12",
"last_studied_on": "2026-08-21"
}
}The one genuinely cacheable report: data_updated_at is the last day you studied, so an If-None-Match stays 304 for the rest of the day. The same numbers ride along in /dashboard, so fetch this only when you want the figure on its own.
GET /heat_map · App-specific
A day-by-day count of reviews and lessons, for the contribution-graph grid.
| Parameter | Values |
|---|---|
view | month (default) or year |
offset | 0–120. Whole periods back from the current one; 0 is now, 1 is last month/year |
{
"object": "report",
"data": {
"view": "month",
"offset": 0,
"from": "2026-08-01",
"to": "2026-08-31",
"label": "August 2026",
"days": [
{ "day": "2026-08-01", "reviews": 0, "lessons": 0 },
{ "day": "2026-08-12", "reviews": 21, "lessons": 24 }
]
}
}days covers every day in the range including empty ones, so it maps straight onto a grid with no gap filling. label is pre-formatted for the header. An out-of-range offset is a 422 rather than an empty grid.
GET /item_spread · App-specific
The dashboard’s five-by-three grid of counts, with the items in the cells: every started item grouped by SRS stage and then by subject type. This is what the Item Spread screen draws.
{
"object": "report",
"url": "https://kanjibuddy.mblrc.me/api/v1/item_spread",
"data_updated_at": "2026-08-28T04:10:02.881Z",
"data": {
"total": 61,
"buckets": [
{
"key": "apprentice",
"label": "Apprentice",
"total": 33,
"types": [
{
"key": "radical",
"label": "Radicals",
"total": 14,
"items": [
{ "id": 1, "object": "radical",
"data": { "slug": "ground", "level": 1, "characters": "一",
"meanings": [ { "meaning": "Ground", "primary": true } ] } }
]
},
{ "key": "kanji", "label": "Kanji", "total": 11, "items": [ … ] },
{ "key": "vocabulary", "label": "Vocabulary", "total": 8, "items": [ … ] }
]
},
{ "key": "guru", "label": "Guru", "total": 18, "types": [ … ] },
{ "key": "master", "label": "Master", "total": 7, "types": [ … ] },
{ "key": "enlightened", "label": "Enlightened", "total": 0, "types": [ … ] },
{ "key": "burned", "label": "Burned", "total": 3, "types": [ … ] }
]
}
}- All five buckets are always present in SRS order, and each carries all three type groups, empty ones included. A client can draw a row for every stage without checking whether it exists.
- Items are trimmed to what a chip needs —
slug,level,characters,meanings. No mnemonics, no context sentences. - Ordered by level, then subject id, within each group.
kana_vocabularyis folded into thevocabularygroup, but each item keeps its realobject.- Stage 0 — unlocked but never studied, i.e. your pending lessons — is excluded, as are hidden assignments. The totals therefore agree exactly with
item_spreadon /dashboard; a test pins that.
Pacing
GET /lesson_allowance · App-specific
How many lessons today’s ceiling still permits. Exists so a client can honour max_daily_lessons without running the whole dashboard aggregation to learn one number.
{
"object": "report",
"url": "https://kanjibuddy.mblrc.me/api/v1/lesson_allowance",
"data_updated_at": "2026-08-28T04:10:02.881Z",
"data": {
"max": 15,
"started_today": 3,
"remaining": 12
}
}- A lesson is an assignment gaining a
started_at, and “today” is a calendar day in the account’stimezone— not a rolling 24 hours. started_todaycounts every lesson started, including ones begun through the picker, which ignores the cap. Soremainingcan be0whilestarted_todayexceedsmax.remainingis always a number. There is no “unlimited”: the setting’s ceiling of 100 is the least restrictive it gets.
Pacing is advisory, and the resource endpoints stay truthful
Neither max_daily_lessons nor vacation shrinks /assignments or /summary. Those are WaniKani’s endpoints and mean “what is actually unlocked”; a client asking what is available gets the true answer. Applying the cap is the client’s job — the web app does it in its own fetch layer, not in the API.
Extra Study
Nothing in Extra Study can move an SRS stage
It writes to extra_study_cycles and nothing else; assignments and reviews are read to decide what is eligible and never written. That is the whole point of the feature, and it is enforced by the code path rather than by convention.
GET /study · App-specific
What one mode currently holds — a pure read that starts nothing. kind is required and is one of recent_lessons, recent_mistakes, burned_items.
{
"object": "report",
"data": {
"kind": "recent_lessons",
"capped": false,
"total_count": 24,
"progress": { "remaining": 24, "total": 24 },
"items": [
{ "assignmentId": 77, "subjectId": 441 },
{ "assignmentId": 80, "subjectId": 444 }
]
}
}items is camelCase — the one place in the API that is
assignmentId / subjectId, not assignment_id / subject_id. It is a wart, kept because clients depend on it. Do not assume it will spread, and do not assume it will change.
items carries ids only — no subjects. Fetch them with /subjects?ids=…. capped: true means the 100-item ceiling trimmed the set, so a client can say so rather than quietly showing a hundred of many more.
POST /study · App-specific
Two jobs at one address. Sending only kind opens or resumes a cycle; adding assignment_id records one item finished within it.
# open or resume — responds with the same shape as GET /study
-d '{ "kind": "recent_lessons" }'
# record one item done
-d '{ "kind": "recent_lessons", "assignment_id": 77 }'
{ "object": "report",
"data": { "kind": "recent_lessons", "progress": { "remaining": 23, "total": 24 } } }Resuming is the default — a cycle already in progress is returned as it stands, and only a finished one recycles. Recording is idempotent per item: the same assignment_id twice does not double-count. Note the request body is snake_case even though the response’s items are camelCase.
| Kind | Contents | Counts down? |
|---|---|---|
recent_lessons | Items taught recently | Yes |
recent_mistakes | Answered wrong in a review in the last 24 hours | No |
burned_items | Items at stage 9 | Yes |
recent_mistakes does not count down because an item leaves that list by being answered correctly in a real review, not by being practised. Its number is simply how many mistakes you made in the last 24 hours.
Progression
GET /unlockable · App-specific
What POST /advance would open, and when each item would have opened on its own. Read-only, and the preview for the “unlock ahead of schedule” confirmation.
{
"object": "report",
"data": {
"total": 2,
"pending": 0,
"blocked": null,
"candidates": [
{
"subject_id": 440,
"object": "kanji",
"level": 1,
"characters": "一",
"slug": "一",
"meanings": [{ "meaning": "One", "primary": true }],
"unlocks_at": null
},
{
"subject_id": 449,
"object": "kanji",
"level": 1,
"characters": "大",
"slug": "大",
"meanings": [{ "meaning": "Big", "primary": true }],
"unlocks_at": "2026-09-02T04:00:00.000Z"
}
]
}
}| Field | Meaning |
|---|---|
subject_id | Pass these to POST /advance to unlock a subset |
object | radical · kanji · vocabulary · kana_vocabulary |
characters | Null for the radicals that have no glyph — fall back to slug, as the web app does |
unlocks_at | When this would unlock without advancing. Null means nothing is left to wait for |
Ordered soonest-first, with the unlocks_at: null items ahead of everything, then by level and id so the order is stable between calls. Render it in the order given: that ordering is the question the list answers, and regrouping by subject type throws it away.
unlocks_at is for sorting, not for promising
It walks each component’s remaining SRS intervals to the passing stage and takes the maximum across components — a subject unlocks when its slowest component reaches Guru, so the fast ones say nothing about when it arrives. Each component is projected through its own SRS system, since levels 1–2 run the accelerated one at roughly half the intervals.
It assumes every review is answered correctly the moment it comes due, which nobody does, so the real date is always later. Round it hard in the UI — the web app says “in ~3 days” and, under an hour, “any moment now”. A precise-looking “in 4 hours 12 minutes” would be false precision about a number that exists to order a list.
The tranche is a snapshot. It is computed by the same findUnlockable that POST /advance acts on — one definition, asked two ways, so the list cannot promise something the button then declines to do — but it is recomputed on the POST, and a review finishing in between can change it.
blocked and pending
Both are app-specific and additive; candidates and total mean what they always meant. When blocked is set, candidates is empty and the POST would refuse for the same reason.
| blocked | Meaning |
|---|---|
null | Nothing in the way — the list below is real |
"lessons_pending" | You still have untaken lessons. pending is how many. Advancing opens what comes after those, so take them first |
"nothing_to_unlock" | Nothing qualifies at this level yet |
Say no before the choosing, not after
This used to list candidates regardless while the POST refused outright on untaken lessons — so a dialog offered thirty-six items, took a selection, and only then said no. If you build the same confirmation, read blocked first and show its sentence instead of the list.
POST /advance · App-specific
Open the next tranche of lessons without waiting for the current ones to reach Guru — the “unlock ahead of schedule” button. The body is optional: send none and it opens the whole tranche, which is what it did before it could be narrowed.
-d '{ "subject_ids": [440, 449] }'| Field | Required | Notes |
|---|---|---|
subject_ids | No | Unlock only these, out of what the tranche offers. Omit for all of it. |
Ids that are not in the tranche are ignored rather than rejected. The list from GET /unlockable is a snapshot, and between drawing it and pressing the button an item can have been unlocked by a review finishing — failing the whole request over a stale id would turn a race into an error.
Omitting it is not the same request as listing every id. Omitting means “whatever the tranche holds when this arrives”, so it picks up anything that qualified in the meantime; listing means “exactly these”. The first is right for a plain “open the next tranche” button, the second for a selection someone built — which should never unlock an item they were not shown.
{
"object": "report",
"data": {
"outcome": "unlocked",
"unlocked": 12,
"level": 2,
"pending": 0,
"reason": null
}
}| Field | Meaning |
|---|---|
outcome | unlocked · levelled_up · nothing |
unlocked | New lessons this put on the pile |
level | Your level after advancing |
pending | Lessons already waiting when the request arrived |
reason | Set only when outcome is nothing: lessons_pending · nothing_to_unlock · max_level |
Branch on outcome and reason, not on the message — these are the stable machine-readable values, and each maps to a different sentence in the UI.
POST, never GET, and deliberately: this is reachable from a bookmark, and a link prefetcher must not be able to advance someone’s progression without them clicking. Do not add a GET to this route — the read lives at GET /unlockable, which is a separate route precisely so that this one can stay write-only.
Burn Manager
POST /burn_manager · App-specific
Burning is the only one-way door in the SRS: an item leaves the review cycle and never returns. This is the way back through it, and the way back out again. Neither action exists in WaniKani’s API — resurrecting is a button on its item pages and retiring is a community userscript — so this is app-specific in full.
POST /burn_manager
{ "action": "resurrect", "subject_ids": [440, 449] }
{
"object": "report",
"data": {
"action": "resurrect",
"changed": [440],
"skipped": [{ "subject_id": 449, "reason": "not_burned" }]
}
}| Field | Notes |
|---|---|
action | resurrect · retire. Required |
subject_ids | Subjects, not assignments. 1–200 per call. Required |
changed | Subject ids actually moved |
skipped | Ids left alone, each with a reason. Never silently dropped |
What each action does
| Action | Requires | Effect |
|---|---|---|
resurrect | burned_at set | srs_stage → the system's starting stage, burned_at cleared, resurrected_at set, available_at now — due immediately |
retire | resurrected_at set, burned_at null | srs_stage → the burning stage, burned_at now, available_at null, passed_at filled in if missing |
| skipped reason | Meaning |
|---|---|
not_burned | resurrect: nothing to bring back |
already_burned | retire: it has already left the cycle |
not_started | retire: never taught, so it is not in the cycle |
not_resurrected | retire: never brought back, so retiring it would be burning it early |
not_found | No assignment for that subject on this account |
Retiring undoes a resurrection — it is not a way to burn early
Only an item you resurrected can be retired. A live item you never brought back is on the schedule it has always been on, and taking it off is a different decision: one press, no undo, months of scheduling gone. Build the two lists from ?burned=true and ?resurrected=true&burned=false and the UI cannot offer what the server would refuse.
passed_at survives a resurrect, and must
It is not a record of the SRS stage; it is the fact that the item once reached Guru, and levelling up counts kanji that carry it. Clearing it would silently un-level an account. The server keeps it — a client should never try to “tidy” it.
Check the state before acting, or report what you skipped. The userscript this borrows from does neither — its author says it “does not check whether items are actually burned before resurrecting them”, so a bulk action over a filter reset live items to Apprentice with no undo. The server here refuses per item; make sure your UI shows the skipped list rather than reporting the count you asked for.
Devices
POST /push_tokens · App-specific
Register this install for push notifications, or re-arm one that was retired. Authenticated as the user, because a push token only means anything attached to an account. See Push notifications.
-d '{ "token": "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]", "platform": "ios" }'
{ "object": "push_token",
"data": {
"token": "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]",
"platform": "ios",
"registered_at": "2026-08-21T18:26:31.004Z"
} }| Field | Required | Notes |
|---|---|---|
token | Yes | Must match ExponentPushToken[…] or ExpoPushToken[…] |
platform | No | ios or android |
Expo tokens only. The format is validated on the way in so a typo fails at registration rather than silently never delivering. A bare APNs or FCM token is a 422. It upserts, so calling it on every launch is correct and cheap — that is also how a token retired after delivery failures gets re-armed.
DELETE /push_tokens · App-specific
Stop pushing to this install. Same body as POST, responds { "object": "push_token", "data": { "removed": 1 } }.
Idempotent — unregistering twice reports removed: 0 and is not an error. Scoped to your own tokens, so one account cannot unregister another’s device by guessing.
Internal endpoints
/internal/* and /unsubscribe are service-to-service, guarded by a shared secret or a signed token, and will not accept your bearer. They are listed on the overview only so you know to leave them alone.