Card scanner API
A Pokémon card scanner API: identify a card from a photo
Send a photograph, get the card back as a catalogue id with ranked candidates. The card is located in the frame and straightened first, so a hand-held phone photo works, and when two printings share the artwork the response says so instead of guessing.
- Request
POST /v1/vision/identify, multipart, oneimagefield- Formats
- JPEG or PNG, up to 10 MB
- Answer
match,ambiguousorno_match, with ranked candidates- Measured latency
- Server time on production traffic, 24–27 September 2026: 1,083 recognitions of app-sized photos (median upload about 0.4 MB), median 0.89 s, 95% within 2.2 s. Since 28 September the server reduces photos larger than 1,600 px on the long side before recognition: on 59 full-resolution 12-megapixel phone photos the median server time went from 2.7 s to 1.4 s. Sending photos already around 1,600 px is still faster, because the upload is smaller.
- Plan
- 5 verified trial calls; then Growth and above, 25 base credits a photo
A card scanner is two problems: recognising the artwork, and knowing which of the printings that share that artwork you are holding. General-purpose vision models are confident and wrong on the second one. They will say "Base Set Charizard" in front of a reprint and have no way to know.
This endpoint matches the photo against an index of the catalogue's own card images and returns a decision. match fills in id. ambiguous returns the candidates, reprints and Japanese twins included, and leaves id null on purpose: picking one would be wrong half the time, on exactly the cards that are worth the most. no_match with regions_detected: 0 means the card was not found in the frame, which is a framing problem and a different message to show your user.
If your flow knows the set, because the user is logging a booster they just opened, pass set (or region) as a hint. It is what breaks the tie between printings.
One call, and what comes back
One photo in, ranked candidates and composite value out with include=index. distance is the raw figure to tune your own threshold on; confidence is the same information rescaled.
curl -s -X POST "https://api.pokemontcgapi.com/v1/vision/identify" \
-H "X-Api-Key: $PTCG_API_KEY" \
-F "[email protected]" \
-F "top_k=3" \
-F "include=index"# What a mobile client does: the body IS the photo.
curl -s -X POST "https://api.pokemontcgapi.com/v1/vision/identify?top_k=1&set=sv3" \
-H "X-Api-Key: $PTCG_API_KEY" \
-H "Content-Type: image/jpeg" \
--data-binary @card.jpgconst form = new FormData();
form.set("image", file);
form.set("top_k", "3");
form.set("include", "index");
const res = await fetch("https://api.pokemontcgapi.com/v1/vision/identify", {
method: "POST",
headers: { "X-Api-Key": process.env.PTCG_API_KEY ?? "" },
body: form,
});
const { data } = await res.json();
// Never trust the top of the list without reading the decision.
if (data.decision === "match") {
await addToCollection(data.id);
} else {
await askTheUser(data.candidates);
}import os, httpx
def identify(path, set_hint=None):
with open(path, "rb") as fh:
res = httpx.post(
"https://api.pokemontcgapi.com/v1/vision/identify",
files={"image": fh},
data={"top_k": 3, "include": "index", **({"set": set_hint} if set_hint else {})},
headers={"X-Api-Key": os.environ["PTCG_API_KEY"]},
)
res.raise_for_status()
return res.json()["data"]
out = identify("binder-page-3.jpg", set_hint="sv3")
print(out["decision"], [c["id"] for c in out["candidates"]]){
"data": {
"decision": "ambiguous",
"decision_reason": "reprint",
"id": null,
"candidates": [
{
"id": "bs-4",
"name": "Charizard",
"number": "4",
"set": { "code": "bs", "name": "Base Set", "print_region": "WEST" },
"rarity": "Rare Holo",
"image_url": "https://img.pokemontcgapi.com/media/cards/bs/4-normal.webp?e=1791763200&s=Nw8cT5kYq1zRv3jLp0xGbH",
"index_eur": 350,
"last_price_at": "2026-08-27T00:00:00.000Z",
"distance": 31,
"confidence": 0.818
},
{
"id": "b2-4",
"name": "Charizard",
"number": "4",
"set": { "code": "b2", "name": "Base Set 2", "print_region": "WEST" },
"rarity": "Rare Holo",
"image_url": "https://img.pokemontcgapi.com/media/cards/b2/4-normal.webp?e=1791763200&s=Fy2mK9wPd4sXn7bQr1tVcZ",
"index_eur": 200,
"last_price_at": "2026-08-27T00:00:00.000Z",
"distance": 38,
"confidence": 0.776
}
]
},
"meta": {
"count": 2,
"cards_indexed": 51983,
"index_built_at": "2026-08-27T04:10:22Z",
"index_loaded_at": "2026-08-27T05:10:22Z",
"signature_version": 1,
"regions_detected": 2,
"hypotheses_tried": 16,
"ocr": { "applied": false, "number": 4, "ms": 210, "reason": "multiple" },
"elapsed_ms": 284
}
}Card and value in one call
Add include=index to get index_eur and last_price_at on every candidate in one request: 26 credits with top_k up to 10. For current per-source quotes, use include=prices: 29 credits. Both together cost 30. A no_match with no candidates costs 25. Price quotes follow the account plan; top-level withheld and X-Plan-Withheld report any withheld graded or non-English quotes.
Design the flow around a phone camera
Build the picker screen first and treat the single-answer path as the shortcut: reprints share artwork, so ambiguous is the normal case on exactly the cards people most want to scan. Resize the photo on the device to the size the server would reduce it to anyway (the latency figure above says which), since a smaller upload is the only part of the response time you control, and show the set hint field whenever the user is logging cards from one product.
After a match, the same photo can go to the centering route: it measures the borders, a person confirms the lines in your app, and the API returns centering-only grade ceilings. Identification and centering are two calls on purpose, so a scanner that never grades pays for one.
Plan and cost
A verified trial account can try 5 photo recognitions at 25 base credits each, plus any requested index or prices, with English, ungraded price quotes. Recognition then continues on Growth (€79/month, 200,000 credits a month). Unverified accounts and trials past the 5 calls get a 403 PLAN_REQUIRED before any credit is spent.
What it is not
Read this before you build on it. Every line here is a support ticket somebody else already opened.
- Treat
ambiguousas the normal case - Reprints share artwork. Design the picker screen first; the single-answer path is the shortcut, not the other way round.
- Lighting and framing matter
- Glare on a holo, a dark room or a card cut off by the frame lower the match quality. Guide the user to fill the frame, and offer the set hint.
- One card per photo
- The endpoint identifies a single card. A binder page or a spread of cards needs to be split on your side.
Questions people ask before signing up
- Centering from the same photo
- Measure a card's centering from a photo. The API returns the L/R and T/B ratios with their uncertainty and the detected corners and border lines; a person reviews the lines in your app and confirms them, and the API returns the centering-only grade ceilings for PSA, BGS, CGC and SGC. Growth and higher; a verified trial includes 5 measure and 5 confirm calls. Measure 25 credits, confirm 5, grade 1, standards 1. This is a ceiling from centering alone, not a prediction of the final grade. Centering from a photo
- Is there an API to identify a Pokémon card from an image?
- Yes. POST /v1/vision/identify takes a JPEG or PNG and returns a decision (match, ambiguous or no_match) with ranked candidate cards from the catalogue, each with its id, set, number and image.
- Does it work with photos taken on a phone?
- Yes. The card is located in the frame and rectified before matching, so it does not need a flat scan or a clean crop. Even lighting and a card that fills the frame give the best results.
- Can it tell a reprint from the original?
- Not from the artwork alone, and it does not pretend to: when printings share the illustration the decision is ambiguous and the candidates are listed. Passing the set as a hint resolves it.
- How much does card recognition cost?
- 25 credits per photo, plus 1 for include=index or 4 for include=prices with top_k up to 10; both add together. Empty no_match responses cost 25. Verified trial accounts get 5 trial calls. Ongoing use starts on Growth (€79/month). Failed requests and plan refusals cost nothing. Pricing