Card recognition
An image in, a printing out. One endpoint over HTTPS for single photos, one socket for continuous scanning, and the same match object from both.
Recognition returns a printing, not a card name — which set, which collector number, which language, which finish — with the full catalog record and both price feeds attached. It is metered separately from the data API in scans, where one scan is one matched card. Frames that do not resolve are answered and billed zero.
Pricing and the per-game gotchas live on the card recognition page. This page is the contract.
Single photo
POST /v1/scan accepts a multipart upload, a public URL, or base64 in a JSON body. One request, one response — there is no job to poll and no id to chase.
curl -X POST "https://api.tcggraph.com/v1/scan" \
-H "Authorization: Bearer $TCGGRAPH_KEY" \
-F image=@front.jpg \
-F games=pokemon \
-F minConfidence=0.92Parameters
The same names work as multipart fields, query parameters, JSON keys, and in the socket’s config frame.
| Parameter | Type | Description |
|---|---|---|
| image | file | url | base64 | The frame. One of a multipart file, a public URL, or a base64 string in a JSON body. |
| games | string[] | Restrict matching to these games. Narrower is faster and less ambiguous. Defaults to all of 8. |
| minConfidence | float | Threshold a match must clear to be returned and billed. Defaults to 0.90. Below it the frame comes back as unresolved, free. |
| prices | string[] | Which price sources to attach. Defaults to both cardmarket and tcgplayer. |
| multi | boolean | Locate and match every card in the frame instead of the largest. Billed per card matched. |
| language | string | Hint the printed language. Optional — the match detects it — but it narrows the search on multilingual sets. |
| slab | boolean | Read a grading label alongside the card. Recognition Growth and up. |
The match object
{
"requestId": "scan_01K5Z8P4XQJ7YB3M",
"latencyMs": 41,
"matches": [
{
"confidence": 0.9962,
"billed": true,
"box": {
"x": 0.171,
"y": 0.064,
"w": 0.658,
"h": 0.872
},
"printing": {
"id": "pkm_sv3pt5_199",
"language": "en",
"finish": "holofoil",
"edition": "unlimited",
"collectorNumber": "199/165"
},
"card": {
"id": "pkm_sv3pt5_199",
"game": "pokemon",
"name": "Charizard ex",
"set": {
"code": "sv3pt5",
"name": "151"
},
"rarity": "Special Illustration Rare",
"prices": [
{
"source": "tcgplayer",
"region": "NA",
"currency": "USD",
"market": 370.48
},
{
"source": "cardmarket",
"region": "EU",
"currency": "EUR",
"market": 312.67,
"trend": 312.67,
"sellers": 86
}
]
},
"alternatives": []
}
],
"scans": {
"billed": 1,
"remaining": 5842
}
}| Field | Type | What it is for |
|---|---|---|
| matches[].confidence | Float | 0 to 1. Above your threshold the match is returned and billed; below it, the frame comes back as a candidate list and costs nothing. |
| matches[].billed | Boolean | Whether this match consumed a scan. Sum it if you want to reconcile your own counter against ours. |
| matches[].box | Object | Normalised x, y, width and height of the card inside the frame, for drawing an overlay without a second pass. |
| matches[].card | Card | The whole catalog record — the identical object /v1/cards returns, including images, legalities and game-specific fields. |
| matches[].card.prices | [Price] | Cardmarket in EUR and TCGplayer in USD, on the match, in the same response. No second request and no second charge. |
| matches[].printing | Object | Which physical printing was matched: language, finish, edition and collector number, not just the card name. |
| matches[].alternatives | [Object] | Ranked runners-up with their confidences. Useful for a confirm step, and the only thing returned when nothing clears the threshold. |
| latencyMs | Int | Server-side time from frame received to match emitted. Excludes network transit, so you can tell our latency from your connection's. |
| scans | Object | Billed count for this call and the balance left on the period. |
matches[].card is byte-for-byte the object /v1/cards returns, so an existing parser handles it unchanged. The prices on it are the same objects, from the same sources, at the same freshness as the price endpoints — identification and pricing are one operation and one charge.
Unresolved frames
Half the contract, and the half people forget to handle. When nothing clears minConfidence you still get a body: where the card was, why it failed, and the ranked runners-up. scans.billed is 0.
{
"requestId": "scan_01K5Z8P51N4WQD0T",
"latencyMs": 29,
"matches": [],
"unresolved": [
{
"box": { "x": 0.19, "y": 0.07, "w": 0.63, "h": 0.86 },
"reason": "glare across set symbol",
"alternatives": [
{ "printingId": "pkm_sv3pt5_199", "confidence": 0.71 },
{ "printingId": "pkm_sv3pt5_223", "confidence": 0.68 }
]
}
],
"scans": { "billed": 0, "remaining": 5842 }
}| reason | Meaning |
|---|---|
| blurred | Motion or focus. The most common cause on hand-fed stacks; slow down or raise the shutter speed. |
| glare | A specular highlight over a discriminating region, usually a set symbol or a foil name plate. |
| cropped | One or more card corners are outside the frame, so the collector number line cannot be read. |
| occluded | A thumb, a sleeve seam or another card covers a field the match depends on. |
| ambiguous | The card was read, but two or more printings remain within the threshold. alternatives lists them. |
| not_in_catalog | Read cleanly and matched nothing we carry. Worth reporting — it is usually a set we have not indexed yet. |
Surface the reason to whoever is holding the card. An operator told glare on the set symbol fixes the shot in a second; the same operator shown a spinner tries the same angle three more times.
Choosing a threshold
- 0.98 and up — unattended pipelines where a wrong printing corrupts inventory. Expect to refuse more frames.
- 0.90 to 0.95 — the default range, and right for anything with a human watching. Pair it with a confirm step driven by
alternatives. - Below 0.85 — only when a person confirms every match. A confident wrong answer costs more than a refused frame, and refused frames are free.
The live socket
wss://api.tcggraph.com/v1/scan keeps one connection open for a whole scanning session. It exists to avoid paying connection setup per card: at a few hundred cards an hour, handshakes are most of the wall clock. Authenticate in the subprotocol list, since browsers cannot set headers on a WebSocket.
const socket = new WebSocket("wss://api.tcggraph.com/v1/scan", [
"tcggraph.v1",
`bearer.${process.env.TCGGRAPH_KEY}`,
]);
socket.onopen = () =>
socket.send(
JSON.stringify({
type: "config",
games: ["pokemon", "magic-the-gathering"],
minConfidence: 0.92,
prices: ["cardmarket", "tcgplayer"],
}),
);
socket.onmessage = (event) => {
const frame = JSON.parse(event.data);
if (frame.type === "match") addToInventory(frame.matches[0]);
if (frame.type === "unresolved") showHint(frame.reason);
};
// Binary frames need no envelope. Push them straight off the camera.
setInterval(async () => socket.send(await captureJpeg()), 100);What you send
| Frame | Meaning |
|---|---|
| config | Sent once after the socket opens. Same fields as the REST parameters; applies to every subsequent frame until replaced. |
| <binary> | A JPEG or PNG frame with no envelope. The server assigns it the next sequence number and echoes that number on the reply. |
| frame | A JSON envelope carrying a base64 image and an explicit seq, for clients that cannot send binary. |
| ping | Keepalive. The server also sends its own every 20 seconds and closes a socket that misses two. |
What you receive
| Frame | Meaning |
|---|---|
| ready | Sent on open with the session id, the resolved config and your remaining scan allowance. |
| match | One or more cards cleared the threshold. Carries matches[], the frame's seq, latencyMs and the billed count. |
| unresolved | Nothing cleared the threshold. Carries the reason and ranked alternatives. Billed zero. |
| usage | Periodic running totals for the session: frames received, cards matched, scans billed, allowance remaining. |
| error | A frame could not be processed — unsupported format, oversize payload, config rejected. Does not close the socket. |
| closing | Sent before the server closes, with a reason. Allowance exhausted, session idle, or a deploy draining connections. |
Operating it
- Replies carry the
seqof the frame they answer, and they can arrive out of order. Key your UI onseq, not on arrival order. - Frames are downsampled on the way in. Sending 4K stills costs you bandwidth and buys nothing; 1080p is ample.
- Roughly ten frames a second per socket is plenty for a hand-fed stack. Sustained excess is dropped rather than queued, so the newest frame is always the one being matched.
- Concurrent sockets are capped by plan: Starter 2, Growth 10, Scale 50. A third camera needs a third socket, not a third key.
- Reconnect with backoff on an unexpected close. A
closingframe tells you whether reconnecting will help.
How scans are counted
- One matched card is one scan, whether it arrived over HTTPS or the socket.
- A multi-card frame is billed per card matched. Nine cards in a binder photo where eight resolve is eight scans.
- An unresolved frame is zero. So is a rejected or malformed one.
- Scans never draw on your data credits, and catalog calls never draw on your scans. The two counters are reported separately on the dashboard and in
X-TCGGraph-Scans-Remaining. - Without a recognition plan, scans are drawn from your prepaid balance at $0.012 per matched card. With one, overage applies past the allowance and stops at the plan price.
Getting good frames
A phone camera in ordinary indoor light is enough, and no minimum resolution needs hitting. What actually moves the match rate:
- All four corners in frame. The collector number line is the primary key on most modern cards, and it lives in a corner.
- Light from the side, not the front. Head-on light bounces off foils and takes the set symbol with it —
glareis the most common reason a frame is refused. - Sleeves are fine unless a seam crosses a set symbol or a promo stamp. Worth knowing when you design the jig.
- Roughly flat and roughly square. Perspective is corrected; a card folded over a thumb is not.