kanji-buddy API

Overview

The HTTP API behind KanjiBuddy, written for someone building a client against it — a mobile app, a script, another front end.

It is a deliberate clone of WaniKani’s public API v2, plus eleven endpoints that admit they aren’t. This page is the shape of the thing: Conventions covers the rules every endpoint obeys, Endpoint reference documents all of them one by one, and Building a client is the part to read if you are actually writing one.

The server it belongs to is documented separately, under kanji-buddy — the database, the SRS engine, the web app and how it is deployed.

Why clone rather than design

Cloning a specification someone else has already refined removes a hundred small decisions — what an error body looks like, how paging works, how a client syncs — and every one of those decisions would otherwise have been made badly the first time. It also means any client written against WaniKani can point here by changing one base URL.

The bodies match WaniKani API v2 revision 20170710: the same envelopes, the same id-cursor pagination, the same conditional requests, the same SRS engine. The paths are ours, versioned under /api/v1.

Base URL

https://kanjibuddy.mblrc.me/api/v1

Local development serves the same routes at http://localhost:3000/api/v1. Every endpoint on this page and the next three is relative to that prefix.

Three envelopes, and every body is one of them

Resource — a single thing with a numeric id

{
  "id": 440,
  "object": "kanji",
  "url": "https://kanjibuddy.mblrc.me/api/v1/subjects/440",
  "data_updated_at": "2026-07-07T05:30:10.285Z",
  "data": { "…": "the resource's own fields" }
}

Collection — a page of resources

{
  "object": "collection",
  "url": "https://kanjibuddy.mblrc.me/api/v1/subjects?levels=1",
  "pages": { "per_page": 1000, "next_url": null, "previous_url": null },
  "total_count": 80,
  "data_updated_at": "2026-07-07T05:30:10.285Z",
  "data": [ { "…": "resource envelopes" } ]
}

total_count counts rows matching your filters, not rows in data — it is the number to page against. data_updated_at on a collection is the most recent one among the resources actually returned, so an empty page reports null.

Report — aggregates, and the two that have no id

{
  "object": "report",
  "url": "https://kanjibuddy.mblrc.me/api/v1/days_studied",
  "data_updated_at": "2026-08-21T00:00:00.000Z",
  "data": { "…": "whatever the report is" }
}

Same outer fields as a resource, minus id. Used by every app-specific aggregate, and by /user and /summary, which are WaniKani’s own two id-less endpoints.

The eleven WaniKani-shaped resources

PathWhat it is
/subjectsThe content — radicals, kanji, vocabulary. ~9,400 rows, shared by every user, read-only.
/assignmentsThe join between you and a subject: unlocked, SRS stage, next due. PUT /assignments/{id}/start moves a lesson into the review cycle.
/reviewsPOST one to submit an answer. The only endpoint that moves the SRS.
/review_statisticsAccuracy per subject. Filterable by percentage — that's the critical-items query.
/study_materialsNotes and personal synonyms. POST and PUT.
/level_progressionsWhen each level opened. The highest one is your real level.
/resetsPresent for parity. Nothing in the API creates one.
/summaryLessons now, reviews bucketed into the next 24 hours.
/userAccount, timezone, preferences. PUT merges rather than replaces.
/spaced_repetition_systemsThe interval tables. Display only — the server schedules.
/voice_actorsAudio metadata.

The eleven that aren’t

Marked App-specific throughout the reference. They are purely additive — they exist because a phone on a slow connection should not make fifteen round trips to draw one screen — and nothing about them changes the WaniKani-shaped endpoints.

EndpointWhy it exists
GET /dashboardThe home screen needs an SRS-stage matrix, a forecast, level progress and three subject lists. Through the resource endpoints that is ~15 requests returning full rows to read a count from each. This is one request, aggregated in Postgres.
GET /review_statsReviews completed in a window and the window before it, to compare against. For asking about a period the user has not chosen in settings.
GET /days_studiedDistinct days with at least one lesson or review. The one genuinely cacheable report.
GET /heat_mapDay-by-day counts for the contribution grid, every day in range including the empty ones.
GET · POST /studyExtra Study: read what a practice mode holds, or open a cycle and record items done. Cannot move an SRS stage, by code path rather than by convention.
GET /item_spreadThe dashboard's stage matrix with the items in the cells. Building it from /assignments and /subjects would be a request per bucket plus a paged subject lookup — and the paging is where a mature account would quietly lose rows.
GET /lesson_allowanceHow many lessons today's ceiling still permits, so a client can honour the daily maximum without running the whole dashboard aggregation to learn one number.
GET · PUT /vacationStop and restart the SRS clock. current_vacation_started_at is a WaniKani field and /user reports it, but WaniKani's API has no way to set it — and ending a vacation must shift the schedule in the same breath as clearing the flag, which a preferences merge has no room for.
GET /unlockableWhat POST /advance would open, ordered by when each item would have arrived on its own. Answered by the same rule the POST acts on, so the preview cannot promise something the button then declines to do.
POST /advanceUnlocks the next tranche of lessons without waiting for Guru, by reading the normal unlock rules with 'started' where they say 'passed'. Takes an optional subject_ids to open part of the tranche. WaniKani has no equivalent by design.
GET /searchSubjects matching what someone typed, across characters, every meaning, every reading and the slug — romaji included, converted the same way a typed reading answer is. WaniKani has no search endpoint, and /subjects filters only by id, slug, type and level.
POST /burn_managerResurrect a burned item, or retire one that was resurrected and re-learned. Burning is the only one-way door in the SRS; neither way back exists in WaniKani's API. Checks each item's state and reports what it declined to touch rather than changing it quietly.
POST · DELETE /push_tokensRegister or retire an Expo push token for this install.

Internal endpoints, which are not part of the API

Listed so you know to ignore them. All are service-to-service, guarded by a shared secret or a signed token rather than a user token, and none will accept your bearer.

EndpointWhat it is
GET /internal/nudgesDaily Vercel Cron tick that sends backlog emails. Supports ?dry_run=1.
GET /internal/pushHourly GitHub Actions tick that sends push notifications.
POST /internal/sessionsPortal→KanjiBuddy SSO: mints a session for someone the portal has already authenticated. Guarded by a shared secret, not a user token — there is no user token yet, that is what it issues.
DELETE /internal/sessionsRevokes one.
POST /unsubscribeTurns off nudge emails, authorised by the HMAC token in the emailed link.

The internal/ prefix is load-bearing: it marks the endpoints authenticated by a shared secret rather than a user, so the distinction is visible in the path rather than buried in the handler.

Why unsubscribe has no GET handler

Outlook Safe Links, Gmail’s proxy, corporate scanners and browser prefetchers all fetch emailed URLs unbidden. If unsubscribing happened on GET, those crawlers would silently opt people out of a feature they never saw, and nobody would find out for months. The emailed link opens a page that only verifies and renders a button; the button posts. A test asserts the route exports no GET. POST /advance is POST-only for the same reason.

Isolation is enforced on every route

The user id comes from the token, never from client input, and every user-owned query filters on it in the WHERE clause rather than checking afterwards. An id belonging to another account is a 404, deliberately indistinguishable from an id that does not exist. That was enforced by convention until it was enforced by tests — see Auth & access.

Trying it

A Postman collection covering every endpoint lives in postman/. Or:

curl -H "Authorization: Bearer $KANJIBUDDY_TOKEN" \
  https://kanjibuddy.mblrc.me/api/v1/subjects/440