16 Keyless Game APIs, 5 Incompatible Ways to Say "Empty"


Yesterday my probe script printed a card list for an API whose request never completed.

The status column said 000. Connection failed, nothing came back. The body column, right next to it, showed {"cards":[]}. Both were printed by the same line of my own code, and they contradicted each other.

The bug was four characters long. I had written curl -s -o /tmp/c55 and reused that one temp file for every endpoint in the loop. When a request died, curl never wrote the file, so my script read the previous API’s leftover body and reported it as this one’s answer. A dead endpoint looked like a live endpoint returning nothing.

I was, at that exact moment, measuring how sixteen game APIs report “nothing found.” My measuring tool had the disease I was measuring.

The one idea in this post

A free game API returns cards, monsters, decks, trivia, prices or server status with no API key, no signup, no card. Sixteen clear that bar and I verified every one with a live curl on July 19, 2026. The list is one screen down.

Under it is the finding: I asked all sixteen for something that does not exist, and got five incompatible answers. Not five different error messages. Five different shapes:

  1. HTTP 200 with a status field buried in the body. OpenTDB response_code: 1, Steam success: false.
  2. HTTP 200 with an empty array, at four different paths. cards, data, pagination.size, and the bare top level.
  3. HTTP 200 with a populated, plausible, wrong value. mcsrvstat hands back ip: "127.0.0.1".
  4. HTTP 404 whose body is text/plain, not JSON. PokeAPI and dnd5eapi, nine bytes, Not Found.
  5. HTTP 404 that disagrees with its own body. FreeToGame: 404 outside, "status": 0 inside.

Seven of the sixteen answer 200. Two serve a 404 that is not JSON. One I could not measure at all.

Which makes the wrapper from every tutorial, the one you have written a dozen times:

if (res.ok) return res.json()

an error detector that fails open. It is not that it misses one API’s convention. It is that no single convention exists to code against.

The artifact: what that wrapper actually does

This is real stdout, July 19, 2026, from Python urllib plus json.load. SILENT means the call returned successfully and handed a body downstream that contains no result.

One caveat about the probes themselves, up front, because it changes how you should read the table. The sixteen questions are not the same kind of question. OpenTDB got a valid filter combination with no matches. MTG.io, PokemonTCG, Speedrun.com, CheapShark and Jikan got searches for a name that does not exist. Steam, Guild Wars 2, Valorant, Deck of Cards, PokeAPI, dnd5eapi, Open5e and Scryfall got a resource id that does not exist. FreeToGame got an invalid platform value. mcsrvstat got a hostname that cannot resolve. Those are five different situations, and part of why the answers disagree is that the questions did. What they share is the thing a caller actually cares about: I asked, and there is nothing for me.

API         outcome under urllib.urlopen + json.load      verdict
OpenTDB     RETURNED DATA  {"response_code": 1, "results": []}SILENT
Steam       RETURNED DATA  {"999999999": {"success": false}}SILENT
MTG.io      RETURNED DATA  {"cards": []}                  SILENT
PokemonTCG  RETURNED DATA  {"data": [], "page": 1, "pageSize": 25SILENT
Speedrun    RETURNED DATA  {"data": [], "pagination": {"offset": SILENT
CheapShark  RETURNED DATA  []                             SILENT
mcsrvstat   RETURNED DATA  {"ip": "127.0.0.1", "port": 25565, "deSILENT
PokeAPI     HTTPError 404                                 caught
dnd5eapi    HTTPError 404                                 caught
FreeToGame  HTTPError 404                                 caught
DeckOfCards HTTPError 404                                 caught
Valorant    HTTPError 404                                 caught
GuildWars2  HTTPError 404                                 caught
Open5e      HTTPError 404                                 caught
Scryfall    HTTPError 404                                 caught
Jikan       HTTPError 504                                 caught

Seven of sixteen. Every one of those seven is a 200. Every one carries a body that parses cleanly. Nothing anywhere raises, retries, or logs.

Be careful about what the other eight rows prove, because I nearly overclaimed here. Those caught verdicts belong to urllib, which raises HTTPError on any 4xx by itself. They are not what if (res.ok) return res.json() does. That wrapper does not raise on a 404 at all: it skips the return and falls off the end, so the caller gets undefined and no exception to trace it to. So the two most common client shapes have two different ways of going quiet on the same sixteen endpoints, and only the seven SILENT rows are a finding both of them share. Those seven are the load-bearing claim.

Here is the script. It needs no key and writes nothing.

# runnable, read-only: no key, no signup. The wrapper every tutorial ships,
# pointed at 16 keyless game APIs, each asked for something that does not exist.
import json, urllib.request, urllib.error

UA = {"User-Agent": "keyless-audit/1.0"}

BOGUS = [
 ("OpenTDB",     "https://opentdb.com/api.php?amount=50&category=9&difficulty=hard&type=boolean"),
 ("Steam",       "https://store.steampowered.com/api/appdetails?appids=999999999"),
 ("MTG.io",      "https://api.magicthegathering.io/v1/cards?name=zzzznotacardzzzz"),
 ("PokemonTCG",  "https://api.pokemontcg.io/v2/cards?q=name:zzzznotacardzzzz"),
 ("Speedrun",    "https://www.speedrun.com/api/v1/games?name=zzzznotagamezzzz"),
 ("CheapShark",  "https://www.cheapshark.com/api/1.0/deals?title=zzzznotagamezzzz"),
 ("mcsrvstat",   "https://api.mcsrvstat.us/3/this-server-does-not-exist-zzz9.example"),
 ("PokeAPI",     "https://pokeapi.co/api/v2/pokemon/zzzznotapokemonzzzz"),
 ("dnd5eapi",    "https://www.dnd5eapi.co/api/2014/spells/zzzznotaspellzzzz"),
 ("FreeToGame",  "https://www.freetogame.com/api/games?platform=zzzznotaplatform"),
 ("DeckOfCards", "https://deckofcardsapi.com/api/deck/zzzznotadeckzzzz/draw/?count=1"),
 ("Valorant",    "https://valorant-api.com/v1/agents/00000000-0000-0000-0000-000000000000"),
 ("GuildWars2",  "https://api.guildwars2.com/v2/items?ids=999999999"),
 ("Open5e",      "https://api.open5e.com/v1/monsters/zzzznotamonsterzzzz/"),
 ("Scryfall",    "https://api.scryfall.com/cards/named?exact=zzzznotacardzzzz"),
 ("Jikan",       "https://api.jikan.moe/v4/anime?q=zzzznotananimezzzz&limit=2"),
]

def fetch(url):
    """The wrapper from every tutorial. Returns data, or raises."""
    req = urllib.request.Request(url, headers=UA)
    with urllib.request.urlopen(req, timeout=40) as r:   # raises on 4xx/5xx
        return json.load(r)                              # raises on non-JSON

print(f"{'API':<12}{'outcome under urllib.urlopen + json.load':<46}verdict")
for name, url in BOGUS:
    try:
        data = fetch(url)
        preview = json.dumps(data)[:38]
        print(f"{name:<12}{'RETURNED DATA  ' + preview:<46}SILENT")
    except urllib.error.HTTPError as e:
        print(f"{name:<12}{'HTTPError ' + str(e.code):<46}caught")
    except json.JSONDecodeError as e:
        print(f"{name:<12}{'JSONDecodeError ' + str(e)[:26]:<46}WRONG BRANCH")
    except Exception as e:
        print(f"{name:<12}{type(e).__name__ + ' ' + str(e)[:24]:<46}other")

I ran it twice, an hour apart, because the first pass had two SSL handshake timeouts and I did not want to publish a flake as a finding. Both passes agree on all seven. On the first pass Scryfall timed out; on the second it returned its 404, which three separate curl calls also confirmed.

The 16, at a glance

Everything below returned HTTP 200 with no key from my host on July 19, 2026. Plain User-Agent, no Authorization, no ?key=.

#APIWhat it returnsWhat it says when there is nothing
1ScryfallMagic: The Gathering cards, prices, rulings404 + full error object with code, status, details
2PokeAPIPokemon, moves, abilities, evolution chains404, body is Not Found as text/plain
3Deck of CardsShuffled decks, draws, piles404 + {"success": false, "error": "Deck ID does not exist."}
4Open Trivia DBTrivia questions, 24 categories200 + {"response_code":1,"results":[]}
5FreeToGameFree-to-play game catalogue404 outside, "status":0 inside. They disagree
6CheapSharkPC game deals across 30+ stores200 + a bare [] at the top level
7dnd5eapiD&D 5e spells, monsters, classes404, body is Not Found as text/plain
8Open5eD&D 5e SRD content, 3,207 monsters by its own count404 + {"detail":"No Monster matches the given query."}
9MTG.ioMagic cards, sets, formats200 + {"cards":[]}
10PokemonTCGPokemon trading cards, sets, prices200 + {"data":[], ..., "count":0,"totalCount":0}
11Guild Wars 2Items, recipes, worlds, prices404 + {"text":"all ids provided are invalid"} if all ids are bad. 206 if only some are
12Speedrun.comSpeedruns, games, leaderboards200 + {"data":[],"pagination":{ ..., "size":0, ...}}
13mcsrvstatMinecraft server status, MOTD, players200 + ip: "127.0.0.1" and online: false
14JikanUnofficial MyAnimeList: anime, manga504 both times I asked. See caveats
15Valorant-APIAgents, weapons, maps, skins404 + {"status":404,"error":"the requested uuid was not found"}
16Steam appdetailsStore data for any Steam app200 + {"999999999":{"success":false}}

Scryfall, Open5e, Valorant-API and Deck of Cards handled my probe the way you would want: a 404, a JSON body, and a field that says why. They still need explicit error-body handling, they are just not going to surprise you about whether they found anything. The rest need a paragraph each, and Guild Wars 2 needs one for a reason I did not expect.

The 206 that res.ok throws away

Guild Wars 2 was going to be in the paragraph above. I asked it for one item id that does not exist and got a textbook 404 with {"text":"all ids provided are invalid"}. Clean.

Then I remembered that nobody uses that endpoint one id at a time. Batching is the whole point of ?ids=. So I asked for one real id and one fake one:

GET /v2/items?ids=999999999,999999998  ->  404  {"text":"all ids provided are invalid"}
GET /v2/items?ids=24,999999999         ->  206  [ { ... "id": 24 ... } ]

One of the two ids came back. There is no error field, no warning, no list of what it dropped. But look at the status code: 206 Partial Content, which is the correct, specification-compliant, genuinely thoughtful way to say “I answered part of your question.”

And res.ok is true for the entire 200 to 299 range.

So Guild Wars 2 is the best-behaved API in this whole post, and the wrapper deletes its signal anyway. This is not an API that failed to tell you. This is an API that told you clearly, in the one channel the wrapper is designed to read, and the wrapper collapsed the channel to a boolean. If you batch fifty item ids and eight are stale, you get forty-two items, res.ok === true, and no way to know from the response object which is which.

Which reframes the whole thing for me. I started this post thinking the problem was APIs with sloppy conventions. Half of it is that, and half of it is that ok is a one-bit summary of a three-digit answer.

Why seven APIs answer 200 for nothing

The seven that return 200 are not all doing the same thing, which is what makes a shared wrapper impossible. They fall into three groups, and the groups need different code.

Group one signals the failure explicitly, in a field. Steam returns {"999999999":{"success":false}}. There is no data key at all. OpenTDB returns {"response_code":1,"results":[]}, where 1 means “no results” and 0 means success, so the field is a status code that happens to live in the body and inverts the usual truthiness. Read response_code as a boolean and you have it exactly backwards.

Group two returns an empty array, at four different paths. MTG.io puts it at cards. PokemonTCG and Speedrun.com both use data, but PokemonTCG adds count and totalCount while Speedrun.com hides the size in pagination.size. CheapShark returns a bare [] at the top level, with no envelope at all.

Four APIs, four places to look, one meaning. There is no if (!data.results.length) that covers them.

Group three is mcsrvstat, and it deserves its own section.

The one that hands you your own loopback

I asked mcsrvstat about this-server-does-not-exist-zzz9.example, a hostname with a reserved TLD that cannot resolve. It returned HTTP 200 and this. The whole body, all 707 bytes, line-wrapped but with nothing removed: an article about truncated responses does not get to quote a truncated response.

{"ip":"127.0.0.1","port":25565,
 "debug":{"ping":false,"query":false,"bedrock":false,"srv":false,
   "querymismatch":false,"ipinsrv":false,"cnameinsrv":false,"animatedmotd":false,
   "cachehit":false,"cachetime":1784418481,"cacheexpire":1784418781,"apiversion":3,
   "dns":{"error":{
     "srv":{"hostname":"_minecraft._tcp.this-server-does-not-exist-zzz9.example",
            "message":"DNS request failed: The domain name referenced in the query does not exist."},
     "aaaa":{"hostname":"this-server-does-not-exist-zzz9.example",
             "message":"DNS request failed: The domain name referenced in the query does not exist."}}},
   "error":{"ip":"DNS lookup failed, no IP detected."}},
 "hostname":"this-server-does-not-exist-zzz9.example",
 "online":false}

Read that carefully, because my first draft of this section was wrong. I went in expecting to report a plausible-looking record with no failure signal anywhere. That is not what is there. online: false sits right at the end of the body, unambiguous, exactly where a careful reader would want it. mcsrvstat is more honest than I was about to give it credit for, and I only found that out because the field I needed was 517 bytes past where an earlier truncated capture had stopped.

The real trap is narrower and, I think, more interesting. The ip field is populated with a lie rather than left null. A server that does not exist has no address, and the correct answer is null. What you get is 127.0.0.1, which is a valid, well-formed IPv4 address, which is your own machine, and which sails through any null check and any well-formedness regex. A format check cannot catch this, because there is nothing malformed about it. What catches it is a check about meaning rather than shape: ipaddress.ip_address(x).is_loopback in Python, or anything that rejects reserved and private ranges, will flag it immediately.

So the API tells the truth at the top level and fabricates at the field level. If your code reads .ip without first reading .online, and reading .ip is the entire reason you called a server-status API, you now have your own loopback recorded as somebody else’s Minecraft server.

For the record, a normal call is fine: mc.hypixel.net came back with ip: "172.65.197.160", online: true, and about 33,100 players. That last number moves, obviously. When I re-ran the same call a few hours later it was 30,515, which is the point: a real field tracks a real thing.

The 404 that breaks before your error branch

PokeAPI and dnd5eapi both return a clean 404 for a name that does not exist. Their bodies are nine bytes long and look like this:

PokeAPI   http=404  content-type: text/plain; charset=utf-8  body=[Not Found]
dnd5eapi  http=404  content-type: text/plain; charset=utf-8  body=[Not Found]
Scryfall  http=404  content-type: application/json; charset=utf-8  body=[{ "object": "error", "code": "not_found" ...
Open5e    http=404  content-type: application/json  body=[{"detail":"No Monster matches the given query."}]

text/plain. Not JSON. The header says so honestly, and almost nobody reads it.

This one is conditional on how you write your client, so let me be precise rather than dramatic. Python’s urllib raises HTTPError on a 404 before your parse ever runs, so my script above caught it cleanly. The body is not gone, incidentally: HTTPError is itself a response object, and e.read() hands you those nine bytes if you want them. But the two most common shapes in the wild parse first:

const data = await res.json();   // JS fetch does NOT throw on 404
if (!data) { /* handle not found */ }
data = requests.get(url).json()  # requests does not raise on 404 either

Both of those hit Not Found and throw a JSON parse error. Your “not found” branch, the one sitting two lines below, never runs. The exception that surfaces is a SyntaxError or a JSONDecodeError, which reads like a corrupted response or a broken parser, not like a missing Pokemon. You will go looking in the wrong place. I have burned an afternoon on exactly this shape of bug, on a different API, and the tell is that the traceback points at your parser instead of at your logic.

The one that disagrees with itself

FreeToGame, asked for a platform that does not exist, returns HTTP 404 on the outside and this on the inside:

{"status":0,"status_message":"No platform found, please check the correct parameters."}

Two status fields in one response. The HTTP layer says 404. The body says 0. Neither 0 nor 404 is a value you would guess means the same thing, and the response does not tell you which one to trust. The status_message is genuinely helpful, which somehow makes it worse: it is a well-designed error body attached to a status number that contradicts its own envelope.

This is not hypothetical, and I have the receipt

On the same day I ran these probes, we found this exact defect in our own code.

A wrapper around a Bluesky search call caught every exception and returned an empty list on failure. The API started answering 403. The wrapper swallowed it and returned []. Every layer above read that as “there are no relevant threads right now” and reported a quiet, healthy, entirely fictional zero.

The tool was dead. It reported its death as an empty market. Nobody noticed for a while, because “no results” is a perfectly ordinary thing for a search to say, and nothing in the output distinguished it from “I never got to look.”

That is the same failure as the seven APIs above, written by us, in our own repo, on the consumer side instead of the provider side. And then, as described at the top, my probe script did it a third time with a reused temp file. Three instances in one day, from three different directions.

The pattern underneath all three: a check that cannot report its own failure is indistinguishable from a check that passed. Empty is not a result. Empty is a question.

What to do instead

I do not have a tidy abstraction to sell you, because the whole point is that the conventions do not reconcile. What I have is three rules that survived contact with these sixteen.

Never let “empty” and “failed” share a return value. If your function can return [] for both “the API said no matches” and “the call blew up”, those two states will be confused, in production, by you, at a bad hour. Return a result object, raise, or return a sentinel. Not the same empty list.

Assert on presence, not on truthiness. response_code: 1 is truthy and means failure. online: false is falsy and is the honest field. ip: "127.0.0.1" is a non-empty string and is a lie. Truthiness carries no information here.

Write the per-API “is this empty?” predicate once, explicitly, and test it against a deliberately impossible request. Not always an id: for OpenTDB that is a filter combination nothing matches, for CheapShark a title search that hits nothing, for mcsrvstat a hostname that cannot resolve. Sixteen APIs need something close to sixteen predicates. That is annoying and it is also the actual shape of the problem.

# runnable, read-only: one explicit emptiness predicate per API.
# Deliberately NOT generic. The whole finding is that a generic one cannot exist.
# Each returns True for "empty", False for "has results", and RAISES for
# "something else happened" - because empty and failed must not share a value.

class Unexpected(Exception): pass

def opentdb_empty(d):
    rc = d["response_code"]        # KeyError if the schema moved: that is the point
    if rc == 0: return False       # success
    if rc == 1: return True        # no results
    raise Unexpected(f"opentdb response_code={rc}")   # 2 param, 3/4 token, 5 rate limit

def steam_empty(d, appid):
    entry = d[str(appid)]          # ask for the id you requested, not values()[0]
    return entry["success"] is not True

def speedrun_empty(d):
    return d["pagination"]["size"] == 0     # the count lives here, not in len(data)

def mcsrvstat_empty(d):
    if "online" not in d: raise Unexpected("mcsrvstat: no online field")
    return d["online"] is False             # is False, not falsy. NOT d.get("ip")

EMPTY = {
    "mtgio":      lambda d: len(d["cards"]) == 0,
    "pokemontcg": lambda d: d["totalCount"] == 0,
    "cheapshark": lambda d: len(d) == 0,     # bare list, no envelope to inspect
}

Seven rules, and note how much longer they got once each one had to distinguish “empty” from “broken.” That length is the finding. The four-character version of each of these is what res.ok is, and it is wrong in exactly the way the short version is always wrong.

What I could not verify

Jikan’s empty-result shape is still unmeasured. It returned 504 with "Jikan failed to connect to MyAnimeList" on both attempts, roughly two hours apart, while a normal query for naruto returned 200 with real data on the same runs. Both attempts landed the same way, which on two data points I cannot separate from timing on MyAnimeList’s side. I do not have an explanation I trust, and I am not going to invent one. Jikan is on the list of sixteen because it answers 200 to a real request. It is not in the seven, and it is not in the eight.

I measured no rate limits. Several of these document them, Scryfall and Jikan explicitly, and I did not test those documented limits against reality. Every number here comes from single requests. If you are going to hit any of these in a loop, that is your homework, not mine, and several of these projects are volunteer-run and will notice.

All sixteen were 200 today, from one host, on one network. Two of my runs had SSL handshake timeouts that had nothing to do with the APIs. If you reproduce this and one endpoint disagrees with me, the most likely explanation is your network or their afternoon, not a discovery.

And one flake I am reporting because it is embarrassingly on topic. On one pass Scryfall returned me HTTP 200 with a body of zero bytes. Not a 404, not a timeout, not a connection reset. A success status wrapped around nothing at all, from the API I spend this post calling the best-behaved of the sixteen. A retry seconds later returned a normal 4,300-byte card. I do not know what happened, I could not reproduce it, and I am not going to build an argument on a single unreproduced event. But note what the naive wrapper does with it: res.ok is true, res.json() throws on an empty string, and the exception you get says nothing about a network problem. That is a sixth way to say nothing, and it arrived by accident, from the API I was holding up as the example.

And the honest scope note. I run web scrapers in production, 2,190 runs across 32 published actors, 962 of them on a single Trustpilot scraper. I have not run a game-data pipeline in production, and nothing above rests on those runs. They are only where the reflex comes from, the habit of asking what a quiet success actually proves. The evidence in this post is the stdout, and you can regenerate all of it.


Here is the question I actually want answered, because I do not have a clean method. How do you write a health check for a data source that can tell “this returned nothing” apart from “this failed to look”? Every version I write either treats a genuine empty result as an outage and pages me for nothing, or treats a real outage as an empty result and stays silent, which is how our Bluesky wrapper reported a 403 as a calm zero for longer than I would like to admit.

What has worked for you? 👇

Every endpoint above was verified with a live curl on July 19, 2026, from my own host, with no API key. Earlier in this series: free sports and score APIs and a score field that vanishes, free dictionary and word APIs, 15 keyless transit APIs and a timestamp their docs get wrong. Drafted with an AI assistant, then fact-checked and edited by me against the raw responses.


More production scraping tips: t.me/scraping_ai