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
| Path | Notes |
|---|---|
/subjects | The content. Read-only. |
/assignments | Your items. PUT /assignments/{id}/start moves a lesson into the review cycle. |
/reviews | POST one to submit an answer. This is what drives the SRS. |
/review_statistics | Accuracy per subject. Filterable by percentage — that's the critical-items query. |
/study_materials | Notes and synonyms. POST and PUT. |
/level_progressions | When each level opened. |
/resets | Present for parity. |
/summary | Lessons now, reviews bucketed into the next 24 hours. |
/user | Account. PUT merges preferences. |
/spaced_repetition_systems | The stage tables. |
/voice_actors | Audio 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 ModifiedOne 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
| Endpoint | Why it exists |
|---|---|
GET /api/v1/dashboard | The 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/sessions | The 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/nudges | The daily cron. Guarded by CRON_SECRET. |
POST /api/v1/unsubscribe | One-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