A typed, zero-dependency osu! API v2 client for Node.js 22+.
- Node.js 22+ (uses native
fetch) - An OAuth application for API access
npm install otsuapiimport { createOsuClient, clientCredentials, Ruleset } from "otsuapi";
const client = createOsuClient({
auth: clientCredentials({
clientId: Number(process.env.OSU_CLIENT_ID),
clientSecret: process.env.OSU_CLIENT_SECRET!,
scopes: ["public"],
}),
});
const user = await client.users.get("@peppy", { mode: Ruleset.Osu });
const scores = await client.users.getScores(user.id, {
type: "best",
mode: Ruleset.Osu,
limit: 10,
});Set credentials in your environment:
# PowerShell
$env:OSU_CLIENT_ID = "your_client_id"
$env:OSU_CLIENT_SECRET = "your_client_secret"import { clientCredentials } from "otsuapi";
const auth = clientCredentials({
clientId: 12345,
clientSecret: "your_secret",
scopes: ["public"],
});Tokens are fetched automatically and refreshed before expiry. Concurrent requests share a single refresh.
When you already have an access token:
import { staticToken } from "otsuapi";
const auth = staticToken(process.env.OSU_ACCESS_TOKEN!);For endpoints that need a logged-in user (/me, friends, replays, etc.).
1. Register a callback URL in your OAuth app settings (e.g. http://127.0.0.1:3914/callback for local scripts).
2. Redirect the user to osu! for consent:
import { createOsuClient, authorizationCode, fileTokenStore, generateOAuthState } from "otsuapi";
const auth = authorizationCode({
clientId: 12345,
clientSecret: "your_secret",
redirectUri: "https://your-app.com/callback",
scopes: ["public", "identify", "friends.read"],
store: fileTokenStore(".osu-tokens.json"),
});
const url = auth.getAuthorizeUrl({ state: generateOAuthState() });
// redirect the user to `url`3. Exchange the callback code for tokens:
await auth.exchangeCode(codeFromCallback);4. Use the client — tokens refresh automatically:
const client = createOsuClient({ auth });
const me = await client.me.get();For scripts, use the built-in callback server:
import { createAuthCallbackServer } from "otsuapi";
const callback = await createAuthCallbackServer({ port: 3914 });
const auth = authorizationCode({
clientId: 12345,
clientSecret: "your_secret",
redirectUri: callback.redirectUri,
scopes: ["public", "identify"],
store: fileTokenStore(".osu-tokens.json"),
});
console.log(auth.getAuthorizeUrl());
const code = await callback.waitForCode();
await auth.exchangeCode(code);
await callback.close();Or run: npm run example:auth
Auth utilities are also available from the otsu/auth subpath.
const client = createOsuClient({
auth,
apiVersion: "20220705", // x-api-version header
acceptLanguage: "en", // Accept-Language header
debug: true, // log requests
logger: (msg) => console.log(msg), // custom debug logger
rateLimit: {
requestsPerMinute: 60, // osu! documented limit
minIntervalMs: 1000, // default: 1 req/s
onThrottled: (ms) => console.warn(`waiting ${ms}ms`),
},
retry: {
maxAttempts: 3,
baseDelayMs: 500,
},
});User ids and @usernames are handled automatically. Numeric strings like "12345" are treated as ids.
Scopes noted below are required for authorization-code flows. Client-credentials (public scope) covers most read endpoints.
| Method | Endpoint | Notes |
|---|---|---|
get(user, options?) |
GET /users/{user} |
By id or @username; optional mode |
getMany(ids, options?) |
GET /users |
Bulk fetch by ids |
lookup(ids, options?) |
GET /users/lookup |
Lookup with optional ruleset |
getScores(user, options?) |
GET /users/{user}/scores/{type} |
type: best, firsts, recent |
getBeatmapsets(user, type, options?) |
GET /users/{user}/beatmapsets/{type} |
favourite, ranked, loved, etc. |
getMostPlayed(user, options?) |
GET /users/{user}/beatmapsets/most_played |
|
getRecentActivity(user, options?) |
GET /users/{user}/recent_activity |
Returns RecentActivityEvent[] |
getPassedBeatmaps(user, options) |
GET /users/{user}/beatmaps-passed |
Requires beatmapsetIds |
getKudosuHistory(user, options?) |
GET /users/{user}/kudosu |
|
getFriends() |
GET /friends |
Scope: friends.read |
| Method | Endpoint |
|---|---|
get(beatmapId) |
GET /beatmaps/{beatmap} |
getMany(ids) |
GET /beatmaps |
lookup(options) |
GET /beatmaps/lookup |
getScores(beatmapId, options?) |
GET /beatmaps/{beatmap}/scores |
getUserScore(beatmapId, user, options?) |
GET /beatmaps/{beatmap}/scores/users/{user} |
getUserScores(beatmapId, user, options?) |
GET /beatmaps/{beatmap}/scores/users/{user}/all |
getDifficultyAttributes(beatmapId, options?) |
POST /beatmaps/{beatmap}/attributes |
getUserTags() |
GET /tags |
getPacks(type?, cursor_string?) |
GET /beatmaps/packs |
getPack(packTag, legacy_only?) |
GET /beatmaps/packs/{pack} |
| Method | Endpoint |
|---|---|
search(options?) |
GET /beatmapsets/search |
searchAll(options?) |
Auto-paginates via cursor_string |
get(beatmapsetId) |
GET /beatmapsets/{beatmapset} |
lookup(beatmapId) |
GET /beatmapsets/lookup |
getDiscussions(options?) |
GET /beatmapsets/discussions |
getDiscussionPosts(options?) |
GET /beatmapsets/discussions/posts |
getDiscussionVotes(options?) |
GET /beatmapsets/discussions/votes |
getEvents(options?) |
GET /beatmapsets/events |
Throws OsuResponseError if search returns HTTP 200 with an error field.
| Method | Endpoint | Notes |
|---|---|---|
get(scoreId) |
GET /scores/{score} |
|
getMany(options?) |
GET /scores |
Global score feed |
getReplay(scoreId) |
GET /scores/{score}/download |
Scope: delegate |
| Method | Endpoint |
|---|---|
get(mode, type, options?) |
GET /rankings/{mode}/{type} |
getAll(mode, type, options?) |
Auto-paginates performance/score/charts |
getKudosu(options?) |
GET /rankings/kudosu |
Types: performance, score, country, charts.
| Method | Endpoint | Scopes |
|---|---|---|
get(options?) |
GET /me/{mode?} |
identify |
getFavouriteBeatmapsetIds() |
GET /me/beatmapset-favourites |
identify |
| Method | Endpoint |
|---|---|
search(options) |
GET /search |
searchUsers(query, page?) |
GET /search?mode=user |
searchWiki(query, page?) |
GET /search?mode=wiki_page |
searchUsersAll(options) |
Paginated user search (max 100 results) |
searchWikiAll(options) |
Paginated wiki search |
| Method | Endpoint |
|---|---|
get(path, locale?) |
GET /wiki/{locale}/{path} |
| Method | Endpoint |
|---|---|
get(post) |
GET /news/{slug} or by numeric id |
list(year?) |
GET /news |
| Method | Endpoint |
|---|---|
get(options?) |
GET /events |
| Method | Endpoint |
|---|---|
get(commentId) |
GET /comments/{comment} |
list(options?) |
GET /comments |
| Method | Endpoint |
| ----------------------------- | --------------------------------- | ------- |
| getStreams() | GET /changelog |
| getBuilds(options?) | GET /changelog |
| getBuild(stream, build) | GET /changelog/{stream}/{build} |
| lookup(changelog, formats?) | GET /changelog/{id | build} |
| Method | Endpoint |
|---|---|
get(matchId, options?) |
GET /matches/{match} |
list(options?) |
GET /matches |
| Method | Endpoint |
|---|---|
list(type, mode, options?) |
GET /rooms |
get(roomId) |
GET /rooms/{room} |
getLeaderboard(roomId) |
GET /rooms/{room}/leaderboard |
getEvents(roomId) |
GET /rooms/{room}/events |
getPlaylistItemScores(roomId, itemId, options?) |
GET /rooms/{room}/playlist/{item}/scores |
type: playlists realtime. mode: active, all, ended, participated, owned.
| Method | Endpoint | Notes |
|---|---|---|
list() |
GET /forums |
|
get(forumId) |
GET /forums/{forum} |
|
listTopics(options?) |
GET /forums/topics |
|
getTopic(topicId, options?) |
GET /forums/topics/{topic} |
|
createTopic(forumId, title, text, poll?) |
POST /forums/topics |
Scope: forum.write |
reply(topicId, body) |
POST /forums/topics/{topic}/reply |
Scope: forum.write |
editTopicTitle(topicId, title) |
PUT /forums/topics/{topic} |
Scope: forum.write |
editPost(postId, body) |
PUT /forums/posts/{post} |
Scope: forum.write |
lockTopic(topicId, lock?) |
POST /forums/topics/{topic}/lock |
Scope: forum.write |
pinTopic(topicId, pin) |
POST /forums/topics/{topic}/pin |
Scope: forum.write |
| Method | Endpoint | Notes |
|---|---|---|
keepAlive(options?) |
POST /chat/ack |
Scope: chat.write |
listChannels() |
GET /chat/channels |
Scope: chat.read |
getChannel(channelId) |
GET /chat/channels/{channel} |
Scope: chat.read |
markAsRead(channelId, messageId) |
PUT /chat/channels/{channel}/mark-as-read/{message} |
Scope: chat.write |
createPrivateChannel(targetUserId) |
POST /chat/channels |
Scope: chat.write_manage |
createAnnouncement(body) |
POST /chat/channels |
Scope: chat.write_manage |
joinChannel(channelId, userId) |
PUT /chat/channels/{channel}/users/{user} |
Scope: chat.write_manage |
leaveChannel(channelId, userId) |
DELETE /chat/channels/{channel}/users/{user} |
Scope: chat.write_manage |
getMessages(channelId, options?) |
GET /chat/channels/{channel}/messages |
Scope: chat.read |
sendMessage(channelId, message, isAction?) |
POST /chat/channels/{channel}/messages |
Scope: chat.write |
sendPrivateMessage(targetUserId, message, options?) |
POST /chat/new |
Scope: chat.write |
WebSocket helpers: createChatWebsocket, createChatWebsocketFromClient, CHAT_WEBSOCKET_URL.
| Method | Endpoint |
|---|---|
get(team, mode?) |
GET /teams/{team} |
| Method | Endpoint |
|---|---|
list() |
GET /spotlights |
getRanking(mode, spotlightId, filter?) |
GET /rankings/{mode}/charts |
| Method | Endpoint |
|---|---|
getSeasonalBackgrounds() |
GET /seasonal-backgrounds |
Discriminated unions and guards help narrow events and scores at runtime:
import { isEventType, isAchievementEvent, isSoloScore, isMultiplayerScore } from "otsuapi";
const events = await client.events.get();
for (const event of events.events) {
if (isAchievementEvent(event)) {
console.log(event.achievement.name);
} else if (isEventType(event, "rank")) {
console.log(event.beatmap.title);
}
}
const scores = await client.users.getScores(2, { type: "best", limit: 10 });
for (const score of scores) {
if (isMultiplayerScore(score)) {
console.log(score.room_id);
} else if (isSoloScore(score)) {
console.log(score.ranked);
}
}await client.request("GET", "/wiki/{path}", {
query: { locale: "en" },
});Three pagination styles are used across the osu! API:
| Style | Used by | Helper |
|---|---|---|
cursor_string |
Beatmapset search | client.beatmapsets.searchAll() |
JSON cursor object |
Rankings | client.rankings.getAll() |
page number |
Home search | client.home.searchUsersAll() |
Low-level helpers are exported if you need them:
import { paginateCursor, paginateJsonCursor, paginatePage } from "otsuapi";All iterators guard against duplicate cursors and unbounded loops (default max 100 pages).
Import error classes from the main package or the otsu/errors subpath:
import {
OsuNotFoundError,
OsuRateLimitError,
OsuAuthenticationError,
OsuResponseError,
} from "otsuapi";
try {
await client.users.get(999999999);
} catch (error) {
if (error instanceof OsuNotFoundError) {
// 404
} else if (error instanceof OsuRateLimitError) {
await sleep(error.retryAfterMs ?? 60_000);
} else if (error instanceof OsuAuthenticationError) {
// bad credentials or expired token
}
}| Class | When |
|---|---|
OsuAuthenticationError |
HTTP 401, OAuth failures |
OsuForbiddenError |
HTTP 403 (missing scope) |
OsuNotFoundError |
HTTP 404 |
OsuValidationError |
HTTP 422 |
OsuRateLimitError |
HTTP 429 — check retryAfterMs |
OsuResponseError |
HTTP 200 with logical error payload |
OsuApiError |
Other HTTP errors |
All errors expose status, request, and response for debugging.
Runnable examples are in examples/ (require OSU_CLIENT_ID and OSU_CLIENT_SECRET):
npm run example # fetch a user + scores
npm run example:search -- "freedom dive"
npm run example:rankings -- US
npm run example:home -- peppy
npm run example:auth # interactive OAuth loginnpm install
npm test # unit tests (mocked fetch) — runs in CI
npm run test:guest # live API, client credentials (public scope)
npm run test:authenticated # live API, user token (scoped endpoints)
npm run test:all # everything
npm run build
npm run typecheck
npm run format # format with Prettier
npm run format:check # CI checkTests mirror the osu-api-v2-js structure:
| Directory | Purpose | Credentials |
|---|---|---|
tests/unit/ |
Mocked tests (client, auth, pagination, resources) | None |
tests/guest/ |
Live API with client credentials | OSU_CLIENT_ID, OSU_CLIENT_SECRET |
tests/authenticated/ |
Live API with user OAuth token | OSU_ACCESS_TOKEN |
Copy .env.example to .env for local guest/authenticated runs. CI runs unit tests only; guest and authenticated suites skip when credentials are unset.
# PowerShell — guest (public) tests
$env:OSU_CLIENT_ID = "your_client_id"
$env:OSU_CLIENT_SECRET = "your_client_secret"
npm run test:guest
# Authenticated tests (after `npm run example:auth`)
$env:OSU_ACCESS_TOKEN = "your_access_token"
npm run test:authenticatedTypes are partial — model interfaces cover documented fields; the API may return more. Use type guards (
isSoloScore,isEventType, etc.) for runtime narrowing
otsu is under the MIT License. See LICENSE.