Premier Card Grading Partner API v1
Base URLhttps://app.premiercardgrading.com/partner/v1

Premier Card Grading Partner API (v1)

Population data for every card PCG has graded: which sets exist, how many of each card have been graded, and the full grade breakdown per card.

Base URL https://app.premiercardgrading.com/partner/v1

This document is the contract. If anything here disagrees with the API, the API is right and this document is wrong. Please tell us so we can fix it.


Authentication

Every request needs an API key, which Premier Card Grading issues to you.

text
Authorization: Bearer pcg_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Use that exact header. The scheme is matched case-insensitively, and a key sent without the Bearer prefix is also accepted, but neither of those is guaranteed, so send the standard form.

Confirm your key works with a call to /me:

GET/me
curl -H "Authorization: Bearer $PCG_API_KEY" \
  "https://app.premiercardgrading.com/partner/v1/me"

Your key is shown once, when it is created. We do not store the key itself. We keep only a hash of it and its first 16 characters, which is the non-secret key_prefix shown by /me. We cannot read a key back to you, so if it is lost we issue a new one. Treat a key like a password: keep it on your server, and never put it in a browser, a mobile app, or anywhere a customer can see it.

You can hold several keys at once, which is how you rotate without downtime. Ask us for a new key, deploy it, then ask us to revoke the old one. If a key leaks, tell us and we will revoke it. A revoked key stops working on its next request.

Call GET /partner/v1/me to confirm which account and key you are using. It is the quickest way to check a key works before you build anything else.


Quick start

Every call is a GET with your key in the Authorization header. These four endpoints are the whole API. Pick your language once with the tabs; the choice applies to every example on the page.

Confirm your key and see which account you are:

GET/me
curl -H "Authorization: Bearer $PCG_API_KEY" \
  "https://app.premiercardgrading.com/partner/v1/me"

List graded sets, filtered by game and paged:

GET/sets?gameCode=PKM&limit=50
curl -H "Authorization: Bearer $PCG_API_KEY" \
  "https://app.premiercardgrading.com/partner/v1/sets?gameCode=PKM&limit=50"

Fetch one set on its own:

GET/sets/4098
curl -H "Authorization: Bearer $PCG_API_KEY" \
  "https://app.premiercardgrading.com/partner/v1/sets/4098"

Fetch the cards in a set, each with its full population breakdown:

GET/sets/4098/cards?limit=50
curl -H "Authorization: Bearer $PCG_API_KEY" \
  "https://app.premiercardgrading.com/partner/v1/sets/4098/cards?limit=50"

Endpoints

GET/sets

Every set with at least one graded card.

Parameter Type Notes
category string Exact match, case-insensitive. Often absent. See Data notes.
year string Four-digit year.
gameCode string PCG game code, for example PKM or fab.
offset integer Default 0.
limit integer Default 50, maximum 200.
json
{
  "set_count": 3633,
  "offset": 0,
  "limit": 2,
  "sets": [
    {
      "set_id": 4098,
      "set_name": "LSS Pre Grade Promos",
      "game_code": "fab",
      "category": "TCG",
      "year": "2023",
      "language": null,
      "total_graded": 7296,
      "distinct_cards": 123
    }
  ]
}

set_count is the total number of sets matching your filters, not the size of this page. It means the same thing whether or not you paginate.

total_graded and distinct_cards on this endpoint come from a catalog-wide roll-up. The two single-set endpoints below count each set on their own. All of these figures are cached and refreshed in the background, per server instance, so keep three things in mind:

  • Any figure can lag the live population. Usually by a few minutes, and by up to half an hour on an instance that has not been asked about a set recently.
  • Neither endpoint is reliably fresher than the other, so a difference between them does not tell you which is newer.
  • Two identical requests can return different numbers, because we run several instances and each caches on its own.

What always holds within a single response is that /sets/{setId}/cards agrees with itself: its total_graded is the sum over the cards in that response. If you need one consistent view of the whole catalog, do a single pass rather than comparing the endpoints against each other.

GET/sets/{setId}

One set, with the same fields as an entry in sets[]. Returns 404 if the set has no graded cards. This is judged against the same cached roll-up as the listing, so a set that has just had its first card graded can still return 404 until that instance refreshes, and a set whose grades were withdrawn can return 200 with zero counts for a short while.

GET/sets/{setId}/cards

The set, plus a page of its graded cards.

Parameter Type Notes
offset integer Default 0.
limit integer Default 50, maximum 200.
json
{
  "set_id": 4098,
  "set_name": "LSS Pre Grade Promos",
  "game_code": "fab",
  "category": "TCG",
  "year": "2023",
  "language": null,
  "total_graded": 7296,
  "distinct_cards": 123,
  "card_count": 123,
  "offset": 0,
  "limit": 1,
  "cards": [
    {
      "card_id": 1042328,
      "card_name": "Scabskin Leathers",
      "card_number": "FAB003",
      "finish": "Golden Cold Foil",
      "rarity": "Legendary",
      "variant": null,
      "language": "English",
      "total_graded": 40,
      "pop_report": {
        "authentic": 40,
        "1": 0, "1.5": 0, "2": 0, "2.5": 0, "3": 0, "3.5": 0, "4": 0, "4.5": 0,
        "5": 0, "5.5": 0, "6": 0, "6.5": 0, "7": 0, "7.5": 0, "8": 0, "8.5": 0,
        "9": 0, "9.5": 0,
        "10": 0, "gem_mint_10": 0, "pristine": 0, "flawless": 0
      }
    }
  ]
}

The set-level fields are repeated at the top level so one call gives you everything about the set and its cards. card_count is how many graded cards this endpoint can list for the set, and cards is one page of them. distinct_cards is how many cards the population figures were computed over. These two numbers are the same except for a short window after a card has been moved out of a set or removed from the catalog, when the population still counts grades the listing can no longer show.

GET/me

json
{
  "account_id": 12,
  "account_name": "Acme Cards",
  "company": "Acme Ltd",
  "key_prefix": "pcg_live_a1b2c3d",
  "key_label": "production",
  "scopes": ["CATALOG_READ"]
}

The grade scale

PCG's scale runs from 1 to 10 in half steps, plus authentic for cards that were authenticated but not given a numeric grade.

A final grade of 10 is not a single grade. It splits into four buckets, decided by the four sub-grades (centering, corners, edges, surface):

Bucket Meaning
10 A 10 whose sub-grades match none of the patterns below
gem_mint_10 Two sub-grades at 10, two at 9.5
pristine Three sub-grades at 10, one at 9.5
flawless All four sub-grades at 10

The four are mutually exclusive, so no card is counted twice. If you are used to a single flat 10, note that collapsing these buckets undercounts the top of the market. A Flawless is much rarer and more valuable than a Gem Mint 10.

The pop_report keys, in order, are:

text
authentic, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9, 9.5,
10, gem_mint_10, pristine, flawless

Every key is always present, so a 0 means "no copies at this grade", not "we did not report this grade".

Only an exact 10.0 splits into the four named buckets. Any value off the standard ladder, historical or otherwise, is reported under its own key (for example "10.5" or "0.5") rather than rounded into a neighbour or dropped. Read pop_report as a map and loop over its keys rather than assuming exactly these 23.

The invariant you can rely on

text
card.total_graded == sum(card.pop_report.values())

This holds for every card, every time. If you ever see it fail, that is a bug, so please report it.


Populations are global

A population is counted worldwide. PCG grades in several regions, and a card graded in New Zealand belongs to the same population as one graded in the United States. There is no per-region population and no region parameter.


Pagination

Both list endpoints use offset paging. limit defaults to 50 and is capped at 200; asking for more gives you 200 rather than an error. A limit of zero or less gives you the default 50, and a negative offset is treated as 0.

The order is stable and part of the contract, since offset paging depends on it. Sets are ordered by set_name, then by set_id. Cards are ordered by the leading number in card_number read as a number (so 2 comes before 10), then by that leading part as text, then by card_id.

To walk everything, page until you have seen set_count (or card_count) rows:

bash
offset=0
while :; do
  page=$(curl -sS -w '\n%{http_code}' -H "Authorization: Bearer $PCG_API_KEY" \
    "https://app.premiercardgrading.com/partner/v1/sets?limit=200&offset=$offset")
  status=$(tail -n1 <<<"$page"); body=$(sed '$d' <<<"$page")
  # Check the status first. An error response has no .sets, so ".sets | length" reads as 0, and a
  # 429 or 500 would otherwise end the loop looking like a finished sync.
  [ "$status" = 200 ] || { echo "HTTP $status. Retry, honouring Retry-After." >&2; exit 1; }
  count=$(jq '.sets | length' <<<"$body")
  [ "$count" -eq 0 ] && break
  jq -c '.sets[]' <<<"$body"
  offset=$((offset + count))
done

The catalog changes while you page, because grading is continuous. For a full sync, prefer a single pass and accept that later pages are slightly fresher than earlier ones.


Rate limits

The limit is 600 requests per minute, counted separately for each key. The number itself is a single setting shared across all keys, so raising it for you raises it for everyone. Tell us if you need more and we will discuss it, but build against 600.

Every successful response tells you how much of the budget is left:

text
X-RateLimit-Remaining: 587

When you go over, you get 429 with a Retry-After header in seconds. Wait that long before retrying.

The limit is enforced per server instance, so you may get somewhat more than 600 before being throttled. Do not rely on that headroom. It is not a guarantee and it will tighten.

Repeated failed authentication from one source is limited separately and more strictly. After a few rejected keys in quick succession you will get 429 (rate_limited) instead of 401 until the minute rolls over, and that applies even to a valid key sent from the same source in that window. You will not hit this in normal use; it exists to absorb credential stuffing. If you are rotating a key, deploy the new one before retrying rather than looping on the old one.


Errors

Failures return JSON with an error code and a human-readable message.

Status error What it means
400 bad_request A parameter we could not read, for example a set id that is not a number
401 invalid_credential Missing, malformed, unknown, or revoked key
403 account_suspended Your key is valid, but the account is suspended. Contact us.
403 insufficient_scope This key does not carry the scope the endpoint needs
404 not_found No such set, or the set has no graded cards
429 rate_limited Over the rate limit. Honour Retry-After.
500 internal_server_error A problem on our side. Retry, and tell us if it persists.
json
{ "error": "invalid_credential", "message": "Provide a valid API key as: Authorization: Bearer <key>." }

Those two fields are the whole body. Branch on error, show or log message, and do not expect anything else.

There are three exceptions, all from the web framework rather than the API itself, and all reached only by a malformed request:

  • A real path called with the wrong HTTP method (every endpoint here is GET) is rejected during routing, before our code runs. You get a bare 405 with an empty body.
  • An Accept header we cannot satisfy gives a bare 406 with an empty body, because the response is always JSON.
  • A path that does not exist under /partner/v1 returns 404 in the platform's own format once your key is valid. Without a valid key it is 401 in the standard envelope, because authentication is checked before routing.

Send a GET with Accept: application/json to a documented path and you will only ever see the standard envelope.

A revoked key and an unknown key both return invalid_credential. We do not confirm whether a key ever existed.


Data notes

Honest answers to questions integrators ask:

  • category is often null. It is set for some games and not others. Where present it is the game's category (for example TCG), and it falls back to the set's own category when the game has none, so two sets in the same game can differ. It is not a sport. Do not make it a required filter or a primary grouping.
  • year is derived from a free-text release-date field. It is the first 19xx or 20xx in that text. Anything outside 1900 to 2099 reads as null, as does text with no year. We would rather give you nothing than a guess. The year filter is a plain string match against that derived value, so an unmatched value simply returns no results rather than an error.
  • language on the set is often null while the cards carry a value. Read language at the card level.
  • distinct_cards counts cards that have been graded, not the number of cards in the set. It is never larger than the set, and equal only when every card in the set has been graded.
  • card_name is the card's name, which for sports is the player. PCG also grades trading-card games, where "player" does not apply. finish is the closest field to what sports collectors call a parallel, and rarity and variant sit alongside it.

Versioning

The path carries the major version. Within v1 we may add fields and endpoints, so parse defensively and ignore anything you do not recognise. A change that would break a working integration, such as removing or renaming a field or changing a type or a grade bucket, goes into v2, and we will tell you before it ships.


Support

For questions, a higher rate limit, a leaked key, or anything in this document that does not match what the API does, contact your Premier Card Grading representative.