Building a Trading Card Price Tracker with Free APIs
If you collect trading cards -- Pokemon, Magic: The Gathering, Yu-Gi-Oh -- you know the pain of checking prices manually. You also know that a card's market price only tells half the story. The other half is scarcity: how many PSA 10 copies exist?
In this tutorial, we will build a Node.js script that:
- Looks up current market prices from TCGPlayer
- Pulls graded population data from PSA
- Combines them into a price-to-scarcity ratio
- Sends alerts when undervalued cards are found
Total code: about 50 lines. Total cost: $0.
Prerequisites
- Node.js 18+ installed
- A text editor
- Optional: a Slack workspace for alerts
Step 1: Fetch TCGPlayer Prices
The TCGPlayer API returns current market prices, lowest listings, and card metadata. Here is how to search for a card:
const BACKEND = "https://rapidapi-backend-production.up.railway.app";
async function getCardPrices(cardName) {
const res = await fetch(
`${BACKEND}/tcgplayer/search?query=${encodeURIComponent(cardName)}&limit=10`
);
const data = await res.json();
if (!data.success) throw new Error(data.error);
return data.results.map((card) => ({
name: card.name || card.title,
set: card.set || card.subtitle,
marketPrice: card.marketPrice || card.price,
lowestPrice: card.lowestPrice || card.lowPrice,
imageUrl: card.imageUrl || card.image,
url: card.url,
}));
}
Try it out:
curl "https://rapidapi-backend-production.up.railway.app/tcgplayer/search?query=charizard&limit=5"
You will get back something like:
{
"success": true,
"count": 5,
"results": [
{
"name": "Charizard ex",
"set": "Obsidian Flames",
"marketPrice": 12.50,
"lowestPrice": 9.99,
"imageUrl": "https://..."
}
]
}
Step 2: Fetch PSA Population Data
PSA grades cards from 1-10 and publishes how many of each grade exist. A card with 500 PSA 10s is much less scarce than one with 12.
The PSA Population API takes a certification number and returns the full population breakdown:
async function getPopulationReport(certNumber) {
const res = await fetch(
`${BACKEND}/psa/pop?certNumber=${encodeURIComponent(certNumber)}`
);
const data = await res.json();
if (!data.success || data.count === 0) return null;
const report = data.results[0];
return {
cardName: report.subject || report.cardName,
year: report.year,
brand: report.brand,
grade: report.grade,
totalPopulation: report.totalPop || report.populationTotal,
psa10Count: report.psa10 || report.pop10 || 0,
psa9Count: report.psa9 || report.pop9 || 0,
};
}
Step 3: The Complete Price Tracker
Here is the full working script that ties it all together. Save this as card-tracker.js:
// card-tracker.js -- Trading card price + scarcity tracker
const BACKEND = "https://rapidapi-backend-production.up.railway.app";
// Cards to track: name (for TCGPlayer) + PSA cert number (for pop data)
const WATCHLIST = [
{ name: "Charizard Base Set", certNumber: "40522040" },
{ name: "Pikachu Illustrator", certNumber: "10217569" },
{ name: "Black Lotus Alpha", certNumber: "42003653" },
];
async function fetchJSON(url) {
const res = await fetch(url);
return res.json();
}
async function trackCard({ name, certNumber }) {
// Fetch price and population in parallel
const [priceData, popData] = await Promise.all([
fetchJSON(`${BACKEND}/tcgplayer/search?query=${encodeURIComponent(name)}&limit=1`),
fetchJSON(`${BACKEND}/psa/pop?certNumber=${certNumber}`),
]);
const price = priceData.results?.[0];
const pop = popData.results?.[0];
const marketPrice = price?.marketPrice || price?.price || 0;
const psa10Pop = pop?.psa10 || pop?.pop10 || 0;
const totalPop = pop?.totalPop || pop?.populationTotal || 0;
// Scarcity score: lower PSA 10 count = higher scarcity
// Value ratio: price relative to scarcity (lower = potentially undervalued)
const scarcityScore = psa10Pop > 0 ? Math.round(marketPrice / psa10Pop * 100) / 100 : null;
return {
name,
marketPrice,
psa10Population: psa10Pop,
totalGradedPopulation: totalPop,
pricePerPsa10: scarcityScore,
cardUrl: price?.url || null,
};
}
async function runTracker() {
console.log("Card Price + Scarcity Report");
console.log("=".repeat(60));
console.log(`Generated: ${new Date().toISOString()}\n`);
for (const card of WATCHLIST) {
try {
const result = await trackCard(card);
console.log(`${result.name}`);
console.log(` Market Price: $${result.marketPrice}`);
console.log(` PSA 10 Pop: ${result.psa10Population}`);
console.log(` Total Graded: ${result.totalGradedPopulation}`);
console.log(` $/PSA 10 Copy: ${result.pricePerPsa10 ? `$${result.pricePerPsa10}` : "N/A"}`);
console.log("");
} catch (err) {
console.log(`${card.name}: Error - ${err.message}\n`);
}
}
}
runTracker();
Run it:
node card-tracker.js
Output:
Card Price + Scarcity Report
============================================================
Generated: 2026-03-20T14:30:00.000Z
Charizard Base Set
Market Price: $285.00
PSA 10 Pop: 3407
Total Graded: 45210
$/PSA 10 Copy: $0.08
Pikachu Illustrator
Market Price: $5100000.00
PSA 10 Pop: 1
Total Graded: 41
$/PSA 10 Copy: $5100000.00
The $/PSA 10 Copy metric is what makes this interesting. A card with a high market price but very low PSA 10 population is scarce at that grade. A card with a moderate price and a dropping PSA 10 population (fewer high-grade copies surfacing) might be undervalued.
Step 4: Add Price Alerts
Now let's add email or Slack alerts when a tracked card drops below a target price or when new PSA 10s appear (diluting scarcity).
Slack Webhook Alerts
const SLACK_WEBHOOK = process.env.SLACK_WEBHOOK_URL;
async function sendAlert(message) {
if (!SLACK_WEBHOOK) {
console.log("[ALERT]", message);
return;
}
await fetch(SLACK_WEBHOOK, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: message }),
});
}
// Add price targets to your watchlist
const WATCHLIST_WITH_TARGETS = [
{ name: "Charizard Base Set", certNumber: "40522040", targetPrice: 250 },
{ name: "Black Lotus Alpha", certNumber: "42003653", targetPrice: 400000 },
];
async function checkAlerts() {
for (const card of WATCHLIST_WITH_TARGETS) {
const result = await trackCard(card);
if (result.marketPrice <= card.targetPrice) {
await sendAlert(
`Price Alert: ${result.name} is at $${result.marketPrice} (target: $${card.targetPrice}). PSA 10 pop: ${result.psa10Population}`
);
}
}
}
Email Alerts (using Nodemailer)
import nodemailer from "nodemailer";
const transporter = nodemailer.createTransport({
service: "gmail",
auth: {
user: process.env.GMAIL_USER,
pass: process.env.GMAIL_APP_PASSWORD, // Use an App Password, not your real password
},
});
async function sendEmailAlert(subject, body) {
await transporter.sendMail({
from: process.env.GMAIL_USER,
to: process.env.ALERT_EMAIL,
subject,
text: body,
});
}
Step 5: Run on a Schedule
The simplest way to run this daily is a cron job:
# Run every day at 9am
crontab -e
0 9 * * * /usr/bin/node /path/to/card-tracker.js >> /var/log/card-tracker.log 2>&1
Or if you prefer a cloud option, deploy it as a GitHub Action:
# .github/workflows/card-tracker.yml
name: Card Price Tracker
on:
schedule:
- cron: "0 14 * * *" # 9am EST
jobs:
track:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: node card-tracker.js
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
Extending It
Some ideas for where to take this:
- Historical tracking: Save results to a JSON file or SQLite database, then chart price trends over time
- Multi-marketplace: Add Reverb (for music gear) or Poshmark (for fashion) to track prices across different collectible categories
- Portfolio valuation: Track your entire collection and get a daily portfolio value report
- Arbitrage detection: Compare TCGPlayer prices to eBay sold listings to find mispriced cards
The APIs used in this tutorial (TCGPlayer and PSA Population) are available at RapidAPI with 50 free requests per month -- more than enough for a daily tracker on a small watchlist.
Top comments (0)