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 — with the full catalog record, every finish it exists in, and both price feeds attached. The finish itself (normal, reverse holo, foil) is not read from the photo; see shared artwork. 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 9. |
| minConfidence | float | Threshold a match must clear to be returned and billed. Defaults to 0.90, floored at 0.50 and capped at 0.99. Below it the frame comes back unresolved and free, with the reason but without the candidate printings. |
| includeCrop | boolean | Also return crop: the framing the matcher hashed, squared up and turned the way it was read, as a PNG data URL. For finding out why a frame missed — a crop that framed the desk says so at a glance. Off by default; it adds the image to the body. |
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
},
"corners": [
{
"x": 0.183,
"y": 0.064
},
{
"x": 0.829,
"y": 0.071
},
{
"x": 0.817,
"y": 0.936
},
{
"x": 0.171,
"y": 0.928
}
],
"distance": 21,
"printingResolved": true,
"finishResolved": true,
"card": {
"id": "pkm_ex7_99",
"game": "pokemon",
"name": "Rocket's Mewtwo ex",
"set": {
"code": "ex7",
"name": "Team Rocket Returns"
},
"rarity": "Rare Holo EX",
"prices": [
{
"source": "tcgplayer",
"region": "NA",
"currency": "USD",
"market": 550
},
{
"source": "cardmarket",
"region": "EU",
"currency": "EUR",
"market": 5201.01,
"trend": 5201.01,
"avg7": 4398.42
}
]
},
"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 is unresolved, which tells you where the card was and why it failed, 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[].corners | [Point] | null | The card's four corners in the frame as fractions of its width and height, clockwise from the corner matched as the card's top-left. This is the outline that was flattened and hashed, so drawn over the frame it shows a tilted or rotated card exactly — the box is the same outline squared off. Null when the frame was matched without finding an outline, such as a full-bleed scan. |
| matches[].distance | Int | Bits between the frame's hash and the matched printing's, 0 to 256 — the measurement confidence is derived from. On real phone frames the right card lands at a median of 24 bits and an unrelated card never under 46, so a caller tuning minConfidence can see what they are tuning against. |
| 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[].printingResolved | Boolean | True when the set, collector number and language on card are the matched ones — the artwork belongs to one record, or the collector number, set code and language printed on the card were read and settled it. False when several records share the artwork and the printed line did not separate them: the card is identified, and the records it could be are in alternatives. Says nothing about the finish; see finishResolved. |
| matches[].finishResolved | Boolean | True only when card.printings has exactly one entry. Normal, reverse holo, foil and the patterns held on one record are entries in its printings, and the photo is not used to choose between them, so a card that exists in two finishes comes back false however clean the frame. Ask the operator, or use what you know about the stock. |
| matches[].alternatives | [Object] | When printingResolved is false, the other records sharing the artwork, closest to the photo first. At most 15: a card reprinted more often than that with the same art (a Commander land, a Yu-Gi-Oh common) can have its own record outside the list, so a confirm step should also offer GET /v1/cards?game=…&name=…&language=…, which lists every printing. When it is true, ranked runners-up. Each carries cardId, game, language, confidence, name, printedName, set, collectorNumber, rarity, images, pricesFrom and printings (every finish with its key, label, picture and prices), so a confirm step can be drawn without another request. Carried on a billed match only: an unresolved frame reports how many candidates remained, not which. |
| 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.
When several printings share the artwork
Matching is done on the artwork first, and 110,562 of the 693,595 printings in the index share an identical artwork hash with at least one other — reprints that kept the art, the Japanese and English printings of one illustration — and every localized printing sits within a few bits of its English twin. When the artwork leaves more than one record standing, the collector number, set code and language printed along the bottom of the card are read off the photograph at full resolution, and the records that do not print what was read are dropped. A Commander reprint is told from the deck it came from by the C21 263 on the card, and an English 151 card from its Japanese counterpart by the MEW EN 001/165, not guessed from the picture.
A scan decides between records: one set, one collector number, one language. It does not decide the finish. Normal, reverse holo and foil are entries in the record’s printings, not separate records, and a photo is not used to choose between them — the artwork is identical and the foil pattern depends on the light. Two fields say how far a match got:
printingResolved. True when one record is left — the artwork was unique to it, or the printed line settled it — so the set, collector number and language on card are the matched ones. False when the line could not be read (the card small in the frame, the corner covered, the print older than collector numbers) or what it says is shared: which card it is is certain, and the other records it could be are in alternatives. This is billed, because the card was identified.
finishResolved. True only when card.printings has a single entry, so there is no finish to choose. A Scarlet & Violet common that exists as normal and reverse holo comes back false every time, however clean the photo: the finish is the operator’s choice.
Every alternative carries what is needed to draw that choice without another request: the card’s name, set, collectorNumber, language, rarity, images and its own printings, each with a key, a label, a picture and its prices. The options for a confirm step are card and each alternative, each crossed with its printings:
const [match] = response.matches;
const records = match.printingResolved ? [match.card] : [match.card, ...match.alternatives];
const options = records.flatMap((record) => {
const cardId = record.id ?? record.cardId; // card has id, an alternative has cardId
const label = `${record.set.name} ${record.collectorNumber} · ${record.language.toUpperCase()}`;
if (record.printings.length === 0) return [{ cardId, finish: null, label, image: record.images.small }];
return record.printings.map((printing) => ({
cardId,
finish: printing.key, // "normal", "reverse-holo", "pokeball", ...
label: `${label} · ${printing.label}`,
image: printing.images.small,
prices: printing.prices,
}));
});
if (options.length === 1) addToInventory(options[0]);
else askOperator(options); // store the cardId and finish they pickA read never adds a printing the artwork ruled out, and a read that could be a truncated number — a 2 where the card says 272 — is not acted on. The printing you are told is either the only one the picture allows or one the card itself confirms.
The case where different cards share artwork is different again, and rare — ten groups in the whole catalog. Those refuse as ambiguous and cost nothing, because naming one of them would put a card you do not own into your inventory.
Framing, rotation and perspective
The card’s outline is found in the frame, its four corners are snapped to the printed edges, and the card is flattened from those corners onto a rectangle before it is hashed. So a card does not need to be aligned to the mat or shot straight down: any rotation, including upside down, and the perspective of a card photographed from across a table are both removed. On phone frames the corners land within one percent of the card’s width of the true corners, at the median. Both ways up are tried, so which end of the card faces the camera does not matter either.
matches[].corners returns that outline, normalised to the frame and clockwise from the corner matched as the card’s top-left; matches[].box is the same outline squared off. Draw the corners rather than the box when you can — a box fits a tilted card and a tilted wrong rectangle equally well, and the corners show which one you got.
What still helps: all four corners in frame, and the card large in it. Below about 400 pixels of card width the collector number line is too small to read, so the card will be identified but the printing may be left to alternatives.
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, how many printings were still in contention and how close the best of them came. scans.billed is 0.
What an unresolved frame does not carry is which printings those were. That asymmetry is deliberate and worth understanding, because it is the difference between a free miss and a free product: if a refused frame named its best candidate, the cheapest way to use recognition would be to set minConfidence to 0.999, treat every response as a miss, and never pay for a scan. Identification is the thing you are buying, so it arrives on a billed match. The reason a frame failed is diagnostics, so it is free.
{
"requestId": "scan_01K5Z8P51N4WQD0T",
"latencyMs": 29,
"matches": [],
"unresolved": [
{
"reason": "glare",
"detail": "A highlight has blown out 31% of one region of the card. Tilt the card away from the light.",
"box": { "x": 0.19, "y": 0.07, "w": 0.63, "h": 0.86 },
"corners": [
{ "x": 0.21, "y": 0.07 },
{ "x": 0.82, "y": 0.09 },
{ "x": 0.80, "y": 0.93 },
{ "x": 0.19, "y": 0.90 }
],
"candidates": 2,
"bestConfidence": 0.71,
"bestDistance": 51
}
],
"scans": { "billed": 0, "remaining": 5842 }
}| reason | Meaning |
|---|---|
| blurred | The card is too soft or too dark to read, measured on the card itself: the sharpness or the mean brightness is below what any matched frame has shown. Hold still, let the camera focus, or put more light on the card. The detail carries the numbers. |
| glare | A highlight has blown out a quarter or more of one region of the card. Usually head-on light on a foil; tilt the card away from the lamp. |
| cropped | No card outline was found in the frame, or the outline found does not read as a card face — nothing in the catalog is anywhere near it, where any card front would be. Usually a hand or the desk edge got into the outline. Have the whole card in view, all four corners, unobstructed, against a plain background. |
| occluded | Most of the card matches one printing while the card as a whole matches nothing: something covers part of it — a thumb, a sleeve seam, another card. Uncover it, or lower minConfidence to have the partial match returned. |
| ambiguous | The card was read cleanly, but it did not clear minConfidence, or two or more different cards share this artwork and nothing visible separates them. In the first case, lower minConfidence to have the best candidate returned and billed, with the runners-up alongside it. |
| not_in_catalog | Read cleanly — sharp, lit, outline found — and the closest printing is where an unrelated card sits. Worth reporting; it is usually a set we have not indexed yet. |
The reason is measured, not guessed: the flattened card is checked for sharpness, brightness and blown-out highlights, and a fault found there names the reason before anything is said about the catalog. detail carries the measurement, and bestDistance says how far the closest printing was in bits, which separates a threshold set too tight from a frame that never showed a card.
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 to 0.99 — unattended pipelines where a wrong printing corrupts inventory. Expect to refuse more frames. 0.99 is the ceiling; above it every frame would refuse, which costs you throughput and buys you nothing.
- 0.90 to 0.95 — the default range, and right for anything with a human watching. Pair it with a confirm step driven by the
alternativeson the match. - 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 with a ticket rather than your key. POST /v1/scan/tickets with the key returns a single-use token good for sixty seconds, which you pass as ?ticket= on the socket URL. Browsers cannot set headers on a WebSocket handshake, so the alternative is putting a long-lived scan key into client code where anyone with developer tools can lift it — the ticket is what lets a kiosk or a phone app open a socket without shipping a credential. It being single-use and sixty seconds is what makes a token in a URL acceptable; a key there would not be. Server-side clients can skip the mint and send Authorization: Bearer on the upgrade instead. The same ticket works for one photo: POST /v1/scan?ticket= lets a browser upload straight to us without the key or a round trip through your server.
Expect to reconnect. A socket is capped by our own function duration, so a long session ends on the server’s clock rather than yours — reconnect on close, mint a fresh ticket, and resend your config frame. Nothing is lost: the meters live in the account, not the connection, and reconnecting is never billed.
// Mint a single-use ticket, so no key reaches client code.
const { ticket } = await fetch("https://api.tcggraph.com/v1/scan/tickets", {
method: "POST",
headers: { authorization: `Bearer ${process.env.TCGGRAPH_KEY}` },
}).then((r) => r.json());
const socket = new WebSocket(`wss://api.tcggraph.com/v1/scan?ticket=${ticket}`);
// Binary frames need no envelope: a JPEG straight off the camera,
// 1280 px across at quality 70. One frame in flight at a time —
// the next goes when the answer to the last arrives.
const sendFrame = async () => socket.send(await captureJpeg());
socket.onopen = () => {
socket.send(
JSON.stringify({
type: "config",
games: ["pokemon", "magic-the-gathering"],
minConfidence: 0.92,
}),
);
sendFrame();
};
socket.onmessage = (event) => {
const frame = JSON.parse(event.data);
if (frame.type === "match") addToInventory(frame.matches[0]);
if (frame.type === "unresolved") showHint(frame.unresolved[0]?.reason);
if (frame.type === "match" || frame.type === "unresolved" || frame.type === "error") sendFrame();
};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 box, the reason and how many candidates remained. 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. - 1080p frames are ample. Sending 4K costs you bandwidth and buys nothing; going much below 720p costs you the collector number read, so the printing is left unresolved more often.
- A session remembers the card in front of the camera. Once one frame has read the printing off the card, following frames of the same card report it too, even where the number was not legible in that frame; and while the printing is unresolved, the same tied printing is reported as the leader on every frame instead of the answer wandering among the tie. A different card starts over, and nothing is carried from a frame that disagrees.
- Keep one frame in flight: send the next when the answer to the last arrives. The socket matches one frame at a time and drops the rest rather than queueing them, so sending faster than it answers spends upload on frames that are never matched, and every answer then describes where the card was that much longer ago. Frames of 1280 pixels across at JPEG quality 70 match as well as anything larger and travel in a third fewer bytes.
- A socket that sends no frames for 45 seconds is closed. This is not a penalty — reconnecting is free and never billed — and it is what allows the concurrency ceiling below to be as high as it is.
- Concurrent live sessions are capped per account, not per key, at Starter 30, Growth 125, Scale 500, and 5 on pay-as-you-go. This is a flood guard rather than a licence count: you are not buying cameras, and an app whose users each open a socket while scanning is the expected shape. If you are near the ceiling, the usual cause is sockets left open after a user stopped scanning rather than genuine concurrency.
- 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 frame is one card. Locating several cards in one image is not shipped yet, so send a binder page pocket by pocket — at one scan per matched card the cost is the same either way.
- An unresolved frame is zero. So is a rejected or malformed one.
- The same printing matched repeatedly on one session bills once every 20 seconds. A card resting under the lens at ten frames a second is one scan, not thirty. Put a genuine second copy of the same printing through later and it bills again, because it is a second card.
- There is no daily limit: a plan’s monthly allowance can be scanned in a day.
- 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, and stop when it empties. With one, every scan past the allowance is overage at the plan’s overage rate, drawn from the same balance and refused only when the balance will not cover the frame. A hard stop refuses instead of drawing overage, if you would rather fail than spend.
Getting good frames
A phone camera in ordinary indoor light is enough, and no minimum resolution needs hitting to identify the card. What actually moves the match rate:
- All four corners in frame. The outline is what the card is flattened from, and the collector number line that settles the printing lives in a corner.
- The card large in the frame — a third of the width or more. Identification works on a small card; reading the printed number does not.
- 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. Rotation and perspective are corrected; a card folded over a thumb is not.