Skip to main content

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 contextctx.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_id your 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 → 429 on the 3rd POST /v1/instances call (that's a dashboard-side limit, not something your bot hits, but worth knowing if you manage the key yourself).
  • Assuming a 200 response means the target's account is RSO-verified — it isn't yet; both /v1/stats/player and /v1/stats/matches currently return deterministic placeholder data pending Riot Developer Program approval, not live stats or real match history.