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 — and lookup of a single completed certification by its cert number.

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. Pick your language once with the tabs; the choice applies to every example on the page. Catalog endpoints need the CATALOG_READ scope. Certification lookup needs CERT_READ. /me works with any valid key, so you can see which scopes you hold.

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"

Look up one completed certification by the number printed on the slab. Leading zeros are optional. This call needs the CERT_READ scope.

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

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/certs/{certNumber}

One completed certification. {certNumber} is the number printed on the label: a numeric id, optionally left-padded with zeros to nine digits (000202027 and 202027 are the same cert).

Needs the CERT_READ scope. A key that only has CATALOG_READ receives 403 insufficient_scope.

Returns 404 not_found when the number is unknown, the card is still in grading, or it was never given a final grade. Those three cases are indistinguishable on purpose.

Images are the catalog stock photos of the card, not photographs of the physical slab. front or back is null when the catalog has no image on that side.

pop_report and total_graded are the worldwide population of this catalog card, using the same assembler and invariant as /sets/{setId}/cards. They can lag the live population in the same way the set endpoints can.

json
{
  "cert_number": "000202027",
  "grade": "Pristine",
  "final_grade": 10.0,
  "centering": 10.0,
  "corners": 10.0,
  "edges": 10.0,
  "surface": 9.5,
  "card_id": 1042328,
  "card_name": "Charizard",
  "card_number": "4/102",
  "finish": "Holofoil",
  "rarity": "Holo Rare",
  "variant": "1st Edition",
  "language": "English",
  "set_id": 4098,
  "set_name": "Base Set",
  "game_code": "PKM",
  "year": "1999",
  "completed_on": "2026-03-12T00:00:00Z",
  "images": {
    "front": "https://example.invalid/front.png",
    "back": "https://example.invalid/back.png"
  },
  "total_graded": 40,
  "pop_report": {
    "authentic": 0,
    "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": 3, "flawless": 0
  }
}

grade is the label printed on the slab (Pristine, Gem Mint 10, Flawless, Authentic, or a numeric such as 8.5). final_grade is the numeric value. A 10.0 still splits into four named buckets in pop_report; this one card's own bucket is the one that matches its four sub-grades.

GET/me

json
{
  "account_id": 12,
  "account_name": "Acme Cards",
  "company": "Acme Ltd",
  "key_prefix": "pcg_live_a1b2c3d",
  "key_label": "production",
  "scopes": ["CATALOG_READ", "CERT_READ"],
  "allowed_regions": ["us"],
  "submission_enabled_by_region": {"us": false},
  "enabled_features": ["catalog", "certs"],
  "limits": [
    {"name": "read_requests_per_minute", "value": 600, "limit_scope": "per_pod"},
    {"name": "write_requests_per_minute", "value": 30, "limit_scope": "per_pod"},
    {"name": "webhook_verify_test_replay_per_minute", "value": 6, "limit_scope": "per_pod"},
    {"name": "max_items_per_order", "value": 100, "limit_scope": "account"},
    {"name": "max_webhook_endpoints", "value": 5, "limit_scope": "account"}
  ]
}

An account HQ has not granted any office returns empty allowed_regions and submission_enabled_by_region; its catalog and cert access is unchanged.

scopes is the list of what this key may call:

Scope Endpoints
CATALOG_READ /sets, /sets/{setId}, /sets/{setId}/cards
CERT_READ /certs/{certNumber}
ORDER_READ order, item, manifest and shipment reads (not yet generally enabled)
ORDER_WRITE order submit and cancel (not yet generally enabled)
WEBHOOKS_READ webhook delivery history (not yet generally enabled)
WEBHOOKS_WRITE webhook endpoint lifecycle (not yet generally enabled)

/me itself requires no scope. Existing keys were issued with CATALOG_READ only; certification lookup needs a key that carries CERT_READ. Regional discovery (/regions, submission options, intake address) also works with any valid key, but only for offices HQ has granted that account. Empty grants do not default to an office. A test (sandbox) office appears only to an account HQ has granted it to, so an integration can be exercised end to end before going live. ORDER_* and WEBHOOKS_* are issued only when HQ enables a marketplace integration; they are not implied by catalog or cert scopes.

/me also returns allowed_regions, submission_enabled_by_region, enabled_features and limits. Inactive and sentinel offices are omitted. enabled_features is catalog / certs / orders / webhooks from the key's scopes. orders needs ORDER_READ with any granted office, or ORDER_WRITE with a submission-enabled one. per_pod limits are counted per server instance and are therefore approximate; see Rate limits for which are counted per key and which per account.

GET/regions

Returns the offices this account may use. Optional country_code is a two-letter office country filter (use GB, not UK), not a nearest-office picker. It is case-insensitive and surrounding whitespace is ignored. Anything else, including a supplied but empty value, is 400; a code that matches none of your granted offices is 200 with data: [].

json
{
  "data": [
    {
      "region": "us",
      "office_id": "7",
      "office_name": "PCG Example Office",
      "country_code": "US",
      "submission_enabled": true,
      "intake_available": true
    }
  ]
}

intake_available is true only when submissions are enabled for this account there and the office has an approved, complete intake address right now.

GET/regions/{region}/submission-options

Offerings HQ has enabled for this account and region, plus the public workflow stage keys. The region must be granted; it is never inferred. Each offering includes declared_value_required, declared_value_currency and declared_value_limit (JSON null when HQ has not set a cap). An inactive office, or one whose workflow publishes no stages, is 503 submission_configuration_unavailable.

json
{
  "region": "us",
  "submission_enabled": true,
  "billing": {"mode": "CONTRACT", "agreement_reference": "fixture-agreement"},
  "offerings": [{
    "code": "standard-traditional",
    "input_mode": "traditional",
    "priority_code": "Standard",
    "slab_types": ["Normal", "Player"],
    "declared_value_required": true,
    "declared_value_currency": "USD",
    "declared_value_limit": 1000.00,
    "return_targets": ["END_CUSTOMER"]
  }],
  "allowed_return_country_codes": ["US"],
  "max_items": 100,
  "workflow_stages": [
    {"stage_key": "INTAKE", "is_done": false, "is_cancelled": false},
    {"stage_key": "GRADING", "is_done": false, "is_cancelled": false},
    {"stage_key": "DONE", "is_done": true, "is_cancelled": false},
    {"stage_key": "REVIEW", "is_done": false, "is_cancelled": false},
    {"stage_key": "CANCELLED", "is_done": false, "is_cancelled": true}
  ]
}

billing is null until HQ has recorded an agreement for this account and region.

workflow_stages lists the region's published stages in order, then its review stage (when the workflow has one) and its cancellation stage. New stage keys may appear; rely on is_done / is_cancelled, not on a fixed list of keys. A workflow change can take up to 30 seconds to appear here.

allowed_return_country_codes lists the ISO countries a graded card may be returned to under this grant: the office's own country plus any HQ has added. An empty list means no return destination is currently allowed.

GET/regions/{region}/intake-address

The approved PCG ship-to address for checkout grading upsell. Available before any order exists and does not create one. Discovery responses are Cache-Control: no-store.

json
{
  "region": "us",
  "office_id": "7",
  "office_name": "PCG Example Office",
  "address_id": "701",
  "address_revision": "ia_3f1c…",
  "recipient": {
    "name": "PCG Grading Intake",
    "company": "Premier Card Grading",
    "phone": "+12025550123",
    "email": "[email protected]"
  },
  "address": {
    "line1": "100 Example Intake Street",
    "line2": null,
    "city": "Example City",
    "administrative_area": "CA",
    "postal_code": "90001",
    "country_code": "US"
  },
  "shipping_instructions": "Include the PCG submission manifest inside the parcel.",
  "reference_instructions": "Use the accepted submission_reference in the carrier reference field.",
  "updated_at": "2026-09-21T00:00:00Z"
}

Optional fields are null, never invented. When it cannot be served:

Status error When
403 region_not_allowed The region is not granted to this account
403 submissions_disabled The region is granted for reads only
503 submission_configuration_unavailable The office is inactive
503 intake_address_unavailable No approved intake, intake switched off by HQ, or the approved address is deactivated or incomplete

address_revision is an opaque token; send it back unchanged when creating an order. It changes whenever HQ changes the approval or the address itself is edited, and updated_at is the later of those two edits.

Every response includes X-PCG-Request-Id; quote it when you contact us about a failed request. Mutating partner bodies are capped at 256 KiB, counted as they are read, so a chunked body is capped too.

Submitted card identity is capped at 255 characters for names/set/game/finish/rarity, 128 for card number, and 64 for language. Declared value is a non-negative decimal string with at most 12 integral digits and 2 fractional digits.


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())

The same invariant holds for a cert lookup (cert.total_graded and cert.pop_report). 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.

Writes (POST, PUT, PATCH, DELETE) do not count against the 600. They have their own budget of 30 per minute shared by every key on the account, and webhook verify, test and replay calls will have one of 6 per minute per account. These are per server instance as well, and /me reports all three under limits. No partner write route is generally available yet.

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 or cert number 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
403 region_not_allowed The region is not granted to this account
403 submissions_disabled The region is granted for reads only
404 not_found No such set, the set has no graded cards, or no completed certification with that number
413 payload_too_large A write body over 256 KiB
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.
503 submission_configuration_unavailable The office cannot take submissions right now
503 intake_address_unavailable The office has no approved intake address right now
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. The one exception is a write body over 256 KiB, which is refused with the enveloped 413 before routing.
  • 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 and carries CATALOG_READ; a valid key without it gets 403 insufficient_scope. 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.