[FREE] Alt Detection: Badge History Graph (180k+ Uses)

About

It’s important for community moderators on Roblox to identify alts to keep their spaces safe from rule-breakers bypassing punishments.

Traditionally, mods would check profiles for account age, friends, badges, inventory, favorites, etc., but that has been easily and quickly faked. Badges especially are taken advantage of with “Badge Walk” games, where you can get hundreds of player badges in minutes.

To help solve the issue of badge walks and detect alts, I’m introducing Badge History Graphs.

Instead of manually scrolling how many badges a user has, it graphs how many they earned and when they earned them. This visualizes a player’s “Proof of Time,” making it nearly impossible to fake a legitimate account history without actually playing Roblox for years.

And despite being hard for ban evaders to fake, it’s easy (and free) for you to tell at a glance whether someone is an alt.

Since its release, this opensource script has been put into a couple community Discord Bots, processing over 180,000 checks in 2025 alone.


Detecting Alts

Alternate accounts in these graphs tend to have:

  • Few badges in number
  • Large spaces of time where they don’t earn a lot (or any) badges
  • A sudden spike in badges from badge walking games or collecting them

The graph makes it easy to see a person’s badge history at a glance without scrolling their profile. Compare my account (a_cemaster) to the other badge-walking account (JOHNCENASKELLYMERC).

Video: https://www.youtube.com/watch?v=R80SCfLSGFE

Why This Works

Most anti-alt methods rely on “Proof of Identity” (including Roblox), where you’re trying to identify who’s who.

This tool relies on “Proof of Time”, where you judge the entropy (randomness) of a human playing games over several years. You don’t even need to know whose alt it is, you can just identify it’s an alt with public badge information (also helps with privacy).

This elevates the cost of making effective alts and catches the majority of low-effort, throwaway accounts as an effective filter. With this tool, ban evaders can no longer just buy a real looking account anymore, because they’d have to be maintained or get red flagged.

It’s essentially a time-lock puzzle, where an alt would need to develop for months or years to mimic human slope, introducing a higher barrier of entry to alt ban evasion when it used to just take a few minutes.

Current Use

xTracker (Gun Clan Community) reported over 150,000 badge graphs made in 2025 including 30,000 in their own Discord server.


Clanware (Sword Clan Community) with over 30,000 badge graphs in their Discord server (+ more most likely in other servers).


Problems and Future Work

Some potential issues:

  1. Users can have an inactivity excuse (or “quit the game”)
  2. Users can buy an active account and continue using it as their new main
  3. Users with an extremely high number of badges take longer to graph
  4. Users can delete their badges
  5. Users can solely play a single game (or a few) which limits their badge growth
  6. API can rate limit with too many calls on the same user
  7. Users need to have public inventory in order to view their badges (Tip: In high-security communities, you can ask a suspicious user to temporarily unprivate their inventory. If they refuse, it is often a red flag in itself.)
  8. Similar looking badge graphs can be very different depending on the scales
  9. A badge walk can skew an otherwise active account by being much larger than the surrounding
  10. A badge check can provide information on a user’s general play history, which could inherently be a privacy concern.

Be careful using this alone to declare if a user is an alt, it’s recommended to combine this with other common analysis methods.

Future Considerations

In the future, someone could possibly make a machine learning method to detect alts with this, although it’s not straightforward to automate into games without public inventories. I started this project a year ago and made the code free to use, so other people have used it for their own work. They’ve made the graph look nicer, the program run faster, and also have put it into Discord Bots to make it easier for them to look up users. Currently considering making a web app to run this on. From use cases I’ve seen, it seems fairly stable and accurate for other groups in the community to check for alts.

How to use

You can run it yourself using the Google Colab Link, it’s a relatively simple plotting script.

Edit (4/22/2026): Use this new Colab link now and follow the instructions in the comment to get an Open Cloud API key to use. You should make a copy of the file, so that you can keep using your own key (It does not save on my Colab link). Link: Google Colab

You can change the username strings of the USERNAMES list to graph different users. In Colab, you can press the triangle run button to see it working, no coding experience required.

It works by gathering all badges that a user has and running the “Awarded Date” API on each one, so that we can get how many badges they earned and when they got it. On the bottom of this post, I also copied the full code, so that you can run it locally as well (requires Python experience).

This code is free and open source. If you run a bot, a cafe group, or a moderation tool, you are encouraged to integrate this logic directly into your infrastructure. The more communities that use Badge Graph Analysis, the harder it becomes for ban evaders to operate on the platform.

Edit (4/22/2026): The script and Colab no longer work due to Roblox’s Badge Web API updates. I posted a newer version in the comments below, which uses Roblox’s Open Cloud API. You will need to get an API key by following the script’s instructions.

Old Code Below (Will not work, gets Forbidden 403 error):

"""
Author: a_cemaster
Date: 2/16/2024

This Python script creates a graph representing the cumulative count of badges earned by a Roblox user over time.
It fetches badge data using the Roblox API and plots the cumulative count of badges against their awarded dates.

If you're running this yourself, a window usually pops up with the graph.
If you're running this in Colab or Jupyter Notebook, then the image will show on the output.

You can also save the plot with the commented code 'plt.savefig(...)'

Some Roblox users have a lot of badges, which makes this script take a long time.

CHANGE LOGS:
- 9/7/2024: Using a proxy for Awarded Dates API for significant speed up, also set STEP = 100
- 3/14/2025: More proxy usage, also added a "can view inventory" print due to player badges being private with hidden inventory, removed concurrency
"""
import requests
import matplotlib.pyplot as plt
import matplotlib.dates as mdates

from datetime import datetime
from time import sleep

# Put usernames in this list to get their badge information
USERNAMES = ["a_cemaster", "w_armaster", "kielthenoob"]

# Will print current amount of badges tracked if True
# Recommended if user has a lot of badges and you want to see progress
PRINT_PROGRESS = True
BATCH_PER_PRINT = 1000

def get_user_id_from_username(username: str) -> str:
    # Use the Roblox users API to get the user ID from the username
    url = f"https://users.roblox.com/v1/usernames/users"
    params = {"usernames": [username]}
    response = requests.post(url, json=params)
    data = response.json()

    # Check if the response is valid and contains the user ID field
    if response.status_code == 200 and data["data"]:
        return data["data"][0]["id"]

    raise Exception(f"Could not find the user ID for username {username}")

def check_can_view_inventory(user_id: str) -> bool:
    """
    Given a Roblox user id, check if the user can view their inventory.
    """
    url = f"https://inventory.roproxy.com/v1/users/{user_id}/can-view-inventory"
    response = requests.get(url)
    # If we get a 429 status, wait and retry with some backoff
    retry_after = 5
    while response.status_code == 429:
        print(f"Rate limited. Retrying after {retry_after} seconds.")
        sleep(retry_after)
        response = requests.get(url)
        retry_after += 5

    response.raise_for_status()  # Raise an error for other non-200 responses
    data = response.json()
    return response.json()["canView"]

def fetch_badges(user_id: str) -> list[dict]:
    """
    Given a Roblox user id, get the user's badge data.
    """
    url = f"https://badges.roproxy.com/v1/users/{user_id}/badges?limit=100&sortOrder=Desc"
    badges = []
    cursor = None

    while True:
        params = {}
        if cursor:
            params['cursor'] = cursor

        response = requests.get(url, params=params)
        data = response.json()

        for badge in data['data']:
            badges.append(badge)
            if PRINT_PROGRESS and len(badges) % BATCH_PER_PRINT == 0:
                print(f"{len(badges)} badges for {user_id} requested.")

        if data['nextPageCursor']:
            cursor = data['nextPageCursor']
        else:
            break

    return badges

def convertDateToDatetime(date: str) -> datetime:
    """
    Given a timestamp string, convert to a datetime object.
    """
    milliseconds_length = len(date.split('.')[-1])

    # The string dates we get vary, so we have to sanitize to 3 places and 'Z'
    # Truncate if more than 3 places & 'Z', else pad zeroes
    if '.' in date:
        if milliseconds_length > 4:
            dotInd = date.find('.')
            date = date[:dotInd + 4] + date[-1]
        elif milliseconds_length < 4:
            date = date[:-1] + '0' * (4 - milliseconds_length) + date[-1]
    else:
        # There is no decimal portion, so we have to add it
        date = date[:-1] + ".000" + date[-1]

    return datetime.strptime(date, "%Y-%m-%dT%H:%M:%S.%fZ")

def fetch_award_dates(user_id: str, badges: list[dict]) -> list[str]:
    """
    Make requests to Roblox's Badge API to get user's badge awarded dates
    """
    dates = []
    badge_ids = [badge["id"] for badge in badges]
    url = f"https://badges.roproxy.com/v1/users/{user_id}/badges/awarded-dates"
    STEP = 100  # Adjust the step size as needed, we can't do too many at once

    for i in range(0, len(badge_ids), STEP):
        try:
            params = {"badgeIds": badge_ids[i:i + STEP]}
            response = requests.get(url, params=params)

            # If we get a 429 status, wait and retry with some backoff
            retry_after = 5
            while response.status_code == 429:
                print(f"Rate limited. Retrying after {retry_after} seconds.")
                sleep(retry_after)
                response = requests.get(url, params=params)
                retry_after += 5

            response.raise_for_status()  # Raise an error for other non-200 responses

            for badge in response.json()["data"]:
                dates.append(badge["awardedDate"])
                if PRINT_PROGRESS and len(dates) % BATCH_PER_PRINT == 0:
                    print(f"{len(dates)} awarded dates for {user_id} requested.")

        except Exception as e:
            print(f"Error fetching data: {e}")

    return dates

def plot_cumulative_badges(username: str, user_id: str, dates: list[str]):
    """
    Graph the cumulative total of badges over time
    """
    # Sort badges by awarded date
    y_values = [convertDateToDatetime(date) for date in dates]
    y_values.sort()

    # Calculate cumulative count at each date and store into a list
    curr_count = 0
    cumulative_counts = []
    for date in y_values:
        curr_count += 1
        cumulative_counts.append(curr_count)

    # Plot the cumulative count over time
    plt.style.use('dark_background')
    plt.xlabel('Badge Earned Date')
    plt.ylabel('Total Badges')
    plt.title(f'Badges over Time for {username} ({user_id})')
    plt.scatter(y_values, cumulative_counts, marker='o', alpha=0.2)

    # Set the X-axis format to 'Year' only
    ax = plt.gca()
    ax.xaxis.set_major_formatter(mdates.DateFormatter('%Y'))
    ax.xaxis.set_major_locator(mdates.YearLocator())

    plt.figtext(0.05, 0.95, f"Badge Count: {len(y_values)}", ha="left", va="top", color="white", transform=ax.transAxes)

    # Save the image, if desired. Must be ran before plt.show()
    # plt.savefig(f"BadgeCount-{username}-{user_id}.png", bbox_inches="tight")

    plt.tight_layout()
    plt.show()

def process_user(username: str):
    """
    Run all the functions to get the badge graph for single username
    """
    user_id = get_user_id_from_username(username)
    can_view_inventory = check_can_view_inventory(user_id)
    print(f"{username}: {user_id}, Can view inventory: {can_view_inventory}")
    badges = fetch_badges(user_id)
    dates = fetch_award_dates(user_id, badges)
    plot_cumulative_badges(username, user_id, dates)
    print(f"Completed plot for {username} - {user_id}")

def main():
    for username in USERNAMES:
        process_user(username)

if __name__ == "__main__":
    main()

tl;dr I made a Python script to see if an account often plays games and earns badges.

82 Likes

This is a really creative approach toward addressing the issue of alts and generally auditing an account’s activity period! I’ve seen this approach applied catch alts very effectively in the clan community.

19 Likes

Thanks, with how Roblox’s alt detection has either been ineffective or delayed over the past few years, I thought I might as well make a possible solution.

2 Likes

Great tool!! I know a lot of communities struggle with combating alts hopefully this helps.

3 Likes

Yeah I’ve seen it has been useful in gun clan and sword clan communities for quickly identifying potential alts. Especially nice for communities that deal with cheaters and unsafe individuals that go on alternative accounts.

Roblox made it so the script would not show badges for users with a hidden inventory. Communities enforcing a badge check will also have to enforce a public inventory.

EDIT: I’ve made an update that will print whether the program can see the inventory of a user. The script should still work for profiles with a public inventory.

2 Likes

So, forcing a player to have their whole inventory on display for anyone to see just to play the game?

1 Like

That’s how some groups work to deter alting exploiters and community-banned users. Once a badge graph is obtained, a player can close their inventory again after confirmation they’re not an alt. This isn’t a catch all for a large scale game, but more for smaller organized community events where alts are a problem.

If someone isn’t comfortable with opening their inventory, then a community can either refuse to let them in due to risk of being a prior banned user or look for another way to verify that it’s a main account. In any case, this could all be resolved if Roblox had a reliable alt detection or cheat detection system for these communities, but until then, badges are one way to determine this.

5 Likes

Can’t games prompt users for permission to access their inventory?

1 Like

Sure, but that’s up to the user to make their way out of the game to open their inventory to identify that they aren’t an alternate account.

For clarification, the badge graphing program is just a visual, so this is more useful for external communities on Guilded or Discord that have to vet accounts, instead of an automated in-game classifer (Although, it’s possible with some statistics or machine learning techniques).

5 Likes

my MAN

the community thanks you :slight_smile:

2 Likes

Member of said clanning community here

It has become revolutionary, every alt is getting CAUGHT and PWNED right before our very eyes.

This is a wonderful tool… I’d reccomend everyone uses it… whether youre a clanner a larper or some cafe shop owner…bazinga…
image

I am a huge fan of a_cemaster and his products, he does a great job coding for Republic of Aerius… great job as always!!!

Signed,
sealofapproval

3 Likes

Thank you for this awesome tool!

I haven’t seen any public Discord bots that have implemented this so I decided to add it to my own that people can use (had to make a NodeJS version of this since my bot’s programmed in that language):

2 Likes

Coming back to this post, I want to call more attention to it after a year of badge check usage.

This past year, I noticed two community servers add the graph to their Discord Bots. I’d love to see this graph gain traction with more groups and applications like bot commands or sites.

xTracker (Gun Clan Community) - Reported 150,000 badge checks over 2025.
Clanware (Sword Clan Community) - Their server had 30,000 badge checks in 2025, with more where the bot is added in servers we can’t see.

I’ve seen the bot used from small community vetting to Star Wars group applications, and I’m hoping it can help more people.

I made a video on it too:

1 Like

UPDATE: Using Badge History Graphs with Open Cloud API

Due to an update from Roblox on their Badge Web APIs as noted in Upcoming Breaking Change to CheckUserBadgesAsync, UserHasBadgeAsync, and Badges Web APIs, I have made a new Colab link with steps to get an API key from their Open Cloud to still be able to run the script.

Link Here: Google Colab

Copyable Script also here:

"""
Author: a_cemaster
Updated: 2026 - Migrated to Roblox Open Cloud API

Breaking change on March 23, 2026: Unauthenticated access to badge awarded-date
endpoints was disabled. This script now uses the Roblox Open Cloud Inventory API
with an API key instead of the unauthenticated roproxy.com endpoints.

HOW TO GET AN API KEY:
1. Go to https://create.roblox.com/dashboard/credentials
2. Click "Create API Key"
3. Under Access Permissions, add "Inventory" and grant Read access
4. Optionally restrict to your IP, or use 0.0.0.0/0 for testing
5. Save & Generate Key, then paste it into API_KEY below

CHANGE LOGS:
- 9/7/2024: Using a proxy for Awarded Dates API for significant speed up, also set STEP = 100
- 3/14/2025: More proxy usage, also added a "can view inventory" print, removed concurrency
- 4/2026: Migrated to Open Cloud API (api key auth) due to unauthenticated endpoint shutdown
"""
import requests
import matplotlib.pyplot as plt
import matplotlib.dates as mdates

from datetime import datetime
from time import sleep

# ============================================================
#  CONFIGURATION
# ============================================================

# Paste your Open Cloud API key here
# Get one at: https://create.roblox.com/dashboard/credentials
API_KEY = "YOUR_OPEN_CLOUD_API_KEY_HERE"

# Put usernames in this list to get their badge information
USERNAMES = ["a_cemaster", "w_armaster", "kielthenoob"]

# Will print current amount of badges tracked if True
PRINT_PROGRESS = True
BATCH_PER_PRINT = 1000

# ============================================================
#  HELPERS
# ============================================================

OPEN_CLOUD_BASE = "https://apis.roblox.com/cloud/v2"
OPEN_CLOUD_HEADERS = {"x-api-key": API_KEY}


def get_user_id_from_username(username: str) -> str:
    """Use the Roblox users API to get the user ID from the username."""
    url = "https://users.roblox.com/v1/usernames/users"
    response = requests.post(url, json={"usernames": [username]})
    data = response.json()
    if response.status_code == 200 and data["data"]:
        return str(data["data"][0]["id"])
    raise Exception(f"Could not find the user ID for username: {username}")


def check_can_view_inventory(user_id: str) -> bool:
    """
    Check if the user's inventory is publicly visible.
    Uses the Open Cloud inventory endpoint — if we can list at least one item
    (or get an empty list without a 403), the inventory is visible.
    A 403 means the inventory is private.
    """
    url = f"{OPEN_CLOUD_BASE}/users/{user_id}/inventory-items"
    params = {"filter": "badges=true", "maxPageSize": 1}
    response = requests.get(url, headers=OPEN_CLOUD_HEADERS, params=params)
    if response.status_code == 403:
        return False
    if response.status_code == 200:
        return True
    # For unexpected errors, raise so the caller knows
    response.raise_for_status()
    return False


def fetch_badges_open_cloud(user_id: str) -> list[dict]:
    """
    Fetch all badge inventory items for a user via the Open Cloud Inventory API.
    Returns a list of inventoryItem dicts, each containing:
      - badgeDetails.badgeId
      - addTime  (ISO 8601 timestamp — when the badge was awarded)
    """
    url = f"{OPEN_CLOUD_BASE}/users/{user_id}/inventory-items"
    params = {
        "filter": "badges=true",
        "maxPageSize": 100,
    }
    badges = []
    page_token = None

    while True:
        if page_token:
            params["pageToken"] = page_token

        response = requests.get(url, headers=OPEN_CLOUD_HEADERS, params=params)

        # Respect rate limits
        retry_after = 5
        while response.status_code == 429:
            print(f"Rate limited. Retrying after {retry_after} seconds.")
            sleep(retry_after)
            response = requests.get(url, headers=OPEN_CLOUD_HEADERS, params=params)
            retry_after += 5

        response.raise_for_status()
        data = response.json()

        for item in data.get("inventoryItems", []):
            badges.append(item)
            if PRINT_PROGRESS and len(badges) % BATCH_PER_PRINT == 0:
                print(f"{len(badges)} badges fetched for user {user_id}.")

        page_token = data.get("nextPageToken")
        if not page_token:
            break

    return badges


def extract_award_dates(badges: list[dict]) -> list[str]:
    """
    The Open Cloud Inventory API already returns the award timestamp as
    `addTime` on each item, so no second API call is needed.
    Returns a list of ISO 8601 date strings.
    """
    return [item["addTime"] for item in badges if "addTime" in item]


def convert_date_to_datetime(date: str) -> datetime:
    """
    Convert an ISO 8601 timestamp string to a datetime object.
    Handles variable fractional-second precision and trailing 'Z'.
    """
    # Normalise to exactly 3 fractional digits + Z
    if "." in date:
        base, frac = date.rstrip("Z").split(".", 1)
        frac = (frac + "000")[:3]
        date = f"{base}.{frac}Z"
    else:
        date = date.rstrip("Z") + ".000Z"

    return datetime.strptime(date, "%Y-%m-%dT%H:%M:%S.%fZ")


def plot_cumulative_badges(username: str, user_id: str, dates: list[str]):
    """Graph the cumulative total of badges earned over time."""
    parsed = sorted(convert_date_to_datetime(d) for d in dates)

    cumulative_counts = list(range(1, len(parsed) + 1))

    plt.style.use("dark_background")
    fig, ax = plt.subplots()

    ax.scatter(parsed, cumulative_counts, marker="o", alpha=0.2)
    ax.set_xlabel("Badge Earned Date")
    ax.set_ylabel("Total Badges")
    ax.set_title(f"Badges over Time for {username} ({user_id})")
    ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y"))
    ax.xaxis.set_major_locator(mdates.YearLocator())

    ax.text(
        0.05, 0.95,
        f"Badge Count: {len(parsed)}",
        ha="left", va="top", color="white",
        transform=ax.transAxes,
    )

    # Uncomment to save:
    # plt.savefig(f"BadgeCount-{username}-{user_id}.png", bbox_inches="tight")

    plt.tight_layout()
    plt.show()


# ============================================================
#  MAIN
# ============================================================

def process_user(username: str):
    """Fetch and graph badge history for a single username."""
    user_id = get_user_id_from_username(username)
    can_view = check_can_view_inventory(user_id)
    print(f"{username}: {user_id} | Inventory visible: {can_view}")

    if not can_view:
        print(f"  Skipping {username} — inventory is private.")
        print("  Tip: Ask the user to temporarily make their inventory public.")
        print("  Refusal to do so is often a red flag in itself.")
        return

    badges = fetch_badges_open_cloud(user_id)
    dates = extract_award_dates(badges)

    if not dates:
        print(f"  No badge dates found for {username}.")
        return

    plot_cumulative_badges(username, user_id, dates)
    print(f"Completed plot for {username} ({user_id}) — {len(dates)} badges.")


def main():
    if API_KEY == "YOUR_OPEN_CLOUD_API_KEY_HERE":
        print("ERROR: Please set your API_KEY before running.")
        print("Get one at: https://create.roblox.com/dashboard/credentials")
        return

    for username in USERNAMES:
        process_user(username)


if __name__ == "__main__":
    main()
1 Like