vision
Build a Pokémon card scanner
Turn a phone photo into ranked card candidates, let the user confirm the printing, and add it to a collection in two taps.
Who this is for
You are building a scan-to-collection flow, a shop intake tool, or anything where the input is a camera and the output has to be a specific printing.
Plan you need
Recognition is included from the Growth plan up and costs 25 credits a call — the most expensive route in the price list, because it is the whole image index answering rather than a row being read. Everything downstream of it costs one.
Scanning is the most satisfying feature a collection app can ship and the easiest one to get subtly wrong. The temptation is to present it as magic: point, shoot, card added. The problem is that artwork alone does not identify a printing. A Base Set Charizard and its later reprint share the illustration and differ by a symbol the camera may not have caught, and a scanner that silently picks one is wrong roughly half the time on exactly the cards people care most about.
The honest design returns candidates, not an answer. The recognition endpoint gives you a ranked list with confidence scores; your job is to turn that into a confirmation step that takes one tap when the model is sure and two when it is not. Users forgive a scanner that asks. They do not forgive one that quietly logs the wrong printing into a collection they are about to insure.
The code
Upload a photo, get ranked candidates, hydrate them all in one batch call so the confirmation sheet can show real art and real prices side by side.
const BASE = "https://api.pokemontcgapi.com";
const KEY = process.env.PTCG_API_KEY;
// file: a Blob or File from an <input type="file" capture="environment">
export async function scan(file, setHint) {
const form = new FormData();
form.append("image", file);
form.append("top_k", "5");
// If the user is scanning a binder page, tell it which set. It narrows the
// search space and turns near-ties into a clear winner.
if (setHint) form.append("set_hint", setHint);
const res = await fetch(BASE + "/v1/vision/identify", {
method: "POST",
headers: { "X-Api-Key": KEY }, // no Content-Type: the browser sets the boundary
body: form,
});
if (!res.ok) {
const { error } = await res.json();
throw new Error(error.code + ": " + error.message);
}
const { candidates } = await res.json();
if (candidates.length === 0) return { candidates: [], cards: [] };
// Hydrate every candidate in one request so the sheet can render at once.
const ids = candidates.map((c) => c.card_id).join(",");
const hydrated = await fetch(
BASE + "/v1/cards/batch?ids=" + ids + "&include=images,prices" +
"&select=id,name,number,set_name,rarity,release_date",
{ headers: { "X-Api-Key": KEY } },
).then((r) => r.json());
const byId = new Map(hydrated.data.map((card) => [card.id, card]));
return {
// Auto-confirm only when the model is confident AND the runner-up is far
// behind. A 0.71 next to a 0.69 is a coin toss with extra steps.
autoConfirm:
candidates[0].confidence >= 0.9 &&
(candidates.length === 1 ||
candidates[0].confidence - candidates[1].confidence >= 0.25),
candidates: candidates.map((c) => ({ ...c, card: byId.get(c.card_id) })),
};
}Ranked candidates, not a single answer
The endpoint returns up to ten candidates, each with a card id and a confidence score. Treat that list as the actual product of the scan. Two thresholds turn it into a good interface: a floor below which you show the picker regardless, and a margin between first and second place below which you show it too. A top score of 0.71 with a runner-up at 0.69 is not a decision, it is a tie.
When the model is confident and the gap is wide, auto-confirm and offer an undo. When it is not, show the candidates with their art, set name and collector number and let the user tap. That step takes about a second and eliminates the entire class of bug where a collection quietly fills with the wrong printings.
A set hint is the cheapest accuracy you will ever buy
Most scanning happens in bulk: someone is going through a binder, a box, or a shop intake pile, and the cards are mostly from the same set. Passing that set as a hint narrows the search space dramatically and converts many near-ties into clear winners, at no extra cost.
You usually already know the answer. If the last three scans confirmed cards from one set, hint that set for the fourth. If the user opened the scanner from a set page, hint that set. If neither, ask once at the start of a session — "which set are you sorting?" — and let them skip it. The hint is a bias, not a filter: a card from another set can still win if the evidence supports it.
Design the flow around a phone camera
Downscale before uploading. The endpoint takes JPEG, PNG or WebP up to 20 MB, but a modern phone photo is several megabytes of detail that recognition does not use, and on a mobile connection that upload is most of the perceived latency. A thousand pixels on the long edge is plenty, and resizing client-side takes milliseconds.
Handle the unhappy paths explicitly. A photo of a table gets a 422 rather than a wrong guess; a photo in shadow gets low confidences across the board. In both cases the right response is a retry prompt with a concrete instruction — flatten the card, more light, fill the frame — rather than an error toast. The recognition route is never cached and the image body is not retained, which is worth stating plainly in your own privacy copy.
What usually goes wrong
Not a disclaimer list: these are the failure modes that cost people time on this specific integration.
- Presenting the top candidate as certainty
- Confidence is information you were given so you could use it. An interface that hides it and shows a single answer converts a well-calibrated model into an overconfident product.
- Uploading full-resolution photos
- A 12-megapixel image costs upload time on the way in and gains nothing in accuracy. Resize to about 1024px on the long edge before the request and the scan feels instant on cellular.
- Scanning one card at a time through a slow path
- Recognition costs 25 credits against one for a lookup, so a bulk intake flow should batch the follow-up lookups rather than fetching each confirmed card individually. One batch request hydrates every candidate on the sheet.
- Ignoring the set hint you already have
- Passing `set` or `region` narrows the search before distances are compared, and it is the one thing that resolves a reprint tie. Somebody inventorying a pack they just opened knows the set: asking the API to work it out from the artwork is throwing away the answer.
Endpoints used
- POST
/v1/vision/identifyPhoto in, ranked candidates with confidence scores out. - GET
/v1/cards/batchHydrate every candidate in one call so the picker renders at once. - GET
/v1/cards/{id}Full detail for the confirmed card, with images and prices. - GET
/v1/setsOffer a set hint from a list the user recognises.