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:

HeaderMeaning
Wanikani-RevisionAlways 20170710. The WaniKani API revision these bodies match.
Content-Typeapplication/json; charset=utf-8.

Sent on the cacheable endpoints:

HeaderMeaning
ETagStrong validator for this exact response.
Last-ModifiedThe resource's data_updated_at, in HTTP date format.
RateLimit-Limit60.
RateLimit-RemainingAdvisory.
RateLimit-ResetUnix 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: 20170710

If-Modified-Since is honoured too, at second precision. If-None-Match takes precedence when both are sent, matching WaniKani.

Supports ETag / 304Does 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.

ParameterMeaning
page_after_idReturn ids greater than this.
page_before_idReturn 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. Only true and 1 count as true.
  • Three assignment filters are valueless flags — ?immediately_available_for_review with 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 Modified

An 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 }
StatusWhen
401No token, or an unknown / revoked / expired one.
404No such id — or an id belonging to another user, which is deliberately indistinguishable.
422Malformed 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:

SuffixMeaning
_atAn instant, UTC. null when the thing has not happened.
_onA 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.