wanikani-sdk
v0.2.0
Published
Typed, runtime-validated TypeScript SDK for the WaniKani v2 API.
Maintainers
Readme
wanikani-sdk
Typed, runtime-validated TypeScript SDK for the WaniKani v2 API.
Schemas are defined with valibot and serve as the single source of truth for both compile-time types and runtime validation of inputs and outputs. Includes a built-in 60 req/min rate limiter and a strict one-retry policy on transient failures to keep API keys out of trouble.
Status: API is pre-1.0; minor versions may introduce breaking changes until 1.0.0.
Install
bun add wanikani-sdk
# or
npm install wanikani-sdkRequires Node 18+ / Bun / modern browsers / Workers (uses native fetch).
Quick start
import { WanikaniClient } from "wanikani-sdk";
const client = new WanikaniClient({ apiKey: process.env.WANIKANI_API_KEY });
const me = await client.user.get();
console.log(me.data.username, "is level", me.data.level);
for await (const page of client.subjects.paginate({ types: ["kanji"], levels: [1] })) {
for (const env of page.data) console.log(env.id, env.data.characters);
}If no apiKey is passed, the client reads WANIKANI_API_KEY from the environment.
Configuration
Every option on new WanikaniClient(options):
new WanikaniClient({
apiKey: string, // default: process.env.WANIKANI_API_KEY
revision: "20170710", // default; pin to a docs revision
baseUrl: string, // default: "https://api.wanikani.com/v2/"
fetch: typeof fetch, // default: globalThis.fetch
rateLimit: { rpm: 60 }, // default; set `false` to disable client-side limiting
validate: "both", // default; "input" | "output" | "none"
});| Option | What it does |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| apiKey | Personal Access Token for the WaniKani account. Falls back to process.env.WANIKANI_API_KEY; constructor throws synchronously if neither is set. |
| revision | Sent as the Wanikani-Revision header. Only change this if you've migrated to a newer docs revision. |
| baseUrl | Override for tests or proxies. Trailing slash auto-added. |
| fetch | Inject a custom fetch — useful for instrumentation, retries beyond the built-in policy, or running in environments that don't expose a global fetch. |
| rateLimit | Token bucket sized to rpm tokens, refilled at rpm/60/sec. false skips the limiter (don't do this against the real API). |
| validate | both parses inputs and outputs through valibot. output only checks responses (still gives you types but trusts your inputs). input only checks request bodies. none skips both — fastest, no safety net. |
Retry policy (non-configurable)
A failed request retries at most once, only for clearly transient signals:
- 429: sleep until
RateLimit-Reset, retry once. A second 429 throwsWanikaniRateLimitError. - 503: 2-second backoff, retry once.
- Network errors (fetch rejection): retry once.
Deterministic 4xx (401, 403, 404, 409, 422) and 500 are never retried — retrying them risks getting the API key banned.
Resources
Every resource hangs off a configured WanikaniClient instance.
| Resource | GET | POST / PUT |
| ----------------------------------------------------- | ------------------------------------------ | ---------------------------------- |
| user | .get() | .update(prefs) |
| summary | .get() | — |
| subjects | .get(id), .list(...), .paginate(...) | — |
| assignments | .get(id), .list(...), .paginate(...) | (start: deferred) |
| reviewStatistics | .get(id), .list(...), .paginate(...) | — |
| studyMaterials | .get(id), .list(...), .paginate(...) | .create(...), .update(id, ...) |
| levelProgressions | .get(id), .list(...), .paginate(...) | — |
| resets | .get(id), .list(...), .paginate(...) | — |
| spacedRepetitionSystems | .get(id), .list(...), .paginate(...) | — |
| voiceActors | .get(id), .list(...), .paginate(...) | — |
Roadmap
Known deferrals — these will land in later 0.x releases:
POST /reviews(submit a review) andPUT /assignments/:id/start— intentionally not exposed yet to avoid accidentally advancing SRS state on real accounts.- Per-resource
If-Modified-Since/If-None-Matcharguments. Today the transport supports conditional requests end-to-end but the resource methods don't yet take the headers as a parameter; see Conditional requests. - A built-in caching layer on top of the conditional-request plumbing.
user
const me = await client.user.get();
console.log(me.data.username, me.data.level, me.data.subscription.type);
// Update preferences (the only mutable user fields)
await client.user.update({
preferences: { reviews_autoplay_audio: true, lessons_batch_size: 5 },
});summary
const summary = await client.summary.get();
console.log(summary.data.lessons.length, "lesson buckets ready");
console.log("next reviews at:", summary.data.next_reviews_at);subjects
// Single subject by id
const kanji = await client.subjects.get(440);
// One page (default per_page is 1000 for subjects)
const radicals = await client.subjects.list({ types: ["radical"], levels: [1, 2] });
// Stream every page
for await (const page of client.subjects.paginate({ types: ["kanji"] })) {
for (const env of page.data) {
// env.data is a Radical | Kanji | Vocabulary | KanaVocabulary discriminated by env.object
}
}assignments
const review = await client.assignments.list({
immediately_available_for_review: true,
});
console.log("ready to review:", review.total_count);reviewStatistics
const weak = await client.reviewStatistics.list({
percentages_less_than: 60,
subject_types: ["kanji"],
});studyMaterials
// Add a synonym + meaning note
const created = await client.studyMaterials.create({
subject_id: 440,
meaning_synonyms: ["one (counter)"],
meaning_note: "Use for counting flat objects.",
});
// Update later
await client.studyMaterials.update(created.id, {
meaning_synonyms: ["one (counter)", "single"],
});levelProgressions
const progressions = await client.levelProgressions.list();
console.log("current level started at:", progressions.data.at(-1)?.data.unlocked_at);resets
const resets = await client.resets.list();
console.log(resets.total_count, "total resets on this account");spacedRepetitionSystems
const srs = await client.spacedRepetitionSystems.get(1);
for (const stage of srs.data.stages) {
console.log("stage", stage.position, "→", stage.interval, stage.interval_unit);
}voiceActors
const actors = await client.voiceActors.list();
for (const env of actors.data) console.log(env.data.name, env.data.gender);Pagination
WaniKani uses cursor-based pagination (page_after_id / page_before_id). Each .list(...) returns a single page; .paginate(...) returns an AsyncGenerator that walks the pages.next_url chain until exhaustion.
// Eager: collect everything (large for /subjects — ~9000 items)
const all = [];
for await (const page of client.subjects.paginate()) all.push(...page.data);
// Lazy: stop early
for await (const page of client.assignments.paginate({ in_review: true })) {
if (page.data.length === 0) break;
process(page.data);
}per_page is controlled by WaniKani (500 for most resources, 1000 for /subjects and /reviews). The SDK doesn't override it.
Conditional requests
The transport sends If-Modified-Since / If-None-Match when you provide them, and translates a 304 response into a WanikaniNotModified error. ETag handling is plumbed end-to-end — what's missing from v0.1 is wiring through the per-resource methods. Caching is left to the consumer:
import { WanikaniNotModified } from "wanikani-sdk";
try {
const fresh = await client.subjects.list({ updated_after: lastSyncedAt });
cache.write(fresh);
} catch (err) {
if (err instanceof WanikaniNotModified) {
// Nothing changed since lastSyncedAt — keep using the cache.
} else throw err;
}The updated_after query param is the v0.1-supported way to do incremental sync. Native If-Modified-Since per-resource is on the v0.2 roadmap.
Errors
All errors extend WanikaniError:
WanikaniApiError— non-2xx HTTP response. Carriesstatus,code,url.WanikaniRateLimitError— 429 after the single allowed retry. Carries theRateLimit-Resetepoch asresetAt: Date | null.WanikaniValidationError— request input or response payload failed valibot validation. Carriesdirection: "input" | "output"and the rawissuesfrom valibot.WanikaniNotModified— thrown on 304 from conditional requests.
import { WanikaniApiError, WanikaniRateLimitError } from "wanikani-sdk";
try {
await client.user.get();
} catch (err) {
if (err instanceof WanikaniRateLimitError) console.warn("retry after", err.resetAt);
else if (err instanceof WanikaniApiError && err.status === 403) {
console.error("account hibernating or token revoked");
} else throw err;
}Browser usage
WaniKani's API does not advertise CORS for arbitrary browser origins. Call this SDK from a server-side route, edge function, or proxy — not from page JS.
License
ISC
