Skip to content
pokemontcgapi.com

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

Verified Free accounts get 5 trial recognitions; ongoing recognition is included from the Growth plan up. A scan costs 25 credits, or 26 with include=index and 29 with include=prices for up to ten candidates. The full-card batch remains available when the picker needs more than the value.

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.

For a scan-to-value flow, send `include=index` and read `index_eur` and `last_price_at` on each candidate in that response. Use `include=prices` when you need current source quotes; Free trial quotes are English and ungraded, with any restrictions in top-level `withheld` and `X-Plan-Withheld`.

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.

scan.js
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 is the one
  // parameter that resolves a reprint: same artwork, different set symbol.
  if (setHint) form.append("set", 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) {
    // 403 PLAN_REQUIRED below Growth, 422 when the body is not an image.
    const { error } = await res.json();
    throw new Error(error.code + ": " + error.message);
  }

  // data.decision is "match", "ambiguous" or "no_match"; data.id is set only
  // on "match". Read the decision, never just the top of the list.
  const { data } = await res.json();
  if (data.decision === "no_match") return { decision: data.decision, candidates: [] };

  // Hydrate every candidate in one request so the sheet can render at once.
  const ids = data.candidates.map((c) => c.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 {
    decision: data.decision,
    // "match" already means one candidate, close, and clear of the runner-up.
    // Anything else is a picker, not an answer.
    autoConfirm: data.decision === "match",
    candidates: data.candidates.map((c) => ({ ...c, card: byId.get(c.id) })),
  };
}

Ranked candidates, not a single answer

The endpoint returns up to ten candidates, each with a card id, a distance and a confidence score, plus a decision it has already taken for you: match, ambiguous or no_match. Treat the decision as the actual product of the scan. A match is one candidate, close, and clear of the runner-up by a wide margin; ambiguous means two or more within a few bits of each other, which is almost always the same illustration printed twice.

On a match, auto-confirm and offer an undo. On ambiguous, show the candidates with their art, set name and collector number and let the user tap — or re-send with the set filled in. 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 10 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 answers no_match with meta.regions_detected at zero, which means no card was ever isolated from the background; a photo in shadow answers with high distances 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.

Measure the centering after identification

Send the same photo of one face to POST /v1/centering/measure. Show the L/R and T/B ratios, uncertainty, detected corners and border lines. On real photos, HTTP 200 needs-review with ceilings: null is the normal result. If geometry is absent, ask for another photo; if corners are wrong, let the person correct them and measure again.

Let a person check or adjust the cut and print lines in your app. Only their explicit acceptance triggers POST /v1/centering/confirm with the reviewed geometry, measured face and card_size, and confirmed_by: person. Confirm returns centering-only ceilings for PSA, BGS, CGC and SGC with basis: client-confirmed-lines. This is a ceiling from centering alone, not a prediction of the final grade. Keep the reviewed geometry in your app; confirm does not return it.

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
The decision and the distances are information you were given so you could use them. An interface that ignores an ambiguous decision and shows the first row as the answer converts a calibrated index 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.
  • POST/v1/centering/measureRatios, uncertainty and geometry for line review.
  • POST/v1/centering/confirmAfter a person reviews the lines in your app: centering-only ceilings. This is a ceiling from centering alone, not a prediction of the final grade.
  • GET/v1/setsOffer a set hint from a list the user recognises.

Related