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, 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. |
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_ex7_99",
"language": "en",
"finish": "holofoil",
"edition": "unlimited",
"collectorNumber": "99"
},
"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": "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[].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 artwork belongs to exactly one printing, so the set, number and finish on the card record are the matched ones. False when reprints or finishes share the artwork: the card is identified, and which of its printings it is comes from alternatives. |
| matches[].alternatives | [Object] | When the printing is unresolved, the other printings sharing the artwork — one of them is the card in front of you. When it is resolved, ranked runners-up for a confirm step. 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, and 47,209 of the 187,294 printings we hold share theirs with at least one other — every foil and non-foil pair, every reverse holo, every reprint that kept the original art. Nothing visible in a photograph of the front separates those, so matches[].printingResolved exists to say which situation you are in.
True. The artwork belongs to one printing. The set, collector number, language and finish on the card record are the matched ones, and alternatives holds ranked runners-up for a confirm step.
False. The card is identified and the printing is not. Which card it is is certain; which of its printings is in alternatives, and choosing between them needs something we cannot see — which set the box being unpacked came from, which finish the seller listed. Take the card, then disambiguate from your own context. This is billed, because the card was identified.
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 and rotation
Frames are squared up before matching, so a card does not need to be aligned to the mat. The rotation is measured and removed, and a background margin around the card is trimmed. A two-degree tilt — roughly what hand-feeding produces — costs nothing measurable; at four degrees the corrected frame still lands within a couple of bits of the card’s reference hash.
What is not corrected is perspective. A card photographed from an angle steep enough to make it a trapezoid rather than a rectangle will come back unresolved. Shoot down onto the card rather than across it, and keep all four corners in frame.
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": [
{
"box": { "x": 0.19, "y": 0.07, "w": 0.63, "h": 0.86 },
"reason": "glare",
"detail": "glare across the set symbol",
"candidates": 2,
"bestConfidence": 0.71
}
],
"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 cleanly, but two or more printings remain in contention. Lower minConfidence to have the best candidate returned and billed, with the runners-up alongside it. |
| 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 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.
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}`);
socket.onopen = () =>
socket.send(
JSON.stringify({
type: "config",
games: ["pokemon", "magic-the-gathering"],
minConfidence: 0.92,
}),
);
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 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. - 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.
- 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.
- Each plan has a daily ceiling as well as a monthly allowance — Starter 600, Growth 2,500, Scale 10,000, a tenth of the month in each case. It is there for the same reason the data API has one: a monthly number does not stop a camera left pointing at a table overnight. Hitting it returns
scan_daily_capuntil midnight UTC. - 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, overage applies past the allowance and stops at the plan price. 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. 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.