Errors
Every error has a stable code to switch on and says what to change. None is billed.
Error shape
Every error response is application/json with one error object. Switch on code, which does not change. message is a sentence for people and may be reworded. details is present when there is something to act on: the field at fault, the values that would have worked, when a limit resets. Two responses have no body: a 405, for a method an existing path does not accept (POST /v1/cards), and a 304, which is not an error.
`q` instead of `name`
curl "https://api.tcggraph.com/v1/cards?q=charizard" \
-H "Authorization: Bearer $TCGGRAPH_API_KEY"const response = await fetch("https://api.tcggraph.com/v1/cards?q=charizard", {
headers: {
Authorization: `Bearer ${process.env.TCGGRAPH_API_KEY}`,
},
});
const body = await response.json();
if (!response.ok) throw new Error(`${body.error.code}: ${body.error.message}`);import os
import requests
response = requests.get(
"https://api.tcggraph.com/v1/cards",
params={
"q": "charizard",
},
headers={"Authorization": f"Bearer {os.environ['TCGGRAPH_API_KEY']}"},
)
body = response.json()
response.raise_for_status(){
"error": {
"code": "invalid_request",
"message": "Unknown filter \"q\". Did you mean name=?",
"details": {
"field": "q",
"suggestion": "name",
"allowed": [
"page",
"limit",
"sort",
"game",
"name",
"text",
"rarity",
"set",
"collectorNumber",
"artist",
"language",
"minPrice",
"maxPrice",
"source",
"region",
"grader",
"grade",
"supertype",
"subtypes",
"hp",
"types",
"evolvesFrom",
"attacks",
"abilities",
"weaknesses",
"resistances",
"retreatCost",
"convertedRetreatCost",
"regulationMark",
"nationalPokedexNumbers",
"manaCost",
"manaValue",
"colors",
"colorIdentity",
"typeLine",
"power",
"toughness",
"loyalty",
"keywords",
"layout",
"finishes",
"cardType",
"cost",
"counter",
"life",
"attribute",
"trigger",
"frameType",
"race",
"atk",
"def",
"level",
"linkRating",
"linkMarkers",
"pendulumScale",
"archetypes",
"banlist",
"inkCost",
"inkable",
"ink",
"lore",
"strength",
"willpower",
"classifications",
"moveCost",
"version",
"aspects",
"arena",
"traits",
"unique",
"variant",
"playCost",
"dp",
"digivolveCosts",
"form",
"inheritedEffect",
"securityEffect",
"memoryCost",
"reserveCost",
"durability",
"elements",
"classes",
"speed",
"line",
"specifiedCost",
"comboPower",
"comboEnergy",
"specialTraits",
"characters",
"era",
"series",
"back",
"minHp",
"maxHp",
"minConvertedRetreatCost",
"maxConvertedRetreatCost",
"minManaValue",
"maxManaValue",
"minCost",
"maxCost",
"minPower",
"maxPower",
"minCounter",
"maxCounter",
"minLife",
"maxLife",
"minAtk",
"maxAtk",
"minDef",
"maxDef",
"minLevel",
"maxLevel",
"minLinkRating",
"maxLinkRating",
"minPendulumScale",
"maxPendulumScale",
"minInkCost",
"maxInkCost",
"minLore",
"maxLore",
"minStrength",
"maxStrength",
"minWillpower",
"maxWillpower",
"minMoveCost",
"maxMoveCost",
"minCost",
"maxCost",
"minPower",
"maxPower",
"minHp",
"maxHp",
"minLevel",
"maxLevel",
"minPlayCost",
"maxPlayCost",
"minDp",
"maxDp",
"minMemoryCost",
"maxMemoryCost",
"minReserveCost",
"maxReserveCost",
"minLevel",
"maxLevel",
"minPower",
"maxPower",
"minLife",
"maxLife",
"minDurability",
"maxDurability",
"minCost",
"maxCost",
"minSpecifiedCost",
"maxSpecifiedCost",
"minPower",
"maxPower",
"minComboPower",
"maxComboPower",
"minComboEnergy",
"maxComboEnergy"
],
"docs": "https://tcggraph.com/docs/rest#filtering"
}
}
}Status codes
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request | A parameter, filter or body field is unknown, malformed or out of range, or a set code needs ?game= to be unambiguous. details.field names it, details.allowed lists the valid values, and details.suggestion is set when the intended name is obvious. |
| 401 | unauthenticated | No key was sent, or the key is not valid or has been revoked. |
| 402 | no_active_plan | The account has no plan. details.plans links to the plans. |
| 402 | balance_empty | Past the monthly allowance, and the balance will not cover the overage. details.balanceCents and details.requiredCents give the numbers. |
| 403 | forbidden | The account is suspended; the key was used from an origin not on its allowed list; or creating a watch would exceed the plan's price-watch allowance (details.limit is price_watches). |
| 404 | not_found | No game, set, card, webhook endpoint or watch with that id, or no endpoint at that path. The message says which. |
| 409 | conflict | That webhook URL is already registered, or an identical price watch already exists. |
| 429 | rate_limited | A limit was reached. details.limit says which: rate_limit (per minute) or monthly_allowance (a hard cap is set). Retry-After is in seconds. |
| 429 | scan_hard_cap | Card recognition only: the monthly scan allowance is used up and a hard stop is set. |
| 405 | — | The path exists but not with that method. Empty body. |
| 5xx | — | Our failure. Not billed. The body is not guaranteed to be JSON, so read the status before parsing. |
Each endpoint’s page lists the errors it can return, with the request that causes each one and the response it got: for example GET /v1/cards.
More real responses
No key
curl "https://api.tcggraph.com/v1/games" \
-H "Authorization: Bearer $TCGGRAPH_API_KEY"const response = await fetch("https://api.tcggraph.com/v1/games", {
headers: {
Authorization: `Bearer ${process.env.TCGGRAPH_API_KEY}`,
},
});
const body = await response.json();
if (!response.ok) throw new Error(`${body.error.code}: ${body.error.message}`);import os
import requests
response = requests.get(
"https://api.tcggraph.com/v1/games",
headers={"Authorization": f"Bearer {os.environ['TCGGRAPH_API_KEY']}"},
)
body = response.json()
response.raise_for_status(){
"error": {
"code": "unauthenticated",
"message": "Missing API key. Send `Authorization: Bearer <key>`.",
"details": {
"docs": "https://tcggraph.com/docs/authentication"
}
}
}Unknown id
curl "https://api.tcggraph.com/v1/cards/pkm_nope_1" \
-H "Authorization: Bearer $TCGGRAPH_API_KEY"const response = await fetch("https://api.tcggraph.com/v1/cards/pkm_nope_1", {
headers: {
Authorization: `Bearer ${process.env.TCGGRAPH_API_KEY}`,
},
});
const body = await response.json();
if (!response.ok) throw new Error(`${body.error.code}: ${body.error.message}`);import os
import requests
response = requests.get(
"https://api.tcggraph.com/v1/cards/pkm_nope_1",
headers={"Authorization": f"Bearer {os.environ['TCGGRAPH_API_KEY']}"},
)
body = response.json()
response.raise_for_status(){
"error": {
"code": "not_found",
"message": "No card with id \"pkm_nope_1\"."
}
}A path with no endpoint
curl "https://api.tcggraph.com/v1/card/pkm_sv3pt5_6" \
-H "Authorization: Bearer $TCGGRAPH_API_KEY"const response = await fetch("https://api.tcggraph.com/v1/card/pkm_sv3pt5_6", {
headers: {
Authorization: `Bearer ${process.env.TCGGRAPH_API_KEY}`,
},
});
const body = await response.json();
if (!response.ok) throw new Error(`${body.error.code}: ${body.error.message}`);import os
import requests
response = requests.get(
"https://api.tcggraph.com/v1/card/pkm_sv3pt5_6",
headers={"Authorization": f"Bearer {os.environ['TCGGRAPH_API_KEY']}"},
)
body = response.json()
response.raise_for_status(){
"error": {
"code": "not_found",
"message": "No endpoint at GET /v1/card/pkm_sv3pt5_6.",
"details": {
"reference": "https://tcggraph.com/docs/rest",
"openapi": "https://tcggraph.com/openapi.json"
}
}
}Same watch twice
curl -X POST "https://api.tcggraph.com/v1/watches" \
-H "Authorization: Bearer $TCGGRAPH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"cardId":"pkm_sv3pt5_6","source":"cardmarket","direction":"below","threshold":20}'const response = await fetch("https://api.tcggraph.com/v1/watches", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TCGGRAPH_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"cardId": "pkm_sv3pt5_6",
"source": "cardmarket",
"direction": "below",
"threshold": 20
}),
});
const body = await response.json();
if (!response.ok) throw new Error(`${body.error.code}: ${body.error.message}`);import os
import requests
response = requests.post(
"https://api.tcggraph.com/v1/watches",
headers={"Authorization": f"Bearer {os.environ['TCGGRAPH_API_KEY']}"},
json={"cardId": "pkm_sv3pt5_6", "source": "cardmarket", "direction": "below", "threshold": 20},
)
body = response.json()
response.raise_for_status(){
"error": {
"code": "conflict",
"message": "That watch already exists on this account."
}
}Handling errors
const response = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });
if (!response.ok) {
// A 405 has no body; everything else is JSON.
const { error } = response.status === 405 ? { error: { code: "method_not_allowed" } } : await response.json();
switch (error.code) {
case "invalid_request":
// error.details.field is the parameter; error.details.allowed the valid values.
throw new Error(`${error.message} (${error.details?.field})`);
case "rate_limited":
if (error.details.limit === "rate_limit") {
// Retry-After seconds, then try again.
} else {
// monthly_allowance (a hard cap is set): stop until the next period or the cap is lifted.
}
break;
default:
throw new Error(`${response.status} ${error.code}: ${error.message}`);
}
}What to retry
| Response | Retry? |
|---|---|
| 429, details.limit = rate_limit | Yes, after Retry-After seconds (60). |
| 429, monthly_allowance | Not in a loop: a hard cap lifts at the next period, or when you raise the plan or turn the cap off. See limits. |
| 5xx | Yes, with exponential backoff. |
| 402 | No. It needs a person: a plan (no_active_plan) or a top-up (balance_empty). |
| 400, 401, 403, 404, 405, 409 | No. The same request gets the same answer; change the request. |
async function withRetry(call, attempts = 4) {
for (let i = 0; ; i++) {
const response = await call();
const retryable =
response.status >= 500 ||
(response.status === 429 && (await response.clone().json()).error.details?.limit === "rate_limit");
if (!retryable || i === attempts - 1) return response;
const after = Number(response.headers.get("Retry-After"));
await new Promise((r) => setTimeout(r, Number.isFinite(after) && after > 0 ? after * 1000 : 2 ** i * 500));
}
}GraphQL errors
A GraphQL request refused before the document runs (no key, a limit) gets the envelope above. Once it runs, errors come back in GraphQL’s own errors array, with extensions.code set to BAD_REQUEST, GRAPHQL_VALIDATION_FAILED or invalid_request. The GraphQL reference has the full list and real responses.