API & Clients
The backend exposes a REST API built with Django REST Framework. This page covers the endpoints, authentication, the OpenAPI schema, and the generated SDKs.
Exploring the API
With the server running (see Getting Started):
http://localhost:8000/— the browsable API roothttp://localhost:8000/schema/swagger/— Swagger UIhttp://localhost:8000/schema/redoc/— ReDochttp://localhost:8000/schema/— the raw OpenAPI document
Responses use camelCase keys (a middleware converts Django's snake_case), which is what the generated clients and the frontend expect.
Bulk data
Do not use the HTTP API to export the whole dataset. Consuming hundreds of result pages every time costs you time and us a ton of resources. Every variant, together with the variant aliases, is published as a single JSON document on S3, refreshed periodically:
| File | Description |
|---|---|
| https://json.commanderspellbook.com/variants.json.gz | Gzipped. Prefer this one. |
| https://json.commanderspellbook.com/variants.json | Uncompressed. |
The document holds the timestamp it was built at, the version that built it, and the variants and aliases arrays, whose items have the very same shape as the /variants/ and /variant-aliases/ responses. Fetch it on a schedule of your own and read it locally.
It is written by the export_variants task (spellbook/tasks/export_variants.py), which the recurring update CronJob runs with --s3 to upload to the bucket named by AWS_S3_BUCKET. The public base URL comes from SPELLBOOK_FILES_URL, set by every deployment alongside SPELLBOOK_API_URL and SPELLBOOK_WEBSITE_URL (see backend/.env and the Kubernetes manifests).
Guidelines for API consumers
Use the HTTP API for sparse, unauthenticated requests, with the general guideline of a few HTTP calls per user interaction with your tool.
- Name your service, optionally with a version, in the User-Agent header.
- You may be rate limited: 80 requests per minute should be a safe rate. Always handle
429 Too Many Requestsresponses — back off and retry rather than hammering the endpoint. - Please credit us and link back to commanderspellbook.com where applicable.
Endpoints
Routes are wired in backend/spellbook/urls.py, backend/website/urls.py, and the project urls.py.
Core (spellbook)
| Endpoint | Description |
|---|---|
GET /variants/ |
The generated variants — the main read endpoint. Supports the search query language. |
GET /cards/ |
Cards. |
GET /features/ |
Features. |
GET /templates/ |
Templates. |
GET/POST /find-my-combos |
Given a decklist, returns the combos it can assemble (the engine's up phase). |
GET/POST /estimate-bracket |
Estimates the power bracket of a decklist. |
GET /explain-query |
Explains a search query in plain English, or reports why it is invalid. |
… /variant-suggestions/ |
Community-submitted combos awaiting review. |
… /variant-update-suggestions/ |
Suggested edits to existing variants. |
… /variant-aliases/ |
Redirects from alternative ids to canonical variants. |
… /salt-votes/ |
The logged-in user's salt votes on variants. |
GET /salt-votes/queue/ |
Variants to vote the salt of. |
Salt votes
Logged-in users vote how salty a variant is, that is how unfun it is to play against, from 0 (not at all) to 4 (the most), like EDHREC's salt score for cards. Each user has a single vote per variant, addressed by the variant id and private to its author:
| Request | Effect |
|---|---|
PUT /salt-votes/{variantId}/ with {"score": 3} |
Casts the vote (201) or changes it (200). Only public variants can be voted. |
GET /salt-votes/{variantId}/ |
Reads the vote. |
DELETE /salt-votes/{variantId}/ |
Retracts the vote. |
GET /salt-votes/?variant={variantId} |
Lists the user's votes, optionally just the one on a variant, answering an empty list rather than 404. |
GET /salt-votes/queue/?limit=10 |
Serves variants to vote on, to anonymous callers too. |
- A vote comes back with the live
averageandvoteCountof the recent votes on its variant, to show right after voting. - Recent means cast or changed within
SALT_VOTE_WINDOW, a year: voting never closes, and old votes stop counting. - Every variant carries
salt, the average of its recent votes once they are at leastSALT_VOTE_MIN_COUNT(5), and their number assaltVoteCount. Theupdate_variantstask refreshes both, so they lag up to a couple of hours.GET /variants/?q=salt>=0&ordering=-saltranks the saltiest combos. - The queue draws commander-legal, non-spoiler variants the caller has not voted on recently, with Efraimidis–Spirakis weighted random sampling. A variant weighs
(decks + 1)^0.25, shrinking linearly to 0 as its recent votes approachSALT_VOTE_TARGET_COUNT(100): popular combos come first, until their score is statistically settled, while unpopular ones are drawn far less often. - Voting needs the
add_saltvoteandchange_saltvotepermissions, which every Discord login grants: revoking them from a user in the admin stops them from voting.
Naming a card
A card is never named by its database key, which the API does not publish. It answers to the compact
number it was given when an editor curated it — the id every response carries for it — and, wherever
an endpoint reaches cards nobody has curated, to its Scryfall Oracle ID as well.
| Input | Accepts |
|---|---|
GET /cards/{id}/ |
a number or an oracle id |
GET /templates/?matches= |
a number or an oracle id, repeatable |
GET /features/?cards= |
a number, repeatable — only a curated card produces features |
| a decklist, wherever one is posted | a card name, a number, or an oracle id |
A card nobody has curated has no number, so its id is null and its oracle id is the only way to
name it. Anything else is refused rather than guessed at.
Site support (website)
| Endpoint | Description |
|---|---|
GET /properties/ |
Site-wide configurable properties. |
GET /card-list-from-url |
Parse a decklist from a supported deckbuilder URL (Moxfield, Archidekt, Deckstats, TappedOut). |
GET/POST /card-list-from-text |
Parse a decklist from pasted text. |
Users & auth
/users/, plus the authentication endpoints below.
Authentication
Two mechanisms, both configured in the project urls.py:
- JWT (
simplejwt): POST /token/— obtain an access/refresh pairPOST /token/refresh/— refresh an access tokenPOST /token/verify/— verify a token
Send the access token as Authorization: Bearer <token>.
- Social login (social-auth) — Discord OAuth, enabled when DISCORD_CLIENTID / DISCORD_CLIENTSECRET are set.
Most read endpoints are public; writing and reviewing require authentication and the appropriate permissions. Editors work primarily through the admin panel (/admin), not the API.
The search query language
variants (and template matching) accept a Scryfall-style search query — e.g. ci:temur mana result:"infinite mana". Numeric terms take whole numbers, except price and salt, which take decimals too, like price<2.5 or salt>=3.2. The grammar is defined with Lark in spellbook/parsers/ and turned into ORM filters by the transformers in spellbook/transformers/. Extend the query language by editing the .lark grammar and its transformer together.
The same grammar drives a second transformer, which turns a query into an English sentence instead of a filter: GET /explain-query?q=ci:temur mana answers "Combos that have a color identity within green, blue, and red and use a card whose name contains “mana”." A new search term needs a phrase in variants_query_explanations/ alongside its filter, so that both endpoints accept and reject exactly the same queries.
OpenAPI schema
The schema is generated from the code by drf-spectacular. It is the contract the clients and frontend depend on, so keep it accurate: add serializer annotations and @extend_schema hints when you add or change an endpoint.
The prose the schema and the browsable API show — the API description, the root page and the /variants/ endpoint — lives in common/api_docs.py. Edit it there and this page together, so the two keep saying the same thing.
Regenerate the committed schema with:
cd client
./generate-openapi.sh # writes client/openapi.yaml
The script runs manage.py spectacular … --fail-on-warn --validate, so a schema warning is treated as an error — the CI does the same.
Generated clients
The SDKs are generated from openapi.yaml with openapi-generator (run via Docker, so Docker must be running):
cd client
./generate-openapi.sh # 1. refresh the schema
./generate-client-python.sh # 2a. Python client -> client/python/
./generate-client-typescript.sh # 2b. TypeScript client -> client/typescript/
- Python — package
spellbook_client(async,asynciolibrary). Used by the bots and the Python integration tests. - TypeScript — published to npm as
@space-cow-media/spellbook-clientand consumed by the React frontend.
The CI regenerates and publishes both on release; you only need to run these locally when changing the API and testing a client against it.