Your Bot Asked 5 Keyless Crypto APIs for UNI. None Said Which UNI.
I sent the three-character string UNI down the natural lookup path of five keyless crypto APIs. Here is what came back, July 19, 2026, inside the same minute.
CoinGecko status=200 PASS usd=3.023e-05 identifies which UNI: NO
Binance status=200 PASS usd=3.495 identifies which UNI: NO
Kraken status=200 PASS usd=3.4942 identifies which UNI: NO
Bybit status=200 PASS usd=3.495 identifies which UNI: NO
Gate.io status=200 PASS usd=3.494 identifies which UNI: NO
Four of them agree to a tenth of a cent. The fifth is off by a factor of about 115,600, and it is not broken, rate-limited or lying. CoinGecko was asked for a coin whose id is uni, and it returned the price of the coin whose id is uni. That coin is named “Uni”. It is not Uniswap, whose id is uniswap and whose symbol is also uni.
But look at the last column, because that is the part that stayed with me. Not one of the five bodies contains a contract address, a coin name, or any field at all that distinguishes one UNI from another. Four of them just echo back the string I sent. Every one of these five answers is unverifiable from its own response. The four that happened to be right were right because of a listing policy I never saw, not because they told me anything.
The one idea in this post
A free crypto price API returns spot prices, tickers or chain data with no API key, no signup, no card. Twelve clear that bar and I verified each with a live curl on July 19, 2026. The list is one screen down.
Under it is the part I care about. The failure modes I have written about all month are loud in their own way: an empty body, a 404, a key that vanishes from the payload. This one is quiet. The API answers confidently, validly, and about a different asset than the one you asked for.
There is no signal to catch. The status is honest. The schema is honest. The number is a real price of a real thing. It is just not the price of your thing. Your loop finished successfully and now holds a number it will happily write to a database, feed to a model, or put in front of a user.
And the sharper version of that, which is what the last column above is really saying: it is not only that one of the five was wrong. It is that none of the five gave me any way to tell. The four correct answers and the one incorrect answer are indistinguishable from inside the response. Correct here was luck plus somebody else’s listing policy, and luck is not a property you can write a test against.
The scale of it, measured
GET https://api.coingecko.com/api/v3/coins/list returned HTTP 200, 1,081,911 bytes in 2.4 seconds. No key. Counting what came back:
coins listed 17,660
distinct symbols 13,710
symbols carried by more than one coin 2,116 (15.4% of symbols)
coins living under a shared symbol 6,066 (34.3% of coins)
About a third of every coin CoinGecko lists shares its ticker with at least one other coin. That is not an exotic edge case you can push to the backlog. That is the base rate.
The seven most collided tickers, and then three majors I added on purpose because they are the strings people hardcode. Be clear on what the last three are: eth, sol and btc are not in the top ten by count. They rank 12th, 14th and 20th, behind things like meow and moon at 15 apiece.
| ticker | distinct coins carrying it |
|---|---|
weth | 63 |
usdc | 59 |
usdt | 48 |
wbtc | 36 |
dai | 28 |
usdc.e | 20 |
pepe | 18 |
eth | 13 |
sol | 12 |
btc | 11 |
Eleven different coins answer to btc. Fifty-nine answer to usdc, which is the one that should worry you, because usdc is the ticker people hardcode into accounting scripts without a second thought.
I picked uni for the demo not because it is the worst, it is nowhere near, but because five is small enough to print in full and the price spread inside those five is absurd.
The twelve, and what each one is for
All twelve returned HTTP 200 on a normal request on July 19, 2026, from my own host, with no key and no Authorization header.
| # | API | What you get | Doc |
|---|---|---|---|
| 1 | CoinGecko | Prices for 17,660 listed coins, full coin list, market data | docs |
| 2 | CoinPaprika | Coin search, tickers, rank, contract addresses | docs |
| 3 | CoinLore | Ranked ticker list, paginated 100 at a time | docs |
| 4 | Binance | Spot ticker prices for exchange-listed pairs | docs |
| 5 | Kraken | Bid, ask, last, volume, VWAP per pair | docs |
| 6 | DeFiLlama | TVL per protocol, chain-level aggregates | docs |
| 7 | DexScreener | On-chain pair search across every major chain | docs |
| 8 | Bybit | Spot and derivatives tickers | docs |
| 9 | Gate.io | Spot tickers per currency pair | docs |
| 10 | Blockchain.info | BTC price in 25+ fiat currencies | endpoint |
| 11 | mempool.space | BTC price in 7 fiat currencies, plus mempool and fee data | docs |
| 12 | Blockstream Esplora | Bitcoin chain data: tip height, blocks, addresses, txs | docs |
Three of these are worth flagging for shape, not for content. DeFiLlama returns a bare scalar: GET /tvl/uniswap gives you 3166874818.296723 with content-type: application/json and no envelope at all. Blockstream returns 958771 as text/plain, six bytes, no JSON anywhere. Kraken carries application-level failures in the body at HTTP 200, as {"error":[...]}. Note what is missing there: on an error response the result key is absent entirely, not empty, so code that reaches for result first gets a KeyError rather than a clean miss. I am saying application-level on purpose, since Kraken still returns real 4xx and 5xx for rate limits and outages.
The artifact: run this yourself
No dependencies, no key. This is the script that produced the output at the top.
It opens with its own negative control, and I want to be blunt about why. An earlier version of this script wrapped everything in a plain with urllib.request.urlopen(...) and then checked status == 200. That check could never be False. urlopen raises HTTPError on any non-2xx, so inside the with block the status is 200 by construction. I had written assert True, dressed it up as a green signal, and printed it five times. The fix is the except clause, and the negative control is there to prove the fix took.
import json, urllib.request, urllib.error
def fetch(url):
"""Returns (status, parsed_or_None). status 0 means the request never landed.
Never raises: urlopen throws HTTPError on any non-2xx, so without this
an except-less version can only ever observe status 200."""
req = urllib.request.Request(url, headers={"User-Agent": "curl/8.7.1"})
try:
with urllib.request.urlopen(req, timeout=30) as r:
return r.status, json.load(r)
except urllib.error.HTTPError as e:
try: return e.code, json.load(e)
except Exception: return e.code, None
except Exception:
return 0, None
def price_of(body):
"""Pull the USD price out of whatever shape came back. Returns float or None.
Half the providers below send the price as a JSON string, so float() is not optional."""
try:
if body is None: return None
if isinstance(body, (int, float)): return float(body)
if isinstance(body, list): return float(body[0]["last"])
if "price" in body: return float(body["price"])
if "result" in body and isinstance(body["result"], dict):
r = body["result"]
if "list" in r: return float(r["list"][0]["lastPrice"])
return float(next(iter(r.values()))["c"][0])
key = next(iter(body))
return float(body[key]["usd"])
except Exception:
return None
TICKER = "UNI"
PROVIDERS = [
("CoinGecko", f"https://api.coingecko.com/api/v3/simple/price?ids={TICKER.lower()}&vs_currencies=usd"),
("Binance", f"https://api.binance.com/api/v3/ticker/price?symbol={TICKER}USDT"),
("Kraken", f"https://api.kraken.com/0/public/Ticker?pair={TICKER}USD"),
("Bybit", f"https://api.bybit.com/v5/market/tickers?category=spot&symbol={TICKER}USDT"),
("Gate.io", f"https://api.gateio.ws/api/v4/spot/tickers?currency_pair={TICKER}_USDT"),
]
print("negative control -- the same probe, aimed at things that must not return a price")
for label, url in [("host that does not exist", "https://api-zz991-not-a-host.coingecko.com/api/v3/ping"),
("symbol that cannot exist", "https://api.binance.com/api/v3/ticker/price?symbol=ZZQQNOTACOIN991USDT"),
("ticker as a CoinGecko id", "https://api.coingecko.com/api/v3/simple/price?ids=usdc&vs_currencies=usd")]:
s, b = fetch(url)
print(f" {label:26} status={s:<4} price={price_of(b)}")
print(f"\nasking {len(PROVIDERS)} keyless APIs for the ticker {TICKER!r}")
for name, url in PROVIDERS:
status, body = fetch(url)
usd = price_of(body)
if status == 0: verdict, why = "FAIL", "never connected"
elif status != 200: verdict, why = "FAIL", f"http {status}"
elif not body: verdict, why = "FAIL", "empty body, no such asset here"
elif usd is None: verdict, why = "FAIL", "no price field I can read"
else: verdict, why = "PASS", f"usd={usd:<12.8g}"
ident = "0x" in json.dumps(body) if body else False
print(f" {name:10} status={status:<4} {verdict} {why:<32} identifies which {TICKER}: {'yes' if ident else 'NO'}")
Runnable, not a toy. Its real stdout, July 19, 2026 at 20:22 UTC:
negative control -- the same probe, aimed at things that must not return a price
host that does not exist status=0 price=None
symbol that cannot exist status=400 price=None
ticker as a CoinGecko id status=200 price=None
asking 5 keyless APIs for the ticker 'UNI'
CoinGecko status=200 PASS usd=3.023e-05 identifies which UNI: NO
Binance status=200 PASS usd=3.495 identifies which UNI: NO
Kraken status=200 PASS usd=3.4942 identifies which UNI: NO
Bybit status=200 PASS usd=3.495 identifies which UNI: NO
Gate.io status=200 PASS usd=3.494 identifies which UNI: NO
Three negative-control lines, three different ways to not get a price: a status of 0 where the connection never happened, a real 400 from Binance, and a 200 that is empty. That last one is the sneaky one and it is worth a second look, because usdc is not an exotic string. Fifty-nine coins carry the symbol usdc, and no coin on CoinGecko has the id usdc, so the naive lowercase-the-ticker move returns HTTP 200 with a two-byte body. Same code path, same green status, no price.
That is what a check looks like when it can actually fail. Compare it to the five PASS rows underneath: those are green because the probe is capable of red, not because red was unreachable.
Now change TICKER to WETH and run it again. This is the interesting one:
CoinGecko status=200 PASS usd=1865.53 identifies which WETH: NO
Binance status=400 FAIL http 400
Kraken status=200 FAIL no price field I can read
Bybit status=200 FAIL no price field I can read
Gate.io status=400 FAIL http 400
weth is the most collided ticker in the whole dataset, 63 coins deep, and it is the one where four of five providers refuse to answer. uni, with five collisions, is where all five answer confidently. The collision count does not predict your risk at all. What predicts it is whether the venue lists the pair, and none of the four exchanges list a WETH/USDT spot pair, so they decline. Your exposure comes from a listing policy you cannot see, not from how crowded the ticker is.
Note also that Kraken and Bybit return 200 here while having nothing to say, and price_of catches them only because it fails to find a price field, not because the status warned it.
Prices move while you read, which is its own small lesson: uniswap came back at 3.49 at 19:30 UTC and 3.48 at 19:33 UTC in two runs three minutes apart. The ratio does not move much though. 3.87 / 3.01e-05 is about 128,571, and uniswap / uni is about 115,947.
The one that surprised me: partial success reads as success
This is the finding I did not go looking for, and it is the one I would fix first in my own code.
Ask CoinGecko for one real coin plus one that cannot exist:
GET /api/v3/simple/price?ids=uniswap,ZZQQNOTACOIN991&vs_currencies=usd
HTTP 200
{"uniswap":{"usd":3.49}}
The bad id is silently dropped. Not null, not an error key, not a warning field. It is simply absent, and the response is a well-formed 200 that looks exactly like a complete answer. If you request forty tickers and eleven of them resolve to nothing, you get twenty-nine rows and a green status, and the only way to know is to compare the keys you asked for against the keys you got back. Which almost nobody does, because why would you, the call succeeded.
Ask for the bad id alone and you get HTTP 200 with a two-byte body: {}.
Different providers resolve “UNI” differently
This is where a fallback chain quietly turns into a coin flip.
CoinPaprika, GET /v1/search/?q=UNI&c=currencies, HTTP 200:
1. id=uni-uniswap name=Uniswap symbol=UNI rank=44
2. id=ub-unibase name=Unibase symbol=UB rank=154
3. id=uusd-unity-usd name=Unity USD symbol=UUSD rank=236
4. id=uai-unifai-network name=UnifAI Network symbol=UAI rank=263
Result 1 is correct and CoinPaprika ranks it first, which is good behaviour. But results 2, 3 and 4 have symbols that are not UNI at all. It is substring-matching the name. Any code that takes the first result is fine here and will break the moment ranking shifts; any code that takes result [1] as a fallback when [0] looks stale is reading a stablecoin as a governance token.
DexScreener, GET /latest/dex/search?q=UNI, HTTP 200, 30 pairs. Counting distinct base-token contract addresses whose symbol is exactly UNI:
distinct base tokens in the response: 14
distinct base tokens with symbol UNI: 14
All fourteen. Across arbitrum, solana, polygon, bsc, base, optimism, cronos, ethereum and robinhood. Named Uniswap, Uniswap (PoS), Uniswap (Wormhole), UNI, and UNI Token. Only one of those fourteen addresses, 0x1f9840a85d... on ethereum, is the token most people mean.
Note that this is a bigger number than the five CoinGecko gave me, and it is measuring a different thing. CoinGecko lists coins; DexScreener lists on-chain pairs, so bridged and wrapped deployments each get their own address. Both counts are correct. They just answer different questions, which is exactly the problem when you treat two providers as interchangeable.
Half of them send the price as a string
This one is not subtle and I nearly shipped a predicate that gets it wrong. I pulled the USD price from every endpoint here that has one and checked the Python type that came out of json.loads:
CoinGecko 3.5 float
Binance "3.49500000" str
Kraken "3.49320" str
Bybit "3.493" str
Gate.io "3.494" str
DeFiLlama 3164926201.6366673 float
mempool 64461 int
Blockchain 64430.91 float
Four of the eight arrive as JSON strings, and the split is not random: all four exchanges quote the price as a string, all four aggregators and chain sources quote it as a number. That is a defensible choice on the exchange side, since a string survives a round trip through a float without losing a digit, and "3.49500000" carries its own precision. It is still a problem for you.
Because the obvious schema check, the one I would have written and very nearly did, is isinstance(usd, (int, float)). Run that across this list and it returns False for Binance, Kraken, Bybit and Gate.io. Not because the price is missing or wrong, but because it is quoted. A validity check that rejects half your providers is worse than no check at all, because it fails in exactly the places where the data is fine and stays quiet in the places where it is not.
So price_of above calls float() on whatever it finds and catches the exception. Coerce, then validate the number. And keep bool out of it, since isinstance(True, int) is True in Python and a stray boolean will sail straight through a numeric check.
Keyless does not mean clientless
A thing I learned by tripping over it mid-measurement. Every endpoint here was verified with curl. When I re-ran the same URLs from Python’s urllib with its default headers, CoinPaprika started answering:
HTTP 403 error code: 1010
Same URL, same host, same second, no key involved in either. Setting one header, User-Agent: curl/8.7.1, brought it straight back to HTTP 200. The 403 is a Cloudflare rule on the client string, not a rate limit and not a key requirement, and error code: 1010 is not documented anywhere I could find in CoinPaprika’s own docs.
Worth internalising if you are porting a shell prototype into a service: “works with curl” and “works from your HTTP client” are two different claims, and the gap between them shows up as a 403 that reads like an auth problem and is not one. I did not test the other eleven for User-Agent sensitivity, so I do not know how many share the behaviour.
Who handles the impossible coin well
I asked all twelve for ZZQQNOTACOIN991. Nine of them accept a symbol or id argument, so nine got a real test:
| API | response to a coin that cannot exist |
|---|---|
| Binance | 400 {"code":-1121,"msg":"Invalid symbol."} |
| Gate.io | 400 {"label":"INVALID_CURRENCY","message":"Invalid currency ZZQQNOTACOIN991"} |
| DeFiLlama | 400 Protocol not found (plain text, empty content-type) |
| CoinGecko | 200 {} |
| CoinPaprika | 200 {"currencies":[]} |
| DexScreener | 200 {"schemaVersion":"1.0.0","pairs":[]} |
| Kraken | 200 {"error":["EQuery:Unknown asset pair"]} |
| Bybit | 200 {"retCode":10001,"retMsg":"Not supported symbols",...} |
| CoinLore | 200, content-type: text/html, zero bytes |
Six of nine answer HTTP 200 to a coin that does not exist. Two of those six need a caveat, and I would rather make it myself than have it made for me in the comments: CoinPaprika and DexScreener were queried through search endpoints, and a search that matches nothing is supposed to return 200 with an empty list. That is correct REST behaviour, not a flaw, and lumping them in inflates the number. Take them out and the count that actually bothers me is four of seven: a lookup by identifier, answering 200 for an identifier that does not exist.
Three returned a 4xx: Binance, Gate.io and DeFiLlama. Binance and Gate.io are the two I would hold up as correct, and the criterion is narrower than just the status code: a real 4xx plus a machine-readable code plus a human-readable message, all inside JSON. DeFiLlama gets the status right and the rest wrong, answering Protocol not found as plain text with an empty content-type, so a JSON client still has to special-case it.
Kraken and Bybit are the interesting middle. Both return 200 and both tell you plainly what went wrong, just in the body rather than the envelope. That is a defensible design, and it is fine as long as you actually read error and retCode. The trap is not the design. It is that these two disagree with Binance about where the truth lives, so no single wrapper handles all three.
CoinLore’s zero-byte text/html at 200 is the worst of the nine. A JSON parser throws, the exception says nothing about a missing coin, and your error handler files it under “network problem.”
The remaining three, Blockchain.info, mempool.space and Blockstream, take no symbol argument at all: they serve BTC price or chain state and nothing else. I probed them with a bad path instead, and got 404 from all three, so I have no comparable data point for them and I am not going to pretend otherwise.
Where CoinLore is right
Credit where it is due. CoinLore’s ranked list, walked ten pages deep to rank 1,000, contains exactly one entry with the symbol UNI:
id=47305 name=Uniswap price_usd=3.49 rank=30
One ticker, one coin, no ambiguity. That is not because CoinLore solved identifier collision. My reading is that depth is doing the work: at rank 1,000 the long tail of wrappers and copycats has not appeared yet. I should be honest that I did not isolate the cause. I walked ten pages and stopped, so a listing policy, a symbol normalisation step, or simply more pages than I paginated could each explain it as well. The cause is arguable. The property is not, and the property is the useful part. If your product only cares about the top 1,000 assets, a provider that only knows the top 1,000 assets cannot hand you a coin from the tail. A smaller universe is a feature when precision matters more than coverage, and I do not see that tradeoff written down often enough.
Why three well-known ones are not on the list
I tested these and cut them, because “free and keyless” has a short shelf life.
exchangerate.host returns HTTP 200 with {"success":false,"error":{"code":101,"type":"missing_access_key"}}. It used to be keyless. It is not anymore, and it reports that fact at 200, which means an old script of yours may have been storing failure objects as rates for a while without a single alarm firing.
CryptoCompare returns HTTP 401 with a body containing "http_status_code":401 and a pointer to the docs. Honest, at least, and no longer keyless.
CoinCap returned 000 from my host, connection never established. I could not test it, so I am not ranking it either way.
What I could not verify
I measured no rate limits. Every number here comes from single requests, plus ten paginated calls to CoinLore. CoinGecko’s free tier documents limits I did not test, and I ran this from a residential connection, so a datacenter IP may see something entirely different. If you are going to poll any of these in a loop, that is your homework.
Coinbase, Bitstamp and OKX all returned 000 from my host. No TLS handshake, no response, nothing to measure. That is a statement about my network path to them, not about their service, and I am not listing them as dead or as alive. Three separate providers failing identically points at geography or a datacenter filter rather than at three simultaneous outages.
The collision counts will drift. New tokens get listed daily and every wrapper deployment adds another. usdt was 48 when I counted it at 19:29 UTC on July 19. I would expect the direction of travel to be up, but I am not going to promise you the exact number reproduces.
I did not verify which UNI is “correct.” I claim 0x1f9840a85d... on ethereum is the one most people mean, and that rests on rank and liquidity, not on a definition. There is no authority to appeal to here. That is sort of the whole point.
And the scope note, plainly. 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 crypto data pipeline in production, and none of the measurements above depend on those runs. They are only where the reflex comes from: after enough green runs that shipped garbage, you stop trusting a success status to mean the thing you wanted happened.
One habit that did transfer, and it is not clever. Assert on the identifier you asked for, not just on the value you got back. Compare requested keys against returned keys and fail loudly on the difference. It takes four lines and it catches both the silently-dropped id and, if you resolve by contract address instead of by ticker, the wrong-coin case as well.
The second habit is newer and I learned it writing this post: make sure the check you just wrote is capable of returning False. The first version of my own script checked status == 200 in a place where it could not be anything else, and I did not notice until I aimed the same probe at a host that does not exist. If a predicate has never once gone red in your logs, that is not necessarily good news about your data.
Here is the question I do not have a clean answer to. When your only input is a user-typed ticker, what do you do with the ambiguity? Resolving by contract address is correct and useless, because the person typing UNI into your box does not have one. Ranking by market cap picks right almost always, which is worse than picking right always, because the failures are rare enough that nobody builds a check for them. Asking the user to disambiguate between fourteen tokens named some variant of Uniswap is not a product anyone wants.
I have shipped the market-cap heuristic and I am not comfortable with it. What do you do? 👇
Every endpoint above was verified with a live curl on July 19, 2026, from my own host, with no API key. Raw responses and counting scripts are what the numbers come from, not from a provider’s marketing page. Earlier in this series: 16 keyless game APIs and 5 incompatible ways to say empty, free sports and score APIs and a score field that vanishes, free dictionary and word APIs. Drafted with an AI assistant, then fact-checked and edited by me against the raw responses.