Building a Bot on ValCord
You don't have to use Redbot. ValCord's Central API doesn't care what framework or language your bot is written in — a bot integration is really just: make one authenticated HTTP call, render whatever it says. This guide walks through that call in several languages and covers every response your bot needs to handle.
The production reference implementation is cog/valcord/valcord.py in this
project's repository — a real, deployed Redbot cog doing exactly what this
guide describes. When in doubt, that file is the ground truth.
What you need first
An Instance API Key, generated by a server admin on the
Hoster Portal — log in
with Discord, pick a server you have Manage Server permission on, generate
a key. It's shown exactly once; store it wherever your bot keeps secrets
(env var, secret manager, Redbot's Config store — never a checked-in
file, never posted in Discord itself, and never anywhere the bot's own
users can read it back).
Two things about the key that shape how you use it:
- It's bound to one guild, permanently. The key you generate for Server
A will always 403 if your bot presents it while claiming to be Server B
— even if you edit your bot's code to send a different
guild_id. This binding lives server-side in ValCord's database, not in the key itself, so there is no client-side trick that gets around it. - Max 2 active keys per Discord account. If you're hosting the bot for multiple servers under one Discord account, each server needs its own key, and you can only hold 2 active at once — revoke one before generating a third.
The one call your bot needs
POST https://api.valcord.smasse.xyz/v1/stats/player
Authorization: Bearer <your instance api key>
Content-Type: application/json
{
"guild_id": 555000111222333444,
"target_discord_id": 398175732393836544
}
guild_id must come from your bot's own real context — ctx.guild.id
in discord.py/Redbot, interaction.guild_id in discord.js, whatever your
framework's equivalent is. Never take it from user input, and never
hardcode it. Sending anything other than the guild you're actually running
in just gets you a 403 — the API already knows which guild your key was
issued for.
riot_account_id is optional — omit it to get the target's primary linked
account.
curl
curl -X POST https://api.valcord.smasse.xyz/v1/stats/player \
-H "Authorization: Bearer vk_live_..." \
-H "Content-Type: application/json" \
-d '{"guild_id": 555000111222333444, "target_discord_id": 398175732393836544}'
Python (httpx — what the real cog uses)
import httpx
async def get_stats(api_key: str, guild_id: int, target_discord_id: int):
async with httpx.AsyncClient(base_url="https://api.valcord.smasse.xyz", timeout=10.0) as client:
resp = await client.post(
"/v1/stats/player",
json={"guild_id": guild_id, "target_discord_id": target_discord_id},
headers={"Authorization": f"Bearer {api_key}"},
)
return resp
Python (requests — synchronous)
import requests
def get_stats(api_key: str, guild_id: int, target_discord_id: int):
return requests.post(
"https://api.valcord.smasse.xyz/v1/stats/player",
json={"guild_id": guild_id, "target_discord_id": target_discord_id},
headers={"Authorization": f"Bearer {api_key}"},
timeout=10,
)
Node.js (discord.js-style bot)
async function getStats(apiKey, guildId, targetDiscordId) {
const resp = await fetch("https://api.valcord.smasse.xyz/v1/stats/player", {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ guild_id: guildId, target_discord_id: targetDiscordId }),
});
return resp;
}
Match history (optional)
POST /v1/stats/matches uses the exact same key, same guild_id/
target_discord_id shape, and the exact same consent gate as
/v1/stats/player above — the only difference is the response includes
recent matches, and each match includes every participant (tracker.gg-style:
that's inherent to what Riot's match data is, not a separate consent
decision). See the API Reference for
the full response shape and an optional count field (default 5, max 10).
curl -X POST https://api.valcord.smasse.xyz/v1/stats/matches \
-H "Authorization: Bearer vk_live_..." \
-H "Content-Type: application/json" \
-d '{"guild_id": 555000111222333444, "target_discord_id": 398175732393836544, "count": 5}'
Game content, leaderboard, and status (no player involved)
Three more endpoints round out the API, and none of them touch consent at
all since none of them are about a specific Discord user: GET /v1/content
(maps/agents/game modes/acts — useful for turning the IDs in a match
response into names), GET /v1/leaderboard (Riot's public competitive
leaderboard), and GET /v1/status (region maintenance/incidents). Same
Instance API Key, just a GET with no body and no guild binding required.
Full shapes in the API Reference.
Handling the response — the one gotcha that matters
FastAPI wraps every error as {"detail": <value>}. For most errors
<value> is a plain string, but the two consent-related 403s use a
nested object so the caller can distinguish why — this bit the real
production cog once (true story, check the git history): code that reads
body["reason"] directly will always come back undefined/None for the
consent errors, because the real path is body["detail"]["reason"].
resp = await get_stats(api_key, guild_id, target_discord_id)
if resp.status_code == 200:
stats = resp.json()
# render stats.rank, stats.kd_ratio, etc.
elif resp.status_code == 403:
detail = resp.json()["detail"]
if isinstance(detail, dict):
# consent-related — detail["reason"] is "no_global_consent" or
# "no_server_consent"; detail["dashboard_url"] is where to send
# the user to fix it
reason = detail["reason"]
dashboard_url = detail["dashboard_url"]
else:
# detail is a plain string here — this is a guild/key mismatch,
# a problem with YOUR key, not the target user's consent
print(f"Key/guild mismatch: {detail}")
elif resp.status_code == 401:
print("Key is invalid or was revoked — generate a new one")
elif resp.status_code == 404:
print("Target consented but has no linked Valorant account")
elif resp.status_code == 429:
print("Rate limited — back off and retry later")
Full status/reason table in the API Reference.
A minimal complete example (discord.py-style)
import discord
import httpx
from discord.ext import commands
API_BASE_URL = "https://api.valcord.smasse.xyz"
class ValCordExample(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.api_key = "vk_live_..." # load from your own config/secrets, don't hardcode
self._http = httpx.AsyncClient(base_url=API_BASE_URL, timeout=10.0)
@commands.command()
async def valstats(self, ctx: commands.Context, member: discord.Member = None):
member = member or ctx.author
resp = await self._http.post(
"/v1/stats/player",
json={"guild_id": ctx.guild.id, "target_discord_id": member.id},
headers={"Authorization": f"Bearer {self.api_key}"},
)
if resp.status_code == 200:
data = resp.json()
await ctx.send(f"{member.mention}: {data['rank']} ({data['rr']} RR), "
f"{data['kd_ratio']:.2f} K/D over {data['matches_played']} matches")
elif resp.status_code == 403:
detail = resp.json()["detail"]
if isinstance(detail, dict):
await ctx.send(f"{member.mention} hasn't consented ({detail['reason']}). "
f"They can fix that at {detail['dashboard_url']}.")
else:
await ctx.send("This bot's API key isn't valid for this server — ask an admin to check it.")
elif resp.status_code == 404:
await ctx.send(f"{member.mention} hasn't linked a Valorant account yet.")
else:
await ctx.send("Couldn't fetch stats right now — try again shortly.")
This is deliberately the same shape as the real cog, trimmed down — no
Config store, no key-setting command, just the one call and the response
handling. Copy the full version from cog/valcord/valcord.py if you want
those too.
Things that will always fail, by design
- Sending a
guild_idyour key wasn't issued for →403, always, no matter how you construct the request. This is checked server-side against the database, not against anything the request itself claims. - Trying more than 2 active keys on one Discord account →
429on the 3rdPOST /v1/instancescall (that's a dashboard-side limit, not something your bot hits, but worth knowing if you manage the key yourself). - Assuming a
200response means the target's account is RSO-verified — it isn't yet; both/v1/stats/playerand/v1/stats/matchescurrently return deterministic placeholder data pending Riot Developer Program approval, not live stats or real match history.