How do I build a trading card scanner app?
Direct answer
A trading card scanner app takes a photo, sends it to a card recognition API and shows the cards that come back. With the NeoSatoshi Detection API that is one POST request: a base64 image or an image link goes in, and JSON with name, set, number, Cardmarket product ID and daily price per card comes out, for Pokémon, One Piece, Disney Lorcana, Yu-Gi-Oh! and Magic. The Python script below runs as is with the public demo key (10 detections per day, no signup); for production you create your own key on any paid plan.
What you need
- Python 3 and the requests library (
pip install requests). - A photo of one or more cards, JPEG or PNG, up to 12 MB. Several cards per photo work for Pokémon; send one card per photo for the other games.
- An API key: the public demo key in the script below, or later your own key in the environment variable
NEOSATOSHI_API_KEY.
Step 1: scan one photo
Save this as scan.py. It reads the photo, sends it as base64 and prints every card with its set, number, Cardmarket product ID, 30-day average price and confidence.
Python: scan.py
import base64
import os
import sys
import requests
API_URL = "https://europe-west1-neosatoshi.cloudfunctions.net/detectApi"
# Public demo key: 10 detections per day per network, no signup.
# Set NEOSATOSHI_API_KEY to use your own key instead.
API_KEY = os.environ.get(
"NEOSATOSHI_API_KEY",
"nsk_live_9c253fbed995042a98280ad13e5c47d3c4a59c9b4f0f7274",
)
def detect(path, tcg="pokemon"):
"""Send one photo and return the parsed JSON response."""
with open(path, "rb") as f:
image = base64.b64encode(f.read()).decode("ascii")
res = requests.post(
API_URL,
headers={"Authorization": f"Bearer {API_KEY}"},
json={"image": image, "tcg": tcg},
timeout=120, # synchronous: the cards come back in this response
)
if res.status_code == 422:
return {"count": 0, "cards": []} # no card found, nothing charged
res.raise_for_status()
return res.json()
if __name__ == "__main__":
tcg = sys.argv[2] if len(sys.argv) > 2 else "pokemon"
data = detect(sys.argv[1], tcg)
for card in data["cards"]:
card_set = card.get("set") or {}
cm = (card.get("prices") or {}).get("cardmarket") or {}
print(
card.get("name"),
"|", card_set.get("name"), card_set.get("number"),
"| Cardmarket ID", card.get("cardmarket_id"),
"| avg30", cm.get("avg30"), cm.get("currency", ""),
"| confidence", card.get("confidence"),
)
if "demo" in data:
print("Demo detections left today:", data["demo"]["remaining_today"])Run python scan.py card.jpg for Pokémon, or add the game: python scan.py card.jpg lorcana (also magic, yugioh and onepiece). With the demo key the last line shows how many detections are left today.
Step 2: send a link instead of a file
If the photo is already online, send image_url instead of image and skip the base64 step. This also lets a chat assistant without code execution test the API. In curl:
curl
curl -X POST https://europe-west1-neosatoshi.cloudfunctions.net/detectApi \
-H "Authorization: Bearer nsk_live_9c253fbed995042a98280ad13e5c47d3c4a59c9b4f0f7274" \
-H "Content-Type: application/json" \
-d '{"image_url": "https://neosatoshi.com/static/images/example_api_lorcana.jpg", "tcg": "lorcana"}'The same in Python, reusing the key from scan.py:
Python
import requests
from scan import API_KEY, API_URL
res = requests.post(
API_URL,
headers={"Authorization": f"Bearer {API_KEY}"},
json={
"image_url": "https://neosatoshi.com/static/images/example_api_lorcana.jpg",
"tcg": "lorcana",
},
timeout=120,
)
res.raise_for_status()
for card in res.json()["cards"]:
print(card.get("full_name") or card.get("name"), card.get("cardmarket_id"))- The link must be public https on the default port, at most 2,048 characters, without a username or password in it.
- The file must be a JPEG or PNG (checked by its content, not its name) of at most 12 MB, and it must download within 15 seconds. Up to 3 redirects are followed.
- Signed links from your own storage work, as long as they are reachable from the internet when the request arrives.
- Send either
imageorimage_url, never both.
Step 3: scan a folder into a CSV
Save this next to scan.py as batch.py and run python batch.py photos/ (or python batch.py photos/ magic). It sends the photos one after another, retries temporary errors and writes one row per detected card to cards.csv.
Python: batch.py
import csv
import pathlib
import sys
import time
import requests
from scan import detect # the script from step 1, saved as scan.py
FIELDS = ["file", "name", "set", "number", "rarity", "language",
"cardmarket_id", "trend_eur", "avg30_eur", "confidence"]
def error_code(response):
try:
return response.json().get("error")
except ValueError:
return None
def detect_with_retry(path, tcg, attempts=3):
for attempt in range(attempts):
last = attempt == attempts - 1
try:
return detect(path, tcg)
except requests.Timeout:
if last:
raise
except requests.HTTPError as err:
status = err.response.status_code
if error_code(err.response) == "demo_limit_reached":
sys.exit("Demo limit reached for today. Use your own API key.")
if status == 402:
sys.exit("No card scans left on this key.")
if status not in (429, 500, 502, 504) or last:
raise
time.sleep(5 * 2 ** attempt) # wait 5 s, then 10 s
def main(folder, tcg="pokemon", out="cards.csv"):
photos = sorted(p for p in pathlib.Path(folder).iterdir()
if p.suffix.lower() in (".jpg", ".jpeg", ".png"))
with open(out, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=FIELDS)
writer.writeheader()
for path in photos:
data = detect_with_retry(path, tcg)
for card in data["cards"]:
card_set = card.get("set") or {}
cm = (card.get("prices") or {}).get("cardmarket") or {}
writer.writerow({
"file": path.name,
"name": card.get("name"),
"set": card_set.get("name"),
"number": card_set.get("number"),
"rarity": card.get("rarity"),
"language": card.get("language"),
"cardmarket_id": card.get("cardmarket_id"),
"trend_eur": cm.get("trend"),
"avg30_eur": cm.get("avg30"),
"confidence": card.get("confidence"),
})
print(f"{path.name}: {len(data['cards'])} card(s)")
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else "pokemon")The columns are a starting point. Add reverse_holo and first_edition for Pokémon, set.code and edition for Yu-Gi-Oh!, or prices.cardmarket_foil for Lorcana and Magic. The fields per game are in the documentation.
Timeouts
The API is synchronous: the cards come back in the response to the same request, there is nothing to poll. A warm request takes about 5 seconds. The first request after a quiet period (a cold start) takes 10 to 15 seconds. The server gives up after about 90 seconds and answers with 504, so set your client timeout to 120 seconds and not lower. Send the photos of a folder one after another or a few in parallel, not hundreds at once.
Error handling
- 400: the request is wrong, for example no image, not a JPEG or PNG, over 12 MB, both
imageandimage_url, or an unknowntcg. Fix the request; retrying will not help. - 401: the key is missing or wrong. Check the
Authorization: Bearerheader. - 402: no card scans left on your plan.
- 422 (
no_card_detected): no card was found. It is not charged. Show your user a hint about light and framing. With the demo key it still counts as one of the 10 daily requests. - 429: either
demo_limit_reached(the demo key's 10 requests per day for your network are used up; the count resets at midnight UTC) orrate_limited(too many requests per minute on your key; wait and retry with a growing pause). - 500 or 502: a temporary error. Retry after a short pause. With the demo key, a failure on our side gives the try back.
- 504 (
detection_timeout): detection took too long, often on a cold start. Retry once.
Error responses carry an error code and a message, so log both. Successful responses carry a request_id; include it when you contact support.
Step 4: show the result to your users
- Show the confidence, and mark cards below a threshold you choose as "please check".
- Offer
alternativesas a dropdown so a wrong first match is fixed with one click. - When a printing is open (for example
print_unconfirmedfor Magic orrarity_unconfirmedfor Yu-Gi-Oh!), ask your user instead of guessing. The API leaves the price empty in that case on purpose. - Do not show a condition grade: the API does not judge condition, so let your user set it.
- Keep the key on your server. Never put it into a mobile app or browser code, where it can be extracted. A phone app uploads the photo to your backend, and your backend calls the API.
Step 5: move to your own key
The demo key is for testing. For production, create your own key in the dashboard settings under API keys. Keys need a paid plan: Start-Up, the smallest paid plan, costs 19 per month (15 per month paid yearly, the same number in EUR, USD or CHF) and includes 1,000 card scans per month, shared with the NeoSatoshi app. One card scan is used per detected card, photos without a card cost nothing, and every response with your own key shows credits.remaining instead of demo. Current plans: pricing. Set the key with export NEOSATOSHI_API_KEY=... and the scripts above use it without any change.
More examples and the full schema
- Documentation with live examples, curl, JavaScript and Python snippets, every field per game and a test box.
- OpenAPI spec, to generate a client in your language.
Frequently asked questions
Can I call the API directly from a phone app?
It would work technically, but anyone could extract the key from the app. Send the photo to your own backend and call the API from there.
Which programming languages can I use?
Any language that can send an HTTPS POST with JSON. The documentation has curl, JavaScript and Python examples, and the OpenAPI spec generates clients for most others.
How much does one scan cost?
One card scan per detected card from your plan's monthly balance; a photo with three Pokémon cards uses three, a photo without a card uses none. See pricing for the scans included in each plan.
Which API should I pick for my app?
That depends on your games, languages and marketplace. Our dated comparison with Ximilar, CardSight, open-source models and vision LLMs: which card recognition API to use in 2026.
Further reading
Get the docs and the demo key
Everything from this tutorial with every field per game, plus a test box for your own photos. 10 free detections per day, no signup.
No account required for the first 10 detections.