kanji-buddy
API conventions
The rules every endpoint obeys: how you authenticate, how you page, how you sync, and what a failure looks like.
Authentication
Every endpoint requires a token. There is no signup: tokens are minted out of band and stored as sha-256 hashes, so a leaked database does not leak anybody’s token.
curl https://kanjibuddy.mblrc.me/api/v1/user \
-H "Authorization: Bearer $KANJIBUDDY_TOKEN"A browser arriving from the portal may instead present the kb_session cookie, which is set on .mblrc.me so every subdomain sees it. The header wins when both are present, so a stale cookie can never override an explicit token — which is what you want on a device that has just been handed a new one.
A missing, unknown, revoked or expired token is a 401, never a 403:
{ "error": "Unauthorized. Provide a valid API token.", "code": 401 }Store the token like a password
It is a bearer credential with no expiry unless one was set at mint time. On a phone that means the Keychain or Keystore, not AsyncStorage or SharedPreferences. See Auth & access for how they are issued and revoked.
Headers
Sent on every response:
| Header | Meaning |
|---|---|
Wanikani-Revision | Always 20170710. The WaniKani API revision these bodies match. |
Content-Type | application/json; charset=utf-8. |
Sent on the cacheable endpoints:
| Header | Meaning |
|---|---|
ETag | Strong validator for this exact response. |
Last-Modified | The resource's data_updated_at, in HTTP date format. |
RateLimit-Limit | 60. |
RateLimit-Remaining | Advisory. |
RateLimit-Reset | Unix seconds. |
The rate limit is advertised, not enforced
The headers exist so a WaniKani-compatible client that throttles itself behaves normally; there is no counter behind them today, and no 429. Do not build a client that depends on being throttled, and do not hammer the API on the grounds that nothing stops you — a real counter can appear later without warning.
Conditional requests
Send back the ETag you were given and get a 304 Not Modified with an empty body when nothing has changed. On a phone this is the difference between re-downloading 9,400 subjects and downloading nothing.
curl -i https://kanjibuddy.mblrc.me/api/v1/days_studied \
-H "Authorization: Bearer $KANJIBUDDY_TOKEN" \
-H 'If-None-Match: "2655a8f2f93aaf1359…"'
HTTP/1.1 304 Not Modified
ETag: "2655a8f2f93aaf1359…"
Wanikani-Revision: 20170710If-Modified-Since is honoured too, at second precision. If-None-Match takes precedence when both are sent, matching WaniKani.
| Supports ETag / 304 | Does not |
|---|---|
/user, /subjects, /assignments, /review_statistics, /study_materials, /level_progressions, /resets, /spaced_repetition_systems, /voice_actors, /summary, /dashboard, /days_studied | /review_stats, /heat_map, /study, /advance, /push_tokens, and all writes |
The ones without it are either writes or reports whose value is “as of now”, where a validator would change on every request anyway.
/dashboard ships an ETag that will never match
It reports what is due now, so its content changes with the clock rather than with the data. Its data_updated_at is the current time and the derived ETag therefore differs every request — which is correct: the dashboard is not cacheable, and pretending otherwise would serve stale review counts. Treat it as uncacheable and do not build a client that waits for a 304 there.
/days_studied is the opposite and the most worthwhile one to revalidate: its data_updated_at is the last day you studied, so it stays 304 for the rest of the day.
Pagination is by id, not offset
Id-cursor, ascending by id. There is no page number and no offset. Offsets shift under you when rows are inserted mid-scan, so a sync can silently skip records; an id cursor cannot. It also lets Postgres use the (user_id, id) index instead of counting past rows it then discards.
| Parameter | Meaning |
|---|---|
page_after_id | Return ids greater than this. |
page_before_id | Return ids less than this, i.e. page backwards. |
per_page is fixed and cannot be set by the client — 1000 for /subjects and /reviews, 500 everywhere else. Passing it does nothing.
Follow pages.next_url until it is null rather than constructing cursors yourself. It carries your filters forward for you:
async function all(url: string, token: string) {
const out: unknown[] = [];
let next: string | null = url;
while (next) {
const res = await fetch(next, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const page = await res.json();
out.push(...page.data);
next = page.pages.next_url;
}
return out;
}pages.previous_url is null on the first page, meaning “you passed no cursor” rather than “there is nothing behind you”.
Filtering
- Lists are comma-separated:
?ids=1,2,3,?levels=1,2,?types=kanji,radical. There is no cap on how many values you may pass; they become a single SQL= ANY(…). - Booleans are
?hidden=true/?hidden=false. Onlytrueand1count as true. - Three assignment filters are valueless flags —
?immediately_available_for_reviewwith no=, exactly as in WaniKani. - An absent filter and an empty one mean the same thing.
?ids=does not return zero rows, it returns all of them.
Incremental sync
?updated_after= on any collection returns only rows changed strictly after an ISO-8601 timestamp. Every table carries updated_at for exactly this. Paired with ETags, a client that is already up to date does almost no work:
GET /api/v1/subjects?updated_after=2026-01-01T00:00:00Z
If-None-Match: "abc123"
304 Not ModifiedAn unparseable updated_after is ignored, not rejected
It returns everything rather than a 422. So a typo here does not fail loudly — it quietly turns a delta sync back into a full one. If a sync suddenly gets large, check the format first.
Errors
Always this shape:
{ "error": "view must be month or year.", "code": 422 }| Status | When |
|---|---|
401 | No token, or an unknown / revoked / expired one. |
404 | No such id — or an id belonging to another user, which is deliberately indistinguishable. |
422 | Malformed body, a bad enum value, or a rule violated (starting an already-started assignment, a second study material for one subject). |
error is a human-readable sentence and is safe to log. It is not a stable machine code — do not branch on its text. Branch on the status, and on the endpoint-specific fields the reference documents: outcome and reason on /advance, for instance.
There is no 403 and no 429.
Timestamps
Every timestamp is ISO-8601 UTC with milliseconds — 2026-08-15T15:04:29.592Z. The suffix tells you which kind you are holding:
| Suffix | Meaning |
|---|---|
_at | An instant, UTC. null when the thing has not happened. |
_on | A calendar date in the user's timezone — 2026-08-21. Belongs to a day, not an instant. |
That distinction matters more than it looks: everything ending in _on, plus the calendar periods on /review_stats, is computed against user.timezone. If that is null, “today” is not what your user thinks it is.