kanji-buddy

The API

A deliberate clone of WaniKani's public API v2 — and the four endpoints that admit they aren't.

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 envelope

Every response is wrapped. A single resource:

{
  "id": 440,
  "object": "kanji",
  "url": "https://kanjibuddy.mblrc.me/api/v1/subjects/440",
  "data_updated_at": "2026-01-01T00:00:00.000Z",
  "data": { /* the resource itself */ }
}

A collection adds paging and a total:

{
  "object": "collection",
  "total_count": 9418,
  "pages": { "next_url": "…?page_after_id=500", "previous_url": null, "per_page": 500 },
  "data": [ /* resource envelopes */ ]
}

The eleven resources

PathNotes
/subjectsThe content. Read-only.
/assignmentsYour items. PUT /assignments/{id}/start moves a lesson into the review cycle.
/reviewsPOST one to submit an answer. This is what drives the SRS.
/review_statisticsAccuracy per subject. Filterable by percentage — that's the critical-items query.
/study_materialsNotes and synonyms. POST and PUT.
/level_progressionsWhen each level opened.
/resetsPresent for parity.
/summaryLessons now, reviews bucketed into the next 24 hours.
/userAccount. PUT merges preferences.
/spaced_repetition_systemsThe stage tables.
/voice_actorsAudio metadata.

Pagination is by id, not offset

?page_after_id=500 rather than ?offset=500. 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.

Incremental sync

?updated_after= on any collection returns only what has changed. 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

One endpoint deliberately has no ETag

/dashboard 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.

The four that aren’t WaniKani

EndpointWhy it exists
GET /api/v1/dashboardThe dashboard 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 and two batched round trips.
POST/DELETE /api/v1/internal/sessionsThe landing page mints a KanjiBuddy session for someone it has already authenticated. Guarded by a shared secret, not a user token — there is no user token yet, that is what it issues.
GET /api/v1/internal/nudgesThe daily cron. Guarded by CRON_SECRET.
POST /api/v1/unsubscribeOne-click email opt-out. POST only, deliberately — see below.

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.

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. 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 $TOKEN" \
  https://kanjibuddy.mblrc.me/api/v1/subjects/440