Hi Beatz API
Public REST API · v1

Everything in Hi Beatz,
as clean JSON.

The full song catalog, cover art, leaderboards, league standings and player profiles. The catalog needs no account at all. Your own data needs one key, which you can create in about two minutes.

Base URL https://api.beatz-nodefox.de
155songs in the catalog
31endpoints
140cover images
246country flags

Start without an account

The catalog, the cover art, the ranked seasons and every image are open. Paste this into a terminal and you have data back before you finish reading this sentence.

# every EDM track between 120 and 140 BPM
curl "https://api.beatz-nodefox.de/v1/songs?style=4&bpm_min=120&bpm_max=140"
{
  "catalog_version": "0.2.4",
  "total": 14,
  "songs": [
    {
      "id": 10001,
      "name": "Titanium",
      "artist": "David Guetta ft. Sia",
      "bpm": 126,
      "style": { "id": 4, "name": "EDM", "color": "#B145FF" },
      "colors": { "primary": "#8BECF5", "gradient": "#0B9ED7" },
      "cover": "/v1/songs/10001/cover"
    }
  ]
}
Only metadata and artwork. The game's audio files are licensed label music and are never served by this API.

The game's own vocabulary

Styles, difficulty grades and league names come straight out of the game's translation table, so the API speaks the same language the app does.

0 · Pop 1 · Rock 2 · Hip Hop 3 · R&B 4 · EDM

Timing windows per difficulty

How many milliseconds early or late a tap may be and still count as Perfect. Available at /v1/levels.

LevelNamePerfectGreat
1Standard±140 ms±190 ms
2Gold±120 ms±160 ms
3Diamond±100 ms±130 ms
4Diamond±70 ms±100 ms

Get your API key

Reaching your own profile, scores and league standing needs an API key, and that key comes from your game session. Two values live on your phone: your gamer_id and a token. You send them once and get a key back. No root, no modified app, and nothing is installed on the phone.

  1. Turn on Developer options

    On your Android phone open Settings → About phone and tap Build number seven times. A message confirms that developer options are now available. On Samsung devices the entry sits under Settings → About phone → Software information.

  2. Turn on USB debugging

    Go to Settings → System → Developer options and switch on USB debugging. Connect the phone to your computer with a cable and confirm the dialog that asks whether you trust this computer. Tick Always allow so it stops asking.

  3. Install the Android platform tools

    You only need adb. The script never downloads it, so it has to be on the machine already. One command, depending on your system:

    # macOS
    brew install --cask android-platform-tools
    
    # Windows
    winget install Google.PlatformTools
    
    # Debian / Ubuntu
    sudo apt install android-sdk-platform-tools

    If you already have Android Studio, adb is on your disk and the script finds it by itself. And if you would rather not install anything: download the platform tools zip, unpack it, and drop hb_token.py into that folder next to adb. It picks up the neighbour without any arguments.

    # check that the phone is visible
    adb devices
    
    # expected output
    List of devices attached
    R58MB0EXAMPLE     device

    If it says unauthorized, look at the phone screen and confirm the dialog. If the list is empty, try a different cable; some charging cables carry no data lines.

  4. Read the two values

    The helper script pulls your save file, reads gamer_id and token out of it and prints a ready made command. It only reads; nothing on the phone is changed.

    python3 hb_token.py
    Hi Beatz - token reader
    device      R58MB0EXAMPLE
    gamer_id    100000001
    token       481625107
    
    # paste this to create your key:
    curl -X POST https://api.beatz-nodefox.de/v1/auth \
      -H 'Content-Type: application/json' \
      -d '{"gamer_id": 100000001, "token": 481625107}'

    Newer Android versions lock app folders down harder, so the automatic search can come up empty. Your gamer ID is printed on your profile in the game: pass it in and the script knows exactly what to look for.

    python3 hb_token.py --gamer-id 100000001
  5. Trade the token for a key

    Run that command. The API checks the token against the game server and hands back a key that is yours to keep.

    {
      "api_key": "b7f3c1a94e2d05f8ab61c7d3e90f24b5",
      "gamer_id": 100000001,
      "usage": "Send this key as the X-Api-Key header on every following request."
    }
    curl https://api.beatz-nodefox.de/v1/user \
      -H 'X-Api-Key: b7f3c1a94e2d05f8ab61c7d3e90f24b5'
The script writes nothing. The save file is read into memory, the two values are picked out, and it is dropped when the script exits. Nothing is stored on your computer and nothing is changed on your phone.
The game token rotates on every login. Logging in on your phone invalidates the old one, so run /v1/auth again with a fresh token when that happens. Your API key stays the same. If a key ever leaks, call /v1/auth/refresh with your current key and a valid token: you get a new key and the old one stops working immediately.


The token reader

One file, no dependencies beyond the Python standard library. It talks to your phone through adb, reads the save file, finds the two values and prints the command. It never writes to the phone.

Download hb_token.py Python 3.8 or newer · 502 lines · MIT
Read the source
#!/usr/bin/env python3
"""
hb_token.py - reads your Hi Beatz gamer ID and session token from a phone
connected over USB, and prints the command that turns them into an API key.

    python3 hb_token.py

The script only reads. Nothing is written to the phone, nothing is
installed, and the game is not modified. No root required.

What it needs
    Python 3.8 or newer
    adb from Google's Android platform tools, on your PATH
    A phone with USB debugging enabled and this computer authorised

Useful options
    --gamer-id <n>   your ID from the game profile; makes the search exact
    --serial <id>    pick a device when more than one is attached
    --adb <path>     adb is not on your PATH
    --file <path>    parse a save file you already have, skip the phone
    --describe       describe the save format without writing anything
    --json           machine readable output
    --verbose        show every extraction attempt

Nothing is written to disk. The save file is held in memory, read, and
dropped when the script exits.

adb is not downloaded either. It has to be installed already; the script
looks on your PATH and in the usual Android Studio locations.
"""

from __future__ import annotations

import argparse
import json
import re
import shutil
import struct
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path

PACKAGE = "com.funtapx.magic.piano.rhythm.tiles.music.game"
SAVE_NAME = "GlobalData.bytes"
API_BASE = "https://api.beatz-nodefox.de"

# Game IDs and tokens observed so far are 8 to 10 digits. Anything outside
# that range is almost certainly a coincidence in the binary.
PLAUSIBLE = range(10_000_000, 4_000_000_000)


# ──────────────────────────────────────────────────────────────── output

class Out:
    """Small console helper. Colours are dropped when piped to a file."""

    def __init__(self, colour: bool, verbose: bool, quiet: bool):
        self.colour = colour
        self.verbose = verbose
        self.quiet = quiet

    def _c(self, code: str, text: str) -> str:
        return f"\033[{code}m{text}\033[0m" if self.colour else text

    def head(self, text: str) -> None:
        if not self.quiet:
            print(self._c("1", text))

    def info(self, label: str, value: str) -> None:
        if not self.quiet:
            print(f"{label:<12}{value}")

    def step(self, text: str) -> None:
        if self.verbose and not self.quiet:
            print(self._c("2", f"  · {text}"))

    def warn(self, text: str) -> None:
        print(self._c("33", f"warning: {text}"), file=sys.stderr)

    def fail(self, text: str, *hints: str) -> None:
        print(self._c("31", f"error: {text}"), file=sys.stderr)
        for h in hints:
            print(f"  {h}", file=sys.stderr)
        sys.exit(1)


# ──────────────────────────────────────────────────────────────── adb

@dataclass
class Adb:
    binary: str
    serial: str | None = None
    out: Out = field(default=None, repr=False)

    def run(self, *args: str, timeout: int = 30) -> subprocess.CompletedProcess:
        cmd = [self.binary]
        if self.serial:
            cmd += ["-s", self.serial]
        cmd += list(args)
        self.out.step("adb " + " ".join(args))
        return subprocess.run(cmd, capture_output=True, timeout=timeout)

    def devices(self) -> list[tuple[str, str]]:
        """[(serial, state)] for everything adb currently sees."""
        proc = subprocess.run([self.binary, "devices"], capture_output=True, timeout=20)
        found = []
        for line in proc.stdout.decode("utf-8", "replace").splitlines()[1:]:
            parts = line.split()
            if len(parts) >= 2:
                found.append((parts[0], parts[1]))
        return found


# Where to look for adb. Two situations cover almost everyone: it is
# installed and on PATH, or the platform tools were unpacked somewhere and
# this script was dropped next to adb. Both work without any arguments.
def adb_search_paths() -> list[Path]:
    home = Path.home()
    exe = "adb.exe" if sys.platform == "win32" else "adb"

    roots = [
        Path(__file__).resolve().parent,          # script dropped into platform-tools
        Path.cwd(),                               # or run from that folder
        home / "Android/Sdk/platform-tools",
        home / "Library/Android/sdk/platform-tools",       # macOS, Android Studio
        home / "AppData/Local/Android/Sdk/platform-tools", # Windows, Android Studio
        Path("/usr/lib/android-sdk/platform-tools"),       # Debian, Ubuntu
        Path("/opt/android-sdk/platform-tools"),
        Path("/usr/local/share/android-sdk/platform-tools"),
        Path("C:/Android/platform-tools"),
        Path("C:/platform-tools"),
        Path(__file__).resolve().parent / "platform-tools",
        Path.cwd() / "platform-tools",
    ]
    return [r / exe for r in roots]


def install_hint() -> list[str]:
    """The one command that installs adb on the system we are running on."""
    if sys.platform == "darwin":
        return ["Install it with Homebrew:",
                "  brew install --cask android-platform-tools"]
    if sys.platform == "win32":
        return ["Install it with winget:",
                "  winget install Google.PlatformTools",
                "or unzip the platform tools and run this script from that folder."]
    return ["Install it from your package manager:",
            "  sudo apt install android-sdk-platform-tools     (Debian, Ubuntu)",
            "  sudo dnf install android-tools                  (Fedora)",
            "  sudo pacman -S android-tools                    (Arch)"]


def locate_adb(explicit: str | None, out: Out) -> str:
    if explicit:
        if Path(explicit).is_file():
            return explicit
        out.fail(f"no adb at {explicit}")

    found = shutil.which("adb")
    if found:
        out.step(f"adb found on PATH: {found}")
        return found

    for candidate in adb_search_paths():
        if candidate.is_file():
            out.step(f"adb found at {candidate}")
            return str(candidate)

    out.fail(
        "adb not found",
        "This script does not download anything; adb has to be on the machine",
        "already. It ships in Google's Android platform tools.",
        "",
        *install_hint(),
        "",
        "Or download the zip, unpack it, and point the script at it:",
        "  https://developer.android.com/tools/releases/platform-tools",
        "  python3 hb_token.py --adb /path/to/platform-tools/adb",
    )


def pick_device(adb: Adb, out: Out) -> str:
    try:
        devices = adb.devices()
    except FileNotFoundError:
        out.fail(f"could not run {adb.binary}")
    except subprocess.TimeoutExpired:
        out.fail("adb did not respond", "Unplug the cable, plug it back in and retry.")

    ready = [s for s, state in devices if state == "device"]
    waiting = [s for s, state in devices if state == "unauthorized"]

    if waiting and not ready:
        out.fail(
            "the phone has not authorised this computer",
            "Look at the phone screen and confirm the USB debugging dialog.",
            "Tick 'Always allow from this computer' so it stops asking.",
        )

    if not ready:
        out.fail(
            "no phone found",
            "Check that USB debugging is on under Developer options.",
            "Try another cable; some charging cables carry no data lines.",
            "Then run: adb devices",
        )

    if len(ready) > 1 and not adb.serial:
        out.fail(
            f"{len(ready)} devices attached",
            "Pick one with --serial, for example:",
            f"  python3 hb_token.py --serial {ready[0]}",
        )

    return adb.serial or ready[0]


# ────────────────────────────────────────────────────────── extraction

def read_save(adb: Adb, out: Out) -> bytes:
    """
    Tries the known ways of reading the save file, in order of likelihood.

    Which one works depends on the Android version and how the game was
    built, so instead of assuming, every route is attempted and the first
    one that returns something usable wins.
    """
    attempts = [
        ("app private storage via run-as",
         ("exec-out", "run-as", PACKAGE, "cat", f"files/{SAVE_NAME}")),
        ("app folder on shared storage",
         ("exec-out", "cat", f"/sdcard/Android/data/{PACKAGE}/files/{SAVE_NAME}")),
        ("app files folder on shared storage",
         ("exec-out", "cat", f"/storage/emulated/0/Android/data/{PACKAGE}/files/{SAVE_NAME}")),
        ("run-as with shared storage path",
         ("exec-out", "run-as", PACKAGE, "cat",
          f"/sdcard/Android/data/{PACKAGE}/files/{SAVE_NAME}")),
    ]

    errors = []
    for label, args in attempts:
        out.step(f"trying {label}")
        try:
            proc = adb.run(*args)
        except subprocess.TimeoutExpired:
            errors.append(f"{label}: timed out")
            continue

        data = proc.stdout
        # run-as prints its complaint on stdout on some builds, so a short
        # answer that looks like text is treated as a failure, not as data.
        if len(data) > 64 and not data.lstrip()[:1].isalpha():
            out.step(f"got {len(data)} bytes from {label}")
            return data
        if len(data) > 512:
            out.step(f"got {len(data)} bytes from {label}")
            return data

        reason = (proc.stderr or data).decode("utf-8", "replace").strip()
        errors.append(f"{label}: {reason[:120] or 'empty response'}")

    out.fail(
        "could not read the save file",
        *[f"- {e}" for e in errors],
        "",
        "Newer Android versions block access to app folders. If none of the",
        "routes worked, copy the file off the phone yourself and pass it in:",
        f"  python3 hb_token.py --file {SAVE_NAME}",
    )


# ─────────────────────────────────────────────────────────────── parsing

@dataclass
class Credentials:
    gamer_id: int
    token: int
    how: str


def parse_text(data: bytes, out: Out) -> Credentials | None:
    """
    Unencrypted saves keep their fields as readable text. Look for the
    field names the game uses, in any of the spellings seen so far.
    """
    text = data.decode("utf-8", "ignore")

    id_names = ("gamerId", "gamer_id", "playerId", "player_id", "uid")
    tk_names = ("token", "sessionId", "session_id", "loginToken")

    def grab(names: tuple[str, ...]) -> int | None:
        for name in names:
            m = re.search(rf'"{name}"\s*[:=]\s*"?(\d{{6,12}})"?', text)
            if m:
                value = int(m.group(1))
                if value in PLAUSIBLE:
                    out.step(f"text match: {name} = {value}")
                    return value
        return None

    gamer_id = grab(id_names)
    token = grab(tk_names)

    if gamer_id and token:
        return Credentials(gamer_id, token, "field names in the save file")
    return None


def find_anchored(data: bytes, gamer_id: int, out: Out) -> Credentials | None:
    """
    The reliable route: you read your own ID off your game profile and
    pass it in. We locate it in the file and take the 32 bit value next
    to it as the token, which is how the game stores the pair.
    """
    needle = struct.pack("<I", gamer_id)
    at = data.find(needle)

    while at != -1:
        for label, off in (("after", at + 4), ("before", at - 4)):
            if 0 <= off <= len(data) - 4:
                (value,) = struct.unpack_from("<I", data, off)
                if value in PLAUSIBLE and value != gamer_id:
                    out.step(f"anchor at 0x{at:x}, token {label} it: {value}")
                    return Credentials(
                        gamer_id, value,
                        f"gamer ID you supplied, token stored {label} it at 0x{off:x}",
                    )
        at = data.find(needle, at + 1)

    # Text form of the same idea, for readable saves.
    m = re.search(rf"{gamer_id}\D{{1,20}}?(\d{{6,12}})", data.decode("utf-8", "ignore"))
    if m and int(m.group(1)) in PLAUSIBLE:
        return Credentials(gamer_id, int(m.group(1)),
                           "gamer ID you supplied, token found next to it as text")
    return None


def parse_binary(data: bytes, out: Out) -> Credentials | None:
    """
    Last resort without an anchor: look for two plausible 32 bit values
    side by side, on aligned offsets only. Save files are not random, so
    a single hit is trustworthy; several hits are not, and we say so
    instead of picking one.
    """
    hits: list[tuple[int, int, int]] = []

    for off in range(0, len(data) - 8, 4):
        a, b = struct.unpack_from("<II", data, off)
        if a in PLAUSIBLE and b in PLAUSIBLE and a != b:
            hits.append((off, a, b))

    for off, a, b in hits[:12]:
        out.step(f"binary candidate at 0x{off:x}: {a}, {b}")

    if len(hits) == 1:
        off, a, b = hits[0]
        return Credentials(a, b, f"adjacent 32 bit values at offset 0x{off:x}")

    if len(hits) > 1:
        out.warn(f"{len(hits)} possible pairs; too ambiguous to choose one.")
    return None


def parse(data: bytes, gamer_id: int | None, out: Out) -> Credentials:
    if gamer_id is not None:
        found = find_anchored(data, gamer_id, out)
        if found:
            return found
        out.fail(
            f"gamer ID {gamer_id} does not appear in the save file",
            "Check the number on your game profile, or drop --gamer-id to",
            "let the script search on its own.",
        )

    for strategy in (parse_text, parse_binary):
        found = strategy(data, out)
        if found:
            return found

    out.fail(
        "the save file was read but the values could not be identified",
        f"file size: {len(data)} bytes",
        "",
        "Fastest fix: your gamer ID is shown on your profile in the game.",
        "Pass it in and the token is found next to it:",
        "  python3 hb_token.py --gamer-id 100000001",
        "",
        "If that also fails, the save format has changed. Run",
        "  python3 hb_token.py --describe",
        "and report that description; it contains no token.",
    )


# ───────────────────────────────────────────────────────────── diagnosis

def describe(data: bytes, out: Out) -> None:
    """
    Prints the shape of the save file so an unrecognised format can be
    reported without sending the file itself, which holds a live token.
    """
    printable = sum(1 for b in data[:4096] if 32 <= b < 127 or b in (9, 10, 13))
    ratio = printable / min(len(data), 4096) if data else 0

    print()
    print("save file description")
    print(f"  size          {len(data)} bytes")
    print(f"  first bytes   {data[:16].hex(' ')}")
    print(f"  looks like    {'text' if ratio > 0.85 else 'binary'} "
          f"({ratio:.0%} printable)")

    for marker, what in ((b"ES3", "Easy Save 3"), (b"{", "JSON"), (b"PK", "zip")):
        if data[:8].lstrip().startswith(marker):
            print(f"  format hint   {what}")
            break

    keys = sorted(set(re.findall(rb'"([A-Za-z_][A-Za-z0-9_]{2,24})"\s*[:=]', data[:65536])))
    if keys:
        names = ", ".join(k.decode("ascii", "ignore") for k in keys[:25])
        print(f"  field names   {names}")
    print()
    print("  Paste this description when reporting an unrecognised format.")
    print("  It contains no token and no personal data.")
    print()


# ────────────────────────────────────────────────────────────────── main

def main() -> None:
    ap = argparse.ArgumentParser(
        prog="hb_token.py",
        description="Read your Hi Beatz gamer ID and session token over USB.",
    )
    ap.add_argument("--serial", help="device serial, when several are attached")
    ap.add_argument("--adb", help="path to adb, if it is not on your PATH")
    ap.add_argument("--file", help="parse an existing save file instead of the phone")
    ap.add_argument("--describe", action="store_true",
                    help="describe the save file without writing anything anywhere")
    ap.add_argument("--gamer-id", type=int, metavar="N",
                    help="your ID from the game profile; makes the search exact")
    ap.add_argument("--json", action="store_true", help="print JSON instead of text")
    ap.add_argument("--verbose", action="store_true", help="show every attempt")
    args = ap.parse_args()

    out = Out(
        colour=sys.stdout.isatty() and not args.json,
        verbose=args.verbose,
        quiet=args.json,
    )

    out.head("Hi Beatz - token reader")

    # ── get the save file ────────────────────────────────────────────
    if args.file:
        try:
            with open(args.file, "rb") as fh:
                data = fh.read()
        except OSError as exc:
            out.fail(f"could not read {args.file}: {exc}")
        out.info("source", args.file)
    else:
        adb_path = locate_adb(args.adb, out)
        adb = Adb(adb_path, args.serial, out)
        serial = pick_device(adb, out)
        adb.serial = serial
        out.info("device", serial)
        data = read_save(adb, out)

    if args.describe:
        describe(data, out)

    # ── find the values ──────────────────────────────────────────────
    creds = parse(data, args.gamer_id, out)

    if args.json:
        print(json.dumps({
            "gamer_id": creds.gamer_id,
            "token": creds.token,
            "found_by": creds.how,
        }, indent=2))
        return

    out.info("gamer_id", str(creds.gamer_id))
    out.info("token", str(creds.token))
    if args.verbose:
        out.info("found by", creds.how)

    body = json.dumps({"gamer_id": creds.gamer_id, "token": creds.token})
    print()
    print("# paste this to create your key:")
    print(f"curl -X POST {API_BASE}/v1/auth \\")
    print("  -H 'Content-Type: application/json' \\")
    print(f"  -d '{body}'")
    print()
    print("# the token expires the next time you log in on your phone.")


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        sys.exit(130)

Endpoint reference

Everything answers JSON except the image routes, which answer WebP with a one year cache lifetime. Endpoints are grouped by whether they need a key.

Catalog

No key required
EndpointReturns
GET/v1/metaCatalog summary: song counts, BPM range, duplicates
GET/v1/songsSong list. Filters: q, style, bpm_min, bpm_max, visible, limit, offset
GET/v1/songs/{id}One song with artist, BPM, style and colors
GET/v1/stylesThe five music styles with colors and song counts
GET/v1/levelsDifficulty grades with their timing windows
GET/v1/ranked/seasonsCompetitive seasons with start and end dates
GET/v1/ranked/tiersAll 35 leagues from Rookie1 to Legend

Images

No key required
EndpointReturns
GET/v1/songs/{id}/coverCover art, 512×512 WebP
GET/v1/avatars/{id}Profile picture, 256×256 WebP
GET/v1/tiers/{group}League badge, e.g. legend, expert
GET/v1/flags/{code}Country flag by ISO code, e.g. de
GET/v1/items/{name}Currency or item icon, e.g. Icon_Diamond

Authentication

No key required
EndpointReturns
POST/v1/authTrade gamer_id and token for an API key
POST/v1/auth/refreshRotate your key. The old one stops working at once

Your account

X-Api-Key
EndpointReturns
GET/v1/userName, level, experience, region, songs owned
GET/v1/user/scoreTotal score and global rank
GET/v1/user/songsSongs you own, joined with the catalog
GET/v1/user/recordsScore, grade and play count for every song played
GET/v1/user/levelLevel, experience and the current level task
GET/v1/user/currenciesDiamonds, coins, tickets and decorations
GET/v1/user/wishlistSongs on your wishlist
GET/v1/user/avatarAvatar ID and its image URL
GET/v1/user/bannerBanner ID
GET/v1/user/tasksCurrent task with progress
GET/v1/user/mailMailbox with attachments

Competitive

X-Api-Key
EndpointReturns
GET/v1/leaderboardRanked list with names resolved. rank=global | region | season_global | season_region, plus region=DE
GET/v1/user/rankedYour league, ranked score and match history
GET/v1/user/ranked/matchThe match currently running and its opponents
GET/v1/user/boardProgress in the board mode
GET/v1/challengeThe running song challenge and your results

Other players

X-Api-Key
EndpointReturns
GET/v1/players/{gamer_id}Public profile, statistics per difficulty, most played songs
GET/v1/players/searchSearch by name or unique code. q=Nightfall
GET/v1/shopShop slots of the current week

Errors

Every failure answers the same shape, so one handler covers all of them.

{
  "error": {
    "code": "unauthorized",
    "message": "Missing X-Api-Key header."
  }
}
400bad_requestA required value is missing or out of range
400bad_jsonThe request body is not valid JSON
401unauthorizedKey missing, malformed or unknown, or the game token expired
403api_errorThis endpoint is disabled on this installation
404not_foundNo such route, song, player or image
404no_coverThe song exists but the game loads its artwork separately
502upstream_errorThe game server answered with an error
504upstream_errorThe game server could not be reached
Read only. This API never writes to your account. Every endpoint that would change something — starting matches, collecting rewards, buying, deleting the account — is switched off at the server.