Contents
API
Read public packs and manage your own from scripts and bots with a personal API key: endpoints, limits, errors and an OpenAPI document.
The packs API lets your own scripts and bots read public packs and manage the packs you saved. It's JSON over HTTPS at https://packs.haruhime.moe/api/v1, and every request needs your personal API key.
A machine-readable description is at /api/v1/openapi.json (OpenAPI 3.1).
Quick start
#- Sign in with osu! and open your account page.
- In API key, press Create API key. Copy the key right away: you only see it once.
- Call the API with it:
curl https://packs.haruhime.moe/api/v1/me \ -H "Authorization: Bearer hpk_your_key_here"{ "user": { "id": "66f0a1b2c3d4e5f6a7b8c9d0", "osuId": 1234567, "username": "player1" } }Authentication
#- Send
Authorization: Bearer hpk_…with every request. A key ishpk_followed by 43 letters, digits,-and_. - Each account has one key. Regenerate on your account page makes a new one, and the old key stops working right away. Revoke deletes it.
- We keep only a hash of your key, so we can't show it to you again. Lost it? Regenerate.
- A key acts as you. It can't hide, pin or moderate packs, not even an admin's key.
- A missing key gets
401with the codeunauthorized. A wrong, revoked, or replaced key gets401with the codeinvalid_api_key.
Keep your key on a server
#The API is for servers and bots. It sends no CORS headers, so a web page on another site can't call it, and a key inside a web page or app would leak to everyone who opens it. Keep it in an environment variable or a secret store, never in a public repo.
Rate limits
#- 60 requests a minute per account, across every endpoint.
- 10 writes a minute per account (
POST,PUT,DELETE). Writes count toward the 60 too. Saving, editing or deleting packs and magnet links on the site counts toward the same 10. - 20 failed key attempts a minute per IP address (an IPv6 address counts by its /64).
- 10 new keys an hour per account, on your account page.
Counters start over at the top of each minute (each hour for new keys). Every response carries:
RateLimit-Limit: requests allowed in the current window;RateLimit-Remaining: how many are left;RateLimit-Reset: seconds until the window starts over.
On a 401, these describe the failed-attempt limit for your IP address.
Over a limit you get 429 with a Retry-After header, in seconds. Wait that long, then try again.
Errors
#Every error has the same shape:
{ "error": { "code": "not_found", "message": "Pack not found." } }code doesn't change, so your program can check it. message is for people and may change.
400bad_request: the body orpageisn't valid. The message says what to fix.401unauthorized: no key. A bad key getsinvalid_api_keyinstead. Both come withWWW-Authenticate: Bearer.404not_found: the pack doesn't exist, or it's private or hidden and not yours.PUTandDELETEalso answer404for any pack that isn't yours. The API doesn't say which.409conflict: you already have 200 saved packs. Delete one first.413too_large: the body is over 16 KB.415unsupported_media_type: send the body withContent-Type: application/json.429rate_limited: see Rate limits.500internal_error: something failed on our side. Try again later.
The pack object
#{ "slug": "V1StGXR8_Z", "name": "Spring Cup Finals", "visibility": "public", "description": "Grand finals pool.", "slots": [ { "mod": "NM", "index": 1, "beatmapId": 129891 }, { "mod": "HD", "index": 1, "beatmapId": 75 } ], "exports": [], "stats": { "srMin": 2.55, "srMax": 7.81, "srAvg": 5.18, "lenMin": 142, "lenMax": 258, "bpmMin": 120, "bpmMax": 222, "mods": ["NM", "HD"], "modes": ["osu"], "count": 2, "complete": true, "computedAt": "2026-09-22T12:00:05.000Z" }, "packKey": "pk1.…", "ownerName": "player1", "createdAt": "2026-09-22T12:00:00.000Z", "updatedAt": "2026-09-22T12:00:00.000Z"}slotsholds beatmap (difficulty) IDs by slot. Titles, star ratings per map, and other beatmap details aren't included. Look them up on osu! or a beatmap mirror.bucketsshows up when a pack has its own slots or slot order. Custom slots carry their color and mods.descriptionis left out when the pack has none.ownerNameis the owner's osu! username,haruhime poolson the tournament pools pools.haruhime.moe publishes, orUnknown playerwhen we don't have one.packKeyis the pack's pack key. Anyone can open it athttps://packs.haruhime.moe/k#followed by the key.exportslists the magnet links the owner recorded, newest first.hiddenAtshows up only on your own packs, when a moderator has hidden one.statssums up the pack's maps. We work it out a few seconds after each save, so it's missing from the answer toPOSTand to aPUTthat changes the maps or slots; read the pack again a little later. It's also missing for a pack whose stats we haven't worked out yet.
Pack stats
#srMin,srMax,srAvg: star rating, 2 decimals. A slot that forces EZ, HR, DT, HT or FL counts with its rating with those mods; every other slot (NM, HD, FM, TB, free mod) counts with the plain rating.lenMin,lenMax: map length in seconds, andbpmMin,bpmMax: BPM, both after DT (1.5 times as fast) and HT (0.75 times).mods: the pack's built-in slots (NM,HD,HR,DT,FM,TB) and the mods its custom slots force (EZ,HD,HR,DT,HT,FL; a custom free mod slot counts asFM), in that order.modes: the rulesets of its maps (osu,taiko,fruits,mania).count: the number of maps.complete:falsewhen we couldn't look up a map or a rating with mods. The numbers then cover the maps we could, and we try again later. A map osu! says doesn't exist (deleted, say) is left out of the numbers and doesn't makecompletefalse. A range isnullwhen no map gave a value.computedAt: when we worked the stats out.
Endpoints
#GET /api/v1/me
#The key's owner.
{ "user": { "id": "66f0a1b2c3d4e5f6a7b8c9d0", "osuId": 1234567, "username": "player1" } }GET /api/v1/packs
#Public packs, most recently updated first, 50 per page. Takes ?page= (see Pagination). Each entry in packs is a full pack object, shortened here. Pinned packs aren't marked or moved up here; the Pinned row is only on the site.
{ "packs": [{ "slug": "V1StGXR8_Z", "name": "Spring Cup Finals", "packKey": "pk1.…" }], "page": 1, "pageCount": 3, "total": 131 }GET /api/v1/packs/{slug}
#One pack. Public and unlisted packs work with any key. Private and hidden packs work only with their owner's key.
{ "pack": { "slug": "V1StGXR8_Z", "name": "Spring Cup Finals", "packKey": "pk1.…" } }GET /api/v1/me/packs
#Your packs, any visibility, most recently updated first, 50 per page. Takes ?page= (see Pagination) and answers in the same shape as GET /api/v1/packs.
{ "packs": [{ "slug": "V1StGXR8_Z", "name": "Spring Cup Finals", "visibility": "private" }], "page": 1, "pageCount": 1, "total": 12 }POST /api/v1/packs
#Save a new pack. The body follows the same rules as the site: a name of 1 to 64 characters, 1 to 64 maps, a description of up to 500 characters, up to 8 custom slots, and no slurs in the name, the description or custom slot names. visibility is private, unlisted (the default), or public.
curl https://packs.haruhime.moe/api/v1/packs \ -H "Authorization: Bearer hpk_your_key_here" \ -H "Content-Type: application/json" \ -d '{"name":"Spring Cup Finals","visibility":"unlisted","slots":[{"mod":"NM","index":1,"beatmapId":129891}]}'Answers 201 with { "pack": … }.
PUT /api/v1/packs/{slug}
#Replace one of your packs. Send the whole pack, as for POST. Leaving out description clears it, and leaving out visibility makes the pack unlisted. A new pool (different maps, slots, or name) clears the pack's recorded magnet links, because they no longer match. A pack a moderator hid stays hidden, and a pinned pack that stops being public loses its pin. Answers 200 with { "pack": … }.
DELETE /api/v1/packs/{slug}
#Delete one of your packs. Answers 204 with no body.
Pagination
#page starts at 1 and goes up to 999999. Responses include page, pageCount, and total. A page past the end comes back with an empty packs list.
To list public packs without a key, use the static search index at /packs/index.json (up to 5,000 packs). It doesn't count toward any limit, and it updates shortly after a public pack changes. Entries are newest created first. Each entry has s (slug), n (name), o (the owner's osu! username), c (map count), d (the start of the description: up to 140 characters, plus … when it's cut), u (last updated) and t (created). Each entry also carries the pack's stats in short form once we have them: r star rating range, a average stars, l length range in seconds, b BPM range, m mods and g rulesets (comma-separated), and k for complete. For a pack's maps, call GET /api/v1/packs/{slug}.
Pack keys
#A pack key holds a whole pool in one line of text. The pack key guide documents every key version, byte by byte.
Use it with Claude Code
#The haruhime plugin for Claude Code has skills for osu! tools, including one for packs and this API. Install it from Claude Code:
/plugin marketplace add haruhimemoe/claude-plugin/plugin install haruhime@haruhimemoeThe source is at github.com/haruhimemoe/claude-plugin.
Help
#Questions about the API, or something not working as this page says? Ask in our Discord server.
Changes
#- 2026-09-24: removed map usage (
GET /beatmaps/{id}/usageandGET /beatmaps/usage), thearchivefield on pack objects, the index keysx,xkandxu, and thesourcefilter on /packs (source=). They were live for about a day. Tournament pools from pools.haruhime.moe are plain packs owned byharuhime pools. - 2026-09-24:
GET /beatmaps/{id}/usageandGET /beatmaps/usagelisted the archive pools a map was used in, until the removal above. They needed no key. - 2026-09-24: archive packs (past tournament pools) carried
archive, and their index entries carriedx,xkandxu, until the removal above. The index listed them after community packs. - 2026-09-24: pack objects carry
stats, and the search index carries them in short form. Index entries carryt(created) and are newest created first. - 2026-09-23:
GET /me/packsis paged likeGET /packs.