TCG Vendor Base API

A read API for trading-card catalog data and market prices — cards and sealed products across multiple games, sourced from TCGplayer via tcgcsv. Prices in USD with a daily MYR snapshot.

Overview

Base URL

https://api.tcgvendorbase.com

All endpoints live under /api/v1/, accept GET only (except /cards/scan), and return JSON. Every request needs a bearer token from a registered app (see below). Responses are edge-cached, so repeat reads are fast.

Quick start:
curl -H "Authorization: Bearer YOUR_ID_TOKEN" \
  "https://api.tcgvendorbase.com/api/v1/cards?q=charizard&game=pokemon"

Authentication

Send your end user's identity token as a bearer token on every call. There is no shared API key — the X-API-Key header was retired and is no longer accepted.

Authorization: Bearer YOUR_ID_TOKEN

Each consuming app is registered by its token issuer. Both Supabase Auth and Firebase Auth are supported; the API verifies the token against that issuer's public keys, so no secret is shared and nothing sensitive ships in a browser bundle. Contact the API owner to register an app.

Registration also fixes the app's scopes. A token is valid but still refused if its app lacks the scope an endpoint requires:

ScopeGrants
cards:read/cards, /cards/{id}, /sets/{id}/cards
prices:read/prices, and ?with_prices=1 on any endpoint
sealed:read/sealed, /sealed/{id}
sets:read/sets
scan/cards/scan
ConditionResponse
Header missing401 {"error":"missing Authorization bearer token"}
Issuer not registered401 {"error":"unrecognized token issuer"}
Bad signature / wrong audience401 {"error":"invalid token"}
Token past exp401 {"error":"token expired"}
App lacks the scope403 {"error":"missing scope: …"}
Over the per-minute limit429 {"error":"rate limit exceeded"}
Tokens are short-lived — Firebase ID tokens expire after an hour, Supabase access tokens sooner. Fetch a fresh one per request from your auth SDK (getIdToken() / getSession()) rather than caching the string.

Games & languages

Every product carries a game and a lang. Search endpoints accept both as optional filters (omit to search across all).

Currently available

game = pokemon lang = en lang = ja
Japanese cards use English names. The catalog stores Japanese-set cards with their English names (TCGplayer is a US source). To find Japanese printings, search by the English name and filter with lang=ja — e.g. ?q=charizard&lang=ja.

Caching & prices

  • Caching — GET responses are cached at the edge (s-maxage=3600, stale-while-revalidate=86400). Expect data up to ~1 hour old.
  • Currency — *_usd fields come straight from TCGplayer (USD). market_myr is a snapshot computed at ingest using fx_rate (USD→MYR).
  • Freshness — prices are refreshed from the source about daily; captured_at is the source's publish time.
  • Variants — a product has one price row per sub_type_name (e.g. Holofoil, Normal, Reverse Holofoil, or Unopened for sealed).
  • Prices — fetch with a batch /prices call using the product_ids returned by a search.

Try it — live console

Requests run against this same API (same origin, so no CORS setup needed). Your token is sent only to this API; tick remember to keep it in this browser only. Inputs are pre-filled with working examples.

Request
Response
—

Get a card

GET /api/v1/cards/{id}

Full detail for one card by its product_id. Returns 404 if the id is not a card (e.g. it's a sealed product).

curl -H "Authorization: Bearer YOUR_ID_TOKEN" \
  "https://api.tcgvendorbase.com/api/v1/cards/84189"
{
  "product_id": 84189,
  "group_id": 1863,
  "game": "pokemon",
  "lang": "en",
  "product_type": "card",
  "name": "Charizard",
  "card_number": "4/102",
  "image_url": "https://...",
  "tcgplayer_url": "https://...",
  "extended": [
    { "name": "Number", "displayName": "Number", "value": "4/102" },
    { "name": "Rarity", "displayName": "Rarity", "value": "Holo Rare" }
  ]
}

Get a sealed product

GET /api/v1/sealed/{id}

Detail for one sealed product by product_id. Returns 404 if the id is not sealed. Same response shape as Get a card (with product_type: "sealed").

List sets

GET /api/v1/sets

Every set (TCGplayer group), newest published first. Use a set's group_id to list its cards via Cards in a set.

ParamTypeNotes
lang optionalstringen or ja. Omit for all languages.

Example

curl -H "Authorization: Bearer YOUR_ID_TOKEN" \
  "https://api.tcgvendorbase.com/api/v1/sets?lang=en"
{
  "results": [
    {
      "group_id": 1863,
      "name": "SM Base Set",
      "abbreviation": "SM",
      "lang": "en",
      "category_id": 3,
      "published_on": "2017-02-03"
    }
  ]
}

Cards in a set

GET /api/v1/sets/{id}/cards

Every card in a set by its group_id, paginated 60 per page and ordered by collector number. Same product shape as Search cards (without similarity).

ParamTypeNotes
id requiredintSet group_id (path segment).
page optionalint1-based; default 1. 60 results per page.

Example

curl -H "Authorization: Bearer YOUR_ID_TOKEN" \
  "https://api.tcgvendorbase.com/api/v1/sets/1863/cards?page=1"
{
  "page": 1,
  "total": 102,
  "results": [
    {
      "product_id": 84189,
      "game": "pokemon",
      "lang": "en",
      "name": "Charizard",
      "card_number": "4/102",
      "image_url": "https://...",
      "image_url_fallback": "https://...",
      "tcgplayer_url": "https://...",
      "set_name": "SM Base Set"
    }
  ]
}

Batch prices

GET /api/v1/prices

Current prices for up to 200 products in one call. Returns one row per variant; works for cards and sealed alike.

ParamTypeNotes
ids requiredstringComma-separated product_ids. Max 200.
picked optionalstringauto collapses each product to a single variant by priority (Holofoil > Normal > Reverse Holofoil, else first priced).
history optionalbool1 returns the daily price series instead of current prices. Max 10 ids in this mode. Ignores picked.
days optionalintWindow for history=1. Default 90, max 730.
spark optionalbool1 returns chart-ready series for the card detail page — all five windows in one call. Max 5 ids. Takes precedence over history.
curl -H "Authorization: Bearer YOUR_ID_TOKEN" \
  "https://api.tcgvendorbase.com/api/v1/prices?ids=84189,91601"
{
  "results": [
    {
      "product_id": 84189,
      "sub_type_name": "Holofoil",
      "product_type": "card",
      "lang": "en",
      "game": "pokemon",
      "market_usd": 138.08,
      "market_myr": 547.86,
      "low_usd": 105.00,
      "mid_usd": 572.12,
      "high_usd": 1390.55,
      "direct_low_usd": 999.99,
      "fx_rate": 3.9677,
      "captured_at": "2026-05-30T20:04:57+00:00"
    }
  ]
}

Price history

One series per variant, oldest first. A row is appended only on the days the market price actually changed.

curl -H "Authorization: Bearer YOUR_ID_TOKEN" \
  "https://api.tcgvendorbase.com/api/v1/prices?ids=84189&history=1&days=90"
{
  "days": 90,
  "results": [
    { "product_id": 84189, "sub_type_name": "Holofoil", "d": "2026-05-22", "market_usd": 131.40, "market_myr": 533.52 },
    { "product_id": 84189, "sub_type_name": "Holofoil", "d": "2026-06-04", "market_usd": 138.08, "market_myr": 560.63 }
  ]
}

Reading the series. Three things will trip you up if you treat it as a dense daily feed:

  • Gaps mean the price held steady. A row is only written when the market price actually moves, so consecutive dates are usually days or weeks apart. Draw it as a step line, carrying each value forward until the next point.
  • The first point may predate your window. If a card's price last moved before days ago, that older point is still returned as the opening value — otherwise the chart would render empty for a card that simply hasn't moved. Its d can therefore be older than days.
  • A null market_usd is meaningful. It marks the day the card stopped having a market price at all, which is different from holding steady.

market_myr is converted at that day's USD→MYR rate, not today's, so the series is comparable over time. Weekend and holiday dates use the last published weekday rate.

Chart series (spark=1)

Built for a card detail page: one request serves all five windows. Two series come back per printing, and their point counts nest, so switching window is a slice of what you already have — no second request.

curl -H "Authorization: Bearer YOUR_ID_TOKEN" \
  "https://api.tcgvendorbase.com/api/v1/prices?ids=274439&spark=1"
{
  "as_of": "2026-08-19",
  "windows": {
    "7d":  { "series": "daily",  "take": 7  },
    "30d": { "series": "daily",  "take": 30 },
    "3m":  { "series": "weekly", "take": 13 },
    "6m":  { "series": "weekly", "take": 26 },
    "1y":  { "series": "weekly", "take": 53 }
  },
  "results": [
    {
      "product_id": 274439,
      "sub_type_name": "Holofoil",
      "daily":  { "covered_since": "2026-07-21", "points": [ ... 30 points ... ] },
      "weekly": { "covered_since": "2026-01-07", "points": [
        { "d": "2026-08-19", "close_usd": 4.34, "low_usd": 3.64,
          "high_usd": 4.42, "close_myr": 17.61, "fx_rate": 4.0579 }
      ]}
    }
  ]
}

Rendering it. Take the last take points of the named series — e.g. 6m is the last 26 weekly points. Plot close_usd as the line.

  • Use low_usd/high_usd, don't ignore them. They are the extremes actually reached inside that bucket. On weekly points the close can understate the week's high by 30–40%, so a line drawn from closes alone hides real spikes. Daily points hold one observation each, so their band is zero-width by construction.
  • Series can be shorter than take. A printing that didn't exist a year ago has no year of history — about a fifth of the catalogue is younger than six months. covered_since is the first date present; label the chart from it (“Since Mar”) rather than claiming a full window.
  • Points are evenly spaced, not one-per-change. Each is the price in force on that date, so the x-axis is uniform and safe to plot directly — unlike history=1, which is sparse and needs forward-filling.
  • low_myr/high_myr aren't sent; multiply by that point's fx_rate.

Response fields

Product (search results & detail)

FieldTypeDescription
product_idintStable id (TCGplayer productId). Use it for prices.
group_idintSet id.
set_namestringSet name (search results only).
product_typestringcard or sealed.
game / langstringe.g. pokemon / en|ja.
namestringProduct name.
image_url / tcgplayer_urlstringImage and TCGplayer page.
similarityfloatMatch score 0–1 (search results only).
extendedarrayRaw attribute list (detail only): rarity, number, etc.

Price

FieldTypeDescription
sub_type_namestringVariant.
market_usdnumberMarket price (USD).
market_myrnumberMYR snapshot at ingest.
low_usd / mid_usd / high_usd / direct_low_usdnumberOther TCGplayer price points (USD).
fx_ratenumberUSD→MYR rate used.
captured_attimestampSource publish time.

Price history (history=1)

FieldTypeDescription
product_idintThe product this series belongs to.
sub_type_namestringVariant. Each printing has its own series.
ddateCapture date (UTC). May predate the window for the opening point.
market_usdnumberMarket price that day. null = the card had no market price.
market_myrnumberConverted at that day's rate, not today's.

Errors

Errors return a JSON body {"error":"…"} with the matching status code.

StatusMeaning
400Bad request — missing q/ids, invalid lang, unknown game, bad id, etc.
401Missing, malformed, expired, or unrecognized bearer token.
403Your app lacks the scope this endpoint requires.
404Not found — including type mismatches (a card id on /sealed/{id} or vice-versa).
429Per-minute rate limit exceeded. See Retry-After and X-RateLimit-Remaining.
503Auth registry temporarily unreachable. Retry.