Card Clerk Scan API
Send one scanned trading card image and get back the card and its printing, in the same call. Built for scanner output: one card per image, cropped to the card.
- Base URL
https://vision.card-clerk.com - Scan
POST /v1/scans - Health
GET /v1/ping - Games mtg · pokemon · fab · onepiece · swu
Quick start
We send your API key separately. Keep it secret, and send it only in the Authorization header, over HTTPS.
curl https://vision.card-clerk.com/v1/scans \ -H "Authorization: Bearer $CARD_CLERK_KEY" \ -F "img=@card-0001.jpg" \ -F "game=mtg" \ -F "ref=card-0001" \ -F "batch=box-7"
The same call in Python, with requests:
import os
import requests
try:
with open("card-0001.jpg", "rb") as image:
resp = requests.post(
"https://vision.card-clerk.com/v1/scans",
headers={"Authorization": f"Bearer {os.environ['CARD_CLERK_KEY']}"},
files={"img": ("card-0001.jpg", image, "image/jpeg")},
data={"game": "mtg", "ref": "card-0001", "batch": "box-7"},
timeout=180,
)
except requests.ConnectionError:
resp = None # refused while the API restarts: retry shortly
if resp is None or not resp.headers.get("content-type", "").startswith("application/json"):
print("the API is restarting: retry shortly") # refused, or a 502 from the server in front
elif resp.ok:
answer = resp.json()
print(answer["status"], answer["card"], answer["review_reasons"])
else: # every error has an `error`; a refusal before the scan has no `status`
answer = resp.json()
print(resp.status_code, answer.get("status"), answer.get("error"))
The request
POST /v1/scans takes a multipart/form-data form with one image and the text fields below. Other text fields are ignored; a form may hold up to 16 text fields of up to 8 KB each.
| Field | Required | What to send |
|---|---|---|
img | Yes | The image file: one card, cropped to the card. |
game | Yes | mtg (Magic: The Gathering), pokemon, fab (Flesh and Blood), onepiece or swu (Star Wars: Unlimited). |
ref | No | Your own id for this image, up to 256 characters. We return it unchanged, so you can match answers to images. |
batch | No | Your own id for a group of images, such as a box or a session. Up to 256 characters. |
Images
- JPEG, PNG, WebP, GIF (still), BMP or TIFF. JPEG works well.
- At most 20 MB and 25 megapixels.
- At least 32 pixels on the short side, and no more than 4 times longer than wide.
- Orientation: we apply the image's EXIF orientation, then turn it by the rotation agreed for your scanner (0°, 90°, 180° or 270°). If it is still landscape, we turn it 90° clockwise. If it finds no match, we try it once more turned 180°. Tell us if your scanner's direction changes.
- For Magic, a card back is recognised and answered
card_back.
The answer
Every answer from the API is JSON. A scan the API could read comes back 200 with a status of matched, no_match or card_back. This example shows a Magic promo answered as its standard printing, flagged for review:
{
"ref": "card-0001",
"batch": "box-7",
"scan_id": "6f1c2b9e-…",
"status": "matched",
"card": {
"game": "mtg",
"print_id": "…",
"set_code": "stx",
"set_name": "Strixhaven: School of Mages",
"collector_number": "119",
"variant_code": "",
"name": "Accomplished Alchemist",
"face": null
},
"card_confidence": 0.998,
"printing_confidence": 0.978,
"needs_review": true,
"review_reasons": ["low_printing_confidence"],
"candidates": [],
"answered_base": true,
"rotation_applied": 90,
"alternatives": [
{"print_id": "…", "set_code": "pstx", "collector_number": "119p", "variant_code": "",
"relationship_type": "promo_alternative", "is_base": false, "label": "pstx-119p"},
{"print_id": "…", "set_code": "pstx", "collector_number": "119s", "variant_code": "",
"relationship_type": "promo_alternative", "is_base": false, "label": "pstx-119s · Alternate Art s"}
]
}
| Field | Meaning |
|---|---|
status | What happened. See Statuses. |
scan_id | Our id for this scan. Quote it when you report a problem. |
card | The printing we identified, or null. print_id is Card Clerk's id for that printing; it is null when we recognise a card we hold no record for (no_library_record), or could not read its record (library_unavailable). variant_code tells the printings at one set and number apart; its form differs by game (Magic and Pokémon leave it empty for the standard printing). face names the face of a double-faced card, when we can tell. For Flesh and Blood, One Piece, Pokémon and Star Wars: Unlimited, the image does not tell the finish: the printing is the card in one of its finishes, so set foil or not yourself. |
card_confidence | Magic only, 0 to 1: how sure we are of the card (its artwork). null for other games. |
printing_confidence | Magic only, 0 to 1: how sure we are of the printing our model chose. |
needs_review | true when a person should check the answer. review_reasons says why. |
candidates | When we could not choose one printing: every printing that fits, the likeliest first. Each has the same fields as card. |
alternatives | Related printings a person might mean instead: promos, reprints and other print runs, and for Magic other finishes. Offer these when the answer needs review. |
answered_base | true when we answered the standard printing of a Magic promo that shares its artwork. The promo our model saw is then the first alternative. |
rotation_applied | Degrees clockwise we turned the image before the match. |
ref, batch | Yours, returned unchanged. |
When an answer needs review
An answer with needs_review: true is still our best answer, but a person should confirm it. The reasons:
| Reason | Meaning |
|---|---|
several_candidates | We could not choose between cards. candidates lists them. |
low_card_confidence | Magic: under 95% sure of the card. |
low_printing_confidence | Magic: sure of the card, not of the printing, such as a prerelease stamp against the plain card. Also every answer with answered_base: true. alternatives lists the related printings. |
print_not_resolved | The set and number fit more than one printing. candidates lists them, the likeliest first. |
edition_not_resolved | The card has a 1st Edition and an Unlimited printing, and the image cannot tell them apart. |
no_library_record | We recognised the card but hold no record for that printing, so print_id is null. |
library_unavailable | Our card database did not answer in time. The answer can lack the card's record (print_id and its names null), alternatives may be short, and a Magic promo is not checked against its standard printing. Send the image again later for the full answer. |
Statuses and errors
Every error answer from POST /v1/scans is JSON with an error sentence. A wrong path or method gets the web server's own 404 or 405, whose JSON has a detail instead. Once the API has your request, every answer also carries status, your ref (null where the form could not be read) and the scan_id of its record. The refusals before that (401, 411, an early 413, 429, and busy when 64 scans are in hand) are not recorded and carry no scan_id.
| HTTP | Status | Meaning | What to do |
|---|---|---|---|
| 200 | matched | We identified the card. | done Check needs_review. |
| 200 | no_match | No card matched, after the 180° retry. | check Rescan, or review by hand. |
| 200 | card_back | The image is a card back (Magic). | check Flip and rescan. |
| 400 | invalid_image | Not an image we can read, or outside the size and shape limits. | fix Do not resend as is. |
| 400 | unsupported_game | The game is not one of the five. | fix Correct game. |
| 400 | invalid_field | ref or batch over 256 characters. | fix Shorten it. |
| 400 | invalid_form | The body is not the form above: no img file or no game, a second file, more than 16 text fields, or a field over 8 KB. | fix Correct the form. |
| 401 | — | No key, or a key we do not know. | fix Check the header. |
| 411 | — | No Content-Length. Chunked uploads are not taken. | fix Send the length. |
| 413 | too_large | The image is over 20 MB or 25 megapixels. A request over 21 MB is refused 413 before it is read, without a status. | fix Send a smaller image. |
| 429 | — | Over your key's requests per minute. | wait Retry after the minute turns. |
| 503 | busy | Too many scans in hand right now. | wait Retry after the Retry-After seconds. |
| 503 | vision_unavailable | Card identification is down for a moment. | wait Retry later, backing off. |
| 502 | — | The API is restarting, for a moment, and the server in front of it answers. The body is not JSON. While it restarts, a connection can also be refused outright. | wait Retry shortly. |
| 500 | error | Something failed on our side. | wait Retry once; if it repeats, send us the scan_id. |
Limits and pacing
- Requests per minute: your key allows a set number of requests each clock minute. We give you the number with the key. Past it, you get
429. - At a time: we identify 4 of your images at once. Send up to 4 together for the fastest answers. More wait their turn for up to 30 seconds, then answer
busy. - In hand: past 64 requests in progress at once, a new request is answered
busystraight away, before its image is read. That answer does not count against your requests per minute. - Timeouts: most answers take a second or two. Under load a scan can wait 30 seconds for its turn, and each of its two tries can take up to a minute, so we suggest a timeout of 3 minutes. That is not a hard limit: a scan you send again after giving up is a new scan, recorded and counted again.
Batches: send the images of a batch as separate requests, with the same batch value. Retry busy, vision_unavailable, 429 and 502 answers, and a refused connection; do not retry the other 4xx ones without changing the request.
Health check
GET /v1/ping needs no key and answers {"ok": true} while the API is up.
Your data
We keep a record of each request we take in: its time, your ref and batch, its status and the printing we answered. A request refused before we read it (a missing or unknown key, over your requests per minute, a request over 21 MB, or busy with 64 scans in hand) leaves no record. We also keep the image of a scan we read; we do not keep one we refuse as too large or for its fields, and under heavy load an image can be dropped while its record is kept. We use them to bill and to look into problems you report.