# 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.

- **Endpoint**: `POST /v1/vision/identify`
- **Cost**: 25 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.
- **Minimum plan**: growth
- **Cache-Control**: `private, no-store`

Source: https://pokemontcgapi.com/docs/api/vision/identify

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](https://pokemontcgapi.com/docs/quickstart) and the [pricing](https://pokemontcgapi.com/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**

```bash
curl -s -X POST "https://api.pokemontcgapi.com/v1/vision/identify" \
  -H "X-Api-Key: $PTCG_API_KEY" \
  -F "image=@card.jpg" \
  -F "top_k=3" \
  -F "include=index"
```

**Raw body**

```bash
# 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.jpg
```

**TypeScript**

```ts
const 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);
}
```

**Python**

```python
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"]])
```

## Example response

`200 OK` · `Cache-Control: private, no-store`

`response.json`

```json
{
  "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](https://pokemontcgapi.com/docs/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.
