Skip to content

NeoSatoshi

FeaturesPricingFAQAPIBlogLoginSign UpYouTubeJoin Discord

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 image or image_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 image and image_url, or an unknown tcg. Fix the request; retrying will not help.
  • 401: the key is missing or wrong. Check the Authorization: Bearer header.
  • 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) or rate_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 alternatives as a dropdown so a wrong first match is fixed with one click.
  • When a printing is open (for example print_unconfirmed for Magic or rarity_unconfirmed for 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.

Related questions

Which card recognition API should I use in 2026?

Is there an API that identifies Pokémon cards from a photo?

Is there a card recognition API for Magic, Lorcana, Yu-Gi-Oh! and One Piece?

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.

Try it free

No account required for the first 10 detections.

NeoSatoshi

AI card detection for sellers. Speed up your listing workflow. Save hours every week.

Use Cases

BlogFAQGuidesFree ToolsListing SoftwareeBay Listing ToolShopify Listing ToolCardmarket WorkflowCard ScannerSales AnalyticsOne Piece Listing SoftwareOne Piece eBay ToolOne Piece Card ScannerLorcana Listing SoftwareYu-Gi-Oh! Listing SoftwareMagic Listing Software

Developers

© 2026 NeoSatoshi

The literal and graphical information about the Pokémon Trading Card Game presented on this website, including card images and text, is copyrighted by The Pokémon Company (Pokémon), Nintendo, Game Freak, Creatures and/or pokemontcg.io. One Piece Card Game content is the property of Eiichiro Oda/Shueisha, Toei Animation and Bandai. Disney Lorcana content is the property of Disney and Ravensburger. Yu-Gi-Oh! content is the property of Studio Dice/Shueisha, TV Tokyo and Konami. Magic: The Gathering content is the property of Wizards of the Coast. This website is not produced, endorsed or supported by any of these rights holders.

Terms Of UsePrivacy PolicyContact
Loading...

Loading page...

Your privacy

We use essential cookies to run the site. With your permission we also use analytics to improve NeoSatoshi, and marketing to measure which of our ads work. That loads a script from OpenAI. You can choose each one separately under Settings.