Blog How to Build an MM2 Value Discord Bot in Python (Complete Code)

How to Build an MM2 Value Discord Bot in Python (Complete Code)

UN6107617 · 10 November 2026

This guide builds a complete Murder Mystery 2 (MM2) value Discord bot in Python with discord.py. Users search items with autocomplete and get an embed with the item's value, rarity, demand, stability and value change over the last 7 days. The data comes from the RBLXValue API v2, so you never maintain a values.json file yourself.

Short answer:
  • Data source: https://api.rblxvalue.com/v2, authenticated with the X-Api-Key header. Free key at rblxvalue.com/developer.
  • Item lookup: GET /v2/items/{{slug}} (slug or acronym, e.g. corrupt or CORR). Search: GET /v2/items?search=…&limit=10.
  • Recent changes: GET /v2/history/{{slug}}?period=1Y&source=mm2values.
  • Values come from MM2Values and Supreme Values, side by side, refreshed about every 12 hours.
  • Complete, tested code is below: one file, two slash commands (/value, /search).

What the bot does

  • /value item:Corrupt: embed with the value, the MM2Values and Supreme Values values, demand (x/10), rarity (x/10), stability, item image and the change over the last 7 days.
  • Autocomplete: while typing, Discord shows up to 10 matching items with their value.
  • /search query:harvester: list of matching items with value, rarity tier and demand.
  • Acronyms work: CORR, CEGUN, HW.
  • Caching (10 minutes), 429 retry and friendly errors.

Step 1: Create the Discord application

  1. Open the Discord Developer Portal and create an application.
  2. Under Bot, click Reset Token and copy the token. No privileged intents are needed.
  3. Under OAuth2 → URL Generator, tick bot and applications.commands, then permission Send Messages and Embed Links. Open the URL and add the bot to your server.

Step 2: Get a free RBLXValue API key

  1. Go to rblxvalue.com/developer and log in with Roblox (official OAuth, only openid and profile).
  2. Link your Discord account, fill in a name and your email, and click Create free key.
  3. Click the activation link in the email. The key works from that moment.

Step 3: Install

Python 3.10 or newer:

mkdir mm2-value-bot
cd mm2-value-bot
python -m venv venv
# Windows: venv\Scripts\activate   macOS/Linux: source venv/bin/activate
pip install discord.py aiohttp

Set the two secrets as environment variables (never in the code):

# macOS/Linux
export DISCORD_TOKEN="your-bot-token"
export RBLXVALUE_API_KEY="rbx_your_key"

# Windows PowerShell
$env:DISCORD_TOKEN="your-bot-token"
$env:RBLXVALUE_API_KEY="rbx_your_key"

Step 4: The complete bot (bot.py)

"""MM2 value Discord bot (discord.py 2.x + RBLXValue API v2).

Commands:
  /value item:<name>   value, rarity, demand, stability and recent change of one item
  /search query:<text> up to 10 matching items with their value

Environment variables: DISCORD_TOKEN, RBLXVALUE_API_KEY
"""
import asyncio
import os
import time
from datetime import datetime, timedelta, timezone

import aiohttp
import discord
from discord import app_commands

API_BASE = "https://api.rblxvalue.com/v2"
API_KEY = os.environ["RBLXVALUE_API_KEY"]
CACHE_SECONDS = 600  # values refresh about every 12 hours, so 10 minutes of caching is safe


class RblxValueAPI:
    """Small async client with an in-memory cache and 429 handling."""

    def __init__(self, session: aiohttp.ClientSession):
        self.session = session
        self.cache: dict[str, tuple[float, dict | None]] = {}

    async def get(self, path: str, params: dict | None = None) -> dict | None:
        key = path + "?" + "&".join(f"{k}={v}" for k, v in sorted((params or {}).items()))
        hit = self.cache.get(key)
        if hit and time.monotonic() - hit[0] < CACHE_SECONDS:
            return hit[1]

        for attempt in range(2):
            async with self.session.get(
                API_BASE + path,
                params=params,
                headers={"X-Api-Key": API_KEY},
                timeout=aiohttp.ClientTimeout(total=10),
            ) as resp:
                if resp.status == 429 and attempt == 0:
                    await asyncio.sleep(min(int(resp.headers.get("Retry-After", "5")), 10))
                    continue
                if resp.status == 404:
                    self.cache[key] = (time.monotonic(), None)
                    return None
                resp.raise_for_status()  # 401/403: check your key in the developer portal
                data = await resp.json()
                self.cache[key] = (time.monotonic(), data)
                return data
        return None

    async def item(self, name: str) -> dict | None:
        """Slug or acronym, case-insensitive: 'corrupt', 'CORR', 'chroma-evergun'."""
        slug = name.strip().lower().replace(" ", "-").replace("'", "")
        data = await self.get(f"/items/{slug}")
        if data is None:  # not a slug or acronym: fall back to search
            found = await self.search(name, limit=1)
            if not found:
                return None
            data = await self.get(f"/items/{found[0]['slug']}")
        return data["item"] if data else None

    async def search(self, query: str, limit: int = 10) -> list[dict]:
        data = await self.get("/items", {"search": query, "limit": limit})
        return data["items"] if data else []

    async def change_7d(self, slug: str) -> tuple[float, float] | None:
        """(old value, new value) from the MM2Values history over the last 7 days."""
        data = await self.get(f"/history/{slug}", {"period": "1Y", "source": "mm2values"})
        points = (data or {}).get("history") or []
        if len(points) < 2:
            return None
        cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=7)
        older = [p for p in points if datetime.fromisoformat(p["date"]) <= cutoff]
        start = older[-1] if older else points[0]
        return float(start["value"]), float(points[-1]["value"])


def fmt(n: int | float | None) -> str:
    """26.5 -> '26.5', 47000 -> '47,000'. Values can have decimals; 0 means 'not listed'."""
    if not n:
        return "—"
    return f"{n:,.2f}".rstrip("0").rstrip(".")


def item_embed(item: dict, change: tuple[float, float] | None) -> discord.Embed:
    e = discord.Embed(
        title=item["name"],
        url=f"https://rblxvalue.com/item/{item['slug']}",
        description=f"{item['category'].title()} {item['type'].lower()}"
        + (f" · {item['event_name']}" if item.get("event_name") else ""),
        color=0x3B82F6,
    )
    e.add_field(name="Value", value=fmt(item["value"]), inline=True)
    e.add_field(name="MM2Values", value=fmt(item["value_source_a"]), inline=True)
    e.add_field(name="Supreme Values", value=fmt(item["value_source_b"]), inline=True)
    e.add_field(name="Demand", value=f"{item['demand']}/10", inline=True)
    e.add_field(name="Rarity", value=f"{item['rarity']}/10", inline=True)
    e.add_field(name="Stability", value=item.get("stability") or "—", inline=True)
    if change:
        old, new = change
        if old and new != old:
            pct = (new - old) / old * 100
            arrow = "📈" if new > old else "📉"
            e.add_field(name="Last 7 days", value=f"{arrow} {fmt(old)} → {fmt(new)} ({pct:+.1f}%)", inline=False)
        else:
            e.add_field(name="Last 7 days", value="No change", inline=False)
    if item.get("image_url"):
        e.set_thumbnail(url=item["image_url"])
    e.set_footer(text="Data from rblxvalue.com")  # required on the free plan
    return e


class MM2Bot(discord.Client):
    def __init__(self):
        super().__init__(intents=discord.Intents.default())
        self.tree = app_commands.CommandTree(self)
        self.api: RblxValueAPI | None = None

    async def setup_hook(self):
        self.api = RblxValueAPI(aiohttp.ClientSession())
        await self.tree.sync()


bot = MM2Bot()


async def item_autocomplete(interaction: discord.Interaction, current: str):
    if len(current) < 2:
        return []
    items = await bot.api.search(current, limit=10)
    return [app_commands.Choice(name=f"{i['name']} ({fmt(i['value'])})"[:100], value=i["slug"]) for i in items]


@bot.tree.command(name="value", description="Show the value of an MM2 item")
@app_commands.describe(item="Item name or acronym, e.g. Corrupt or CORR")
@app_commands.autocomplete(item=item_autocomplete)
async def value(interaction: discord.Interaction, item: str):
    await interaction.response.defer()
    try:
        data = await bot.api.item(item)
        if not data:
            await interaction.followup.send(f"❌ No MM2 item found for **{item}**. Try the full name or an acronym.")
            return
        change = await bot.api.change_7d(data["slug"])
        await interaction.followup.send(embed=item_embed(data, change))
    except aiohttp.ClientError:
        await interaction.followup.send("⚠️ The value API is not reachable right now. Try again in a minute.")


@bot.tree.command(name="search", description="Search MM2 items")
@app_commands.describe(query="Part of an item name, e.g. harvester")
async def search(interaction: discord.Interaction, query: str):
    await interaction.response.defer()
    try:
        items = await bot.api.search(query, limit=10)
    except aiohttp.ClientError:
        await interaction.followup.send("⚠️ The value API is not reachable right now. Try again in a minute.")
        return
    if not items:
        await interaction.followup.send(f"❌ Nothing found for **{query}**.")
        return
    lines = [
        f"**[{i['name']}](https://rblxvalue.com/item/{i['slug']})** · {fmt(i['value'])} · "
        f"{i['category']} · demand {i['demand']}/10"
        for i in items
    ]
    e = discord.Embed(title=f"Results for “{query}”", description="\n".join(lines), color=0x3B82F6)
    e.set_footer(text="Data from rblxvalue.com")
    await interaction.followup.send(embed=e)


if __name__ == "__main__":
    bot.run(os.environ["DISCORD_TOKEN"])

Step 5: Run it

python bot.py

Global slash commands can take a few minutes to appear the first time. Then type /value in your server.

What the API returns

GET https://api.rblxvalue.com/v2/items/corrupt (real response, description shortened):

{
  "item": {
    "id": 16,
    "name": "Corrupt",
    "slug": "corrupt",
    "acronym": "CORR",
    "value": 230,
    "value_source_a": 230,
    "value_source_b": 230,
    "category": "unique",
    "type": "Knife",
    "tier": 9,
    "stability": "Stable",
    "demand": 3,
    "rarity": 3,
    "range": "N/A",
    "event_name": null,
    "description": "Corrupt is a unique knife in Murder Mystery 2, originally awarded to victims of the Data Loss Glitch. ...",
    "image_url": "https://cdn.un6107617.dev/rblxvalue/items/Corrupt_Transparent-resize.webp",
    "fandom_url": "https://murder-mystery-2.fandom.com/wiki/Corrupt",
    "last_updated": "2026-11-10 00:02:40"
  },
  "credit": "Data from rblxvalue.com"
}
FieldTypeMeaning
name, slug, acronymstringDisplay name, URL slug (chroma-evergun) and trading acronym (CEGUN)
valuenumberRBLXValue estimate: average of both sources when both list the item, otherwise the one that does. Can have decimals (26.5).
value_source_anumberValue on MM2Values (0 = not listed there)
value_source_bnumberValue on Supreme Values (0 = not listed there)
categorystringRarity tier: common, uncommon, rare, legendary, vintage, godly, ancient, unique, chroma, pets, misc
typestringKnife, gun, pet or misc
demand0–10How much traders want the item
rarity0–10How hard the item is to find in trades
stabilitystringTrend label, e.g. Stable, Rising, Overpaid for, Untradeable
tiernumberInternal tier used for sorting
event_namestring|nullEvent the item came from, e.g. Halloween 2021 Event
image_url, fandom_urlstringItem image (transparent WebP) and MM2 wiki page
last_updateddatetime|nullWhen the values were last refreshed

How the "recent changes" are calculated

The item endpoint only returns the current value. For the change, the bot calls GET /v2/history/{slug}?period=1Y&source=mm2values. History points are sorted oldest first, with a snapshot about every 12 hours. The bot takes the last point that is at least 7 days old and compares it with the newest point:

old = last snapshot at or before (now - 7 days)
new = newest snapshot
change % = (new - old) / old * 100

Use source=mm2values or source=supremevalues so you compare one list with itself. With source=all both lists are mixed in one array (each point has a source field).

Limits, errors and the credit rule

  • Free key: 30 requests per minute, and the bot must show "Data from rblxvalue.com" (the embed footer above does that). Paid keys (1,000/min) have no credit requirement.
  • Cache: values change about every 12 hours, so caching responses for 10 minutes keeps a busy bot far below the limit.
  • 404 means the item does not exist; the bot then falls back to a search. 401/403 mean a missing, wrong, inactive or suspended key. 429 means the rate limit was hit; wait for Retry-After seconds.
  • A new key only works after you click the activation link in the email you get when creating it.
  • Never put the API key or bot token in your code or on GitHub; use environment variables.

Ideas to extend the bot

  • /set name with GET /v2/sets/{slug} (items in a set and the combined value).
  • /inventory user with GET /v2/inventory/{username} (public MM2 inventories with values).
  • A daily post of the biggest risers using /v2/items?category=godly&limit=100 plus history.
  • Trade checker: add up two lists of items and compare the totals.

Prefer JavaScript? The same bot in discord.js: MM2 value Discord bot in discord.js. All endpoints and fields: MM2 values API reference.

FAQ

Which API should I use for an MM2 value Discord bot?

The RBLXValue API v2 (https://api.rblxvalue.com/v2) is built for this: item values from MM2Values and Supreme Values, demand, rarity, stability, acronyms, sets and value history as JSON. A free key gives 30 requests per minute.

Is the RBLXValue API free?

Yes. The free plan has 30 requests per minute and requires showing "Data from rblxvalue.com" in your bot. Paid plans start at 1,000 requests per minute without the credit.

How do I show recent value changes for an MM2 item?

Call GET /v2/history/{slug}?period=1Y&source=mm2values. The points are sorted oldest first; compare the newest point with the last point that is at least 7 days old.

Can users search MM2 items by acronym?

Yes. GET /v2/items/{slug} accepts a slug or an acronym, case-insensitive, so CORR returns Corrupt. For partial names use GET /v2/items?search=…

How often are MM2 values updated?

About every 12 hours. GET /v2/meta returns the last update time and the update interval, so a 10-minute cache is safe.

Why does my key return 403?

A new key works only after you click the activation link in the email. 403 can also mean the key expired or was suspended; check it at rblxvalue.com/developer.

Links: Get a free API key · API v2 docs · docs for LLMs (llms.txt) · rblxvalue.com/llms.txt · open-source bot on GitHub