Identify a card from a photo
Send a photograph and get ranked candidate cards. Verified Free accounts get 5 trial recognitions at 25 credits each; ongoing access starts on Growth.
/v1/vision/identify25 base; add ceil(top_k / 50) for include=index and 4 × ceil(top_k / 25) for include=prices (both add when requested together). With top_k 1–10: 26, 29 or 30 credits. An empty no_match costs only 25.growth planCache-Control: private, no-store
No key yet? One call gets you one: POST /v1/accounts/free with an Idempotency-Key header and your email address returns a working key in the response. Keep the original Idempotency-Key and body for response replay for up to 24 hours, under the conditions in the quickstart. The trial is 800 credits over 30 days once the address is confirmed, no card; paid plans start at 29 € a month. See the quickstart and the pricing.
A photograph goes in and a ranked list comes out. Reprints and regional twins can share their artwork, so the image ranking alone may be ambiguous. When available, OCR reads the printed collector fraction on the winning card crop and selects a card only if its number matches exactly one candidate. The response includes the candidates, their distances and whether the evidence is clear enough to act on.
Try 5 recognitions on the verified trial
A verified Free account can make 5 calls during its trial, at 25 credits each. Before email verification, the route returns 403 PLAN_REQUIRED with details.trial_calls_after_verification. After the 5 calls it returns 403 PLAN_REQUIRED with details.trial_calls, details.trial_calls_used and details.next_step, before the upload is read or credits are reserved. Ongoing recognition is included from Growth up.
How it works
Every catalogue image is reduced to a 64-byte perceptual signature — a DCT signature of its low frequencies plus a gradient signature — and your photograph is reduced the same way. The comparison is a Hamming distance across the whole index, which is a linear scan and costs under a millisecond.
The photograph does not need to be a tight crop. Before hashing, the card is looked for as a quadrilateral in the frame and each candidate quadrilateral is straightened into the canonical portrait crop; those hypotheses compete with a set of concentric centre crops, and the one that actually matches the catalogue wins on distance. A quadrilateral that grabbed the table instead of the card lands about three times farther away and simply loses.
For an ambiguous ranking with at least two candidates, the service may read the collector number from the bottom of the winning crop. A fraction such as 048/172 matches card number 48; prefixes and suffixes such as TG12, SV045 and 194a are kept when comparing numbers. When several nearby cards share that number, OCR may also read the printed set code beside it. A unique exact number and set-code match gives decision_reason: "ocr_set_number"; OCR errors and timeouts leave the image ranking unchanged.
What the numbers mean
distance is 0 to 512 and is the number you should threshold on: real matches land well under 150 even on a noisy phone photo, and nothing above 170 is returned at all. confidence is the same information rescaled to 0–1 for a progress bar, and carries no extra evidence.
Reading the decision
| decision | means | what to do |
|---|---|---|
match | A clear image ranking, a unique printed collector number, or a unique number and set code. | Safe to act on. OCR reports ocr_number or ocr_set_number in decision_reason. |
ambiguous | Two or more candidates within a few bits of each other — almost always the same illustration printed twice. | Show the candidates and let a person pick, or re-send with set or region. |
no_match | Nothing within range. | Check meta.regions_detected: zero means the card was never isolated from the background, which is a framing problem rather than a catalogue one. |
data.id is populated only on match. On ambiguous it is null on purpose: a client that reads it without looking at decision should break immediately and visibly rather than quietly hand over the wrong printing.
decision_reason is clear for a match, reprint for a tied distance or the same name and number, close_call for other near matches, and none when no candidate qualifies. The decision uses at least two candidates and all ties even when top_k=1; top_k limits only the returned list.
meta.index_built_at is the newest signature timestamp in the loaded index. meta.index_loaded_at is when this process loaded it. The server checks for a new signature timestamp every hour by default.
Use include=index to put index_eur and last_price_at on each candidate. include=prices also puts the current prices[] quotes on each candidate, with the same legal and plan filters as the card batch. A Free trial sees English ungraded quotes; Growth includes graded and all locales. When the plan withholds quotes, both top-level withheld and the X-Plan-Withheld header list graded and/or non_english_locales. If there are no candidates, the extra include credits are refunded.
The index figures in the response example illustrate the field shape; they are not live valuations. Always read the returned timestamp and the current response for a value.
meta.ocr appears when the tie-break is considered. It reports applied, elapsed OCR time in ms, the detected number when available, or a reason (no_text, no_fraction, no_match, multiple, timeout, disabled, error). A set-code pass reports set_code on success or set_reason (no_token, no_match, multiple, timeout, error) otherwise. meta.elapsed_ms includes this time.
Body fields
| Name | Type | Default | Description |
|---|---|---|---|
image required | file | string | — | The photograph. Three ways to send it: a multipart field named image, the raw bytes as the whole body with an image/* content type, or base64 (a data: URL is accepted) in a JSON field of this name. JPEG, PNG, WebP and GIF; 10 MB and 96 px per side are the bounds. |
top_k | integer | 3 | How many candidates to return, 1 to 10. In query string when the body is the image itself. |
set | string | — | Restrict to one set code. This is the parameter that resolves a reprint: from artwork alone the original and its reprint are the same card, and if you know which pack it came out of, say so. |
region | string | — | Restrict to a print region — WEST, JP or CN. Same purpose: a Japanese card and its Western twin share the illustration. |
include | string | — | Comma-separated index and/or prices. In multipart or JSON alongside image; query is also accepted for multipart and raw image bodies. index adds index_eur and last_price_at; prices adds those fields and plan-filtered prices[]. Price restrictions are listed in top-level withheld and X-Plan-Withheld. Unknown values return 400 INVALID_PARAMETER without charge. |
Headers
| Name | Type | Default | Description |
|---|---|---|---|
X-Api-Key required | string | — | Your API key. Authorization: Bearer <key> is accepted as an alias. |
Example request
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"Example response
200 OK · Cache-Control: private, no-store
{
"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
}
}Errors
Every error body carries error.code, error.message and error.request_id. Switch on the code, never on the message.
| Status | Code | When |
|---|---|---|
| 401 | MISSING_API_KEY | No X-Api-Key header and no bearer token on a route that requires one. |
| INSUFFICIENT_SCOPE | INSUFFICIENT_SCOPE | — |
| 403 | PLAN_REQUIRED | The feature is outside the plan (movers require Growth; photo recognition has 5 calls after Free email confirmation, then requires Growth), or the account subscription is canceled or suspended. Centering requires Growth or higher; Developer is excluded. After email confirmation the trial includes 5 measure and 5 confirm calls independently; grade and standards use trial credits without separate call counters. Before email confirmation, the message starts with “Confirm your email…”. Follow details.next_step when present. |
| 400 | INVALID_PARAMETER | A query parameter has the wrong type or an unsupported value — a non-integer limit, an unknown lang, an unknown region, a sort key that is not sortable. |
| 404 | SET_NOT_FOUND | No set with that code, slug or alternate id. |
| 415 | UNSUPPORTED_MEDIA_TYPE | The Content-Type is not one this route accepts. |
| 413 | PAYLOAD_TOO_LARGE | The request body is larger than the server accepts. |
| 429 | QUOTA_EXCEEDED | Trial credits, monthly credits or the daily cap are exhausted, and details says which: daily_cap with retry_after_s is the trial daily cap, which resets at midnight UTC; renews: true with resets_at is the monthly quota of a paid plan; renews: false is the trial allowance, which never comes back. details.next_step carries the plan, prices and owner action, or email verification when it unlocks the full trial. |
| 503 | FEATURE_NOT_CONFIGURED | The endpoint exists but its optional backend is not configured in this deployment. |
| 403 | TRIAL_EXPIRED | The trial has ended: the account is on the trial and its 30 days from signup are over, and this route costs credits. details.trial_expired_at is the date, details.trial_days the length, and details.next_step the owner handoff. Zero-cost routes — /v1/me, /v1/me/usage, the key routes and /v1/billing/checkout — still answer. |
| 403 | CARD_ALLOWANCE_EXCEEDED | The account has retrieved as many distinct cards as its plan covers in the period — 1,000 for the whole trial, 30,000 a month on Developer; Growth and above have no limit — and this request would add new ones. details.cards_used, details.card_allowance and details.new_cards give the numbers, details.resets_at the next period on paid plans, and details.next_step the owner handoff. Only the card routes count; the page that crossed the limit was served in full, and cards already retrieved keep answering. |
The full taxonomy, with what to do about each code, is on the errors page.
Two candidates, one illustration
The example above is the normal case rather than an edge case. Base Set Charizard and its Base Set 2 reprint are the same illustration on the same layout; the seven bits between them are printing noise, not a difference the camera can see. Any scanner that picks one of the two silently is wrong about half the time, and it is wrong on precisely the cards people care most about.
The two ways out are both in your hands: pass set or region when your workflow knows them — someone inventorying a pack they just opened does — or present both and take one tap. What the API will not do is guess and call it certainty.
It answers 503 until the index is built
The signature index is built by an offline job that hashes every catalogue image. On a deployment where it has not run, this endpoint answers 503 FEATURE_NOT_CONFIGURED rather than no_match — because "we did not look" and "it is not in the catalogue" are opposite answers, and a client would cache the second one as a fact.