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.
Authorization: Bearer pcg_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxUse 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:
curl -H "Authorization: Bearer $PCG_API_KEY" \
"https://app.premiercardgrading.com/partner/v1/me"const res = await fetch(
"https://app.premiercardgrading.com/partner/v1/me",
{ headers: { Authorization: `Bearer ${process.env.PCG_API_KEY}` } }
);
const data = await res.json();import os, requests
res = requests.get(
"https://app.premiercardgrading.com/partner/v1/me",
headers={"Authorization": f"Bearer {os.environ['PCG_API_KEY']}"},
)
data = res.json()var client = HttpClient.newHttpClient();
var request = HttpRequest.newBuilder()
.uri(URI.create("https://app.premiercardgrading.com/partner/v1/me"))
.header("Authorization", "Bearer " + System.getenv("PCG_API_KEY"))
.build();
var response = client.send(request, HttpResponse.BodyHandlers.ofString());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:
curl -H "Authorization: Bearer $PCG_API_KEY" \
"https://app.premiercardgrading.com/partner/v1/me"const res = await fetch(
"https://app.premiercardgrading.com/partner/v1/me",
{ headers: { Authorization: `Bearer ${process.env.PCG_API_KEY}` } }
);
const data = await res.json();import os, requests
res = requests.get(
"https://app.premiercardgrading.com/partner/v1/me",
headers={"Authorization": f"Bearer {os.environ['PCG_API_KEY']}"},
)
data = res.json()var client = HttpClient.newHttpClient();
var request = HttpRequest.newBuilder()
.uri(URI.create("https://app.premiercardgrading.com/partner/v1/me"))
.header("Authorization", "Bearer " + System.getenv("PCG_API_KEY"))
.build();
var response = client.send(request, HttpResponse.BodyHandlers.ofString());List graded sets, filtered by game and paged:
curl -H "Authorization: Bearer $PCG_API_KEY" \
"https://app.premiercardgrading.com/partner/v1/sets?gameCode=PKM&limit=50"const res = await fetch(
"https://app.premiercardgrading.com/partner/v1/sets?gameCode=PKM&limit=50",
{ headers: { Authorization: `Bearer ${process.env.PCG_API_KEY}` } }
);
const data = await res.json();import os, requests
res = requests.get(
"https://app.premiercardgrading.com/partner/v1/sets?gameCode=PKM&limit=50",
headers={"Authorization": f"Bearer {os.environ['PCG_API_KEY']}"},
)
data = res.json()var client = HttpClient.newHttpClient();
var request = HttpRequest.newBuilder()
.uri(URI.create("https://app.premiercardgrading.com/partner/v1/sets?gameCode=PKM&limit=50"))
.header("Authorization", "Bearer " + System.getenv("PCG_API_KEY"))
.build();
var response = client.send(request, HttpResponse.BodyHandlers.ofString());Fetch one set on its own:
curl -H "Authorization: Bearer $PCG_API_KEY" \
"https://app.premiercardgrading.com/partner/v1/sets/4098"const res = await fetch(
"https://app.premiercardgrading.com/partner/v1/sets/4098",
{ headers: { Authorization: `Bearer ${process.env.PCG_API_KEY}` } }
);
const data = await res.json();import os, requests
res = requests.get(
"https://app.premiercardgrading.com/partner/v1/sets/4098",
headers={"Authorization": f"Bearer {os.environ['PCG_API_KEY']}"},
)
data = res.json()var client = HttpClient.newHttpClient();
var request = HttpRequest.newBuilder()
.uri(URI.create("https://app.premiercardgrading.com/partner/v1/sets/4098"))
.header("Authorization", "Bearer " + System.getenv("PCG_API_KEY"))
.build();
var response = client.send(request, HttpResponse.BodyHandlers.ofString());Fetch the cards in a set, each with its full population breakdown:
curl -H "Authorization: Bearer $PCG_API_KEY" \
"https://app.premiercardgrading.com/partner/v1/sets/4098/cards?limit=50"const res = await fetch(
"https://app.premiercardgrading.com/partner/v1/sets/4098/cards?limit=50",
{ headers: { Authorization: `Bearer ${process.env.PCG_API_KEY}` } }
);
const data = await res.json();import os, requests
res = requests.get(
"https://app.premiercardgrading.com/partner/v1/sets/4098/cards?limit=50",
headers={"Authorization": f"Bearer {os.environ['PCG_API_KEY']}"},
)
data = res.json()var client = HttpClient.newHttpClient();
var request = HttpRequest.newBuilder()
.uri(URI.create("https://app.premiercardgrading.com/partner/v1/sets/4098/cards?limit=50"))
.header("Authorization", "Bearer " + System.getenv("PCG_API_KEY"))
.build();
var response = client.send(request, HttpResponse.BodyHandlers.ofString());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. |
{
"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. |
{
"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
{
"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:
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, flawlessEvery 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
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:
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))
doneThe 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:
X-RateLimit-Remaining: 587When 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. |
{ "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 bare405with an empty body. - An
Acceptheader we cannot satisfy gives a bare406with an empty body, because the response is always JSON. - A path that does not exist under
/partner/v1returns404in the platform's own format once your key is valid. Without a valid key it is401in 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:
categoryis oftennull. It is set for some games and not others. Where present it is the game's category (for exampleTCG), 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.yearis derived from a free-text release-date field. It is the first19xxor20xxin that text. Anything outside 1900 to 2099 reads asnull, as does text with no year. We would rather give you nothing than a guess. Theyearfilter is a plain string match against that derived value, so an unmatched value simply returns no results rather than an error.languageon the set is oftennullwhile the cards carry a value. Read language at the card level.distinct_cardscounts 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_nameis the card's name, which for sports is the player. PCG also grades trading-card games, where "player" does not apply.finishis the closest field to what sports collectors call a parallel, andrarityandvariantsit 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.