# Identify a card from a photo

> Send a photograph, get back the cards it could be, ranked, with a distance you can threshold yourself.

- **Endpoint**: `POST /v1/vision/identify`
- **Cost**: The most expensive call in the price list, and the only one that is not a row being read: it is the index of every catalogue image answering at once.
- **Minimum plan**: growth
- **Cache-Control**: `private, no-store`

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

A photograph goes in and a ranked list comes out. Never a single answer: reprints and regional twins share their artwork, so an exact call from the image alone is not available to us and would not be available to anyone. What the endpoint returns instead is what it actually knows — the candidates, how far each one is, and whether the top of the list is far enough clear of the rest to act on without asking a human.

> **Included from the Growth plan**
>
> This is the one route with a plan behind it as well as a scope. Below Growth it answers `403` with the code `PLAN_REQUIRED` and `details.min_plan`, before the upload is read — so a client on the wrong plan finds out in a round trip rather than after sending ten megabytes. It is also the most expensive call in the price list at 25 credits, because it is the index of every catalogue image answering rather than a row being read.

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

> **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` | One candidate, close, and clear of the next by a wide margin. | Safe to act on. |
| `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.

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

## 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"
```

**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");

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, **({"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",
    "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/cards/bs/4-normal.webp",
        "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/cards/b2/4-normal.webp",
        "distance": 38,
        "confidence": 0.776
      }
    ]
  },
  "meta": {
    "count": 2,
    "cards_indexed": 51983,
    "index_built_at": "2026-08-27T04:10:22Z",
    "signature_version": 1,
    "regions_detected": 2,
    "hypotheses_tried": 8,
    "elapsed_ms": 74
  }
}
```

## 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 route exists and the key is valid, but the plan does not include this feature. Today that is `/v1/prices/movers` on the trial. |
| 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. |
| 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. |
| QUOTA_EXCEEDED | QUOTA_EXCEEDED | — |
| 503 | `FEATURE_NOT_CONFIGURED` | The endpoint exists but its optional backend is not configured in this deployment. |

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.
