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 theX-Api-Keyheader. Free key at rblxvalue.com/developer.- Item lookup:
GET /v2/items/{{slug}}(slug or acronym, e.g.corruptorCORR). 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
- Open the Discord Developer Portal and create an application.
- Under Bot, click Reset Token and copy the token. No privileged intents are needed.
- Under OAuth2 → URL Generator, tick
botandapplications.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
- Go to rblxvalue.com/developer and log in with Roblox (official OAuth, only
openidandprofile). - Link your Discord account, fill in a name and your email, and click Create free key.
- 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"
}
| Field | Type | Meaning |
|---|---|---|
name, slug, acronym | string | Display name, URL slug (chroma-evergun) and trading acronym (CEGUN) |
value | number | RBLXValue estimate: average of both sources when both list the item, otherwise the one that does. Can have decimals (26.5). |
value_source_a | number | Value on MM2Values (0 = not listed there) |
value_source_b | number | Value on Supreme Values (0 = not listed there) |
category | string | Rarity tier: common, uncommon, rare, legendary, vintage, godly, ancient, unique, chroma, pets, misc |
type | string | Knife, gun, pet or misc |
demand | 0–10 | How much traders want the item |
rarity | 0–10 | How hard the item is to find in trades |
stability | string | Trend label, e.g. Stable, Rising, Overpaid for, Untradeable |
tier | number | Internal tier used for sorting |
event_name | string|null | Event the item came from, e.g. Halloween 2021 Event |
image_url, fandom_url | string | Item image (transparent WebP) and MM2 wiki page |
last_updated | datetime|null | When 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.
404means the item does not exist; the bot then falls back to a search.401/403mean a missing, wrong, inactive or suspended key.429means the rate limit was hit; wait forRetry-Afterseconds.- 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 namewithGET /v2/sets/{slug}(items in a set and the combined value)./inventory userwithGET /v2/inventory/{username}(public MM2 inventories with values).- A daily post of the biggest risers using
/v2/items?category=godly&limit=100plus 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