Build a One Piece Card Price Tracker with the BerryWallet API

🔵 IN THIS GUIDE: A dependency-free Node.js price tracker for One Piece cards. It pulls TCGPlayer and CardMarket prices from the BerryWallet API, keeps its own price history and alerts you when a card moves. Every request and field name below comes from real API responses captured on October 4, 2026.

Quick Answer: To track One Piece card prices, call GET https://api.pokewallet.io/op/prices?set_code=OP01 with your key in the X-API-Key header. You get every card variant in the set with TCGPlayer prices (USD) and CardMarket prices (EUR) in one response: 159 rows for Romance Dawn alone, out of 78 sets in the catalog. Store a snapshot on each run, compare it with the previous one, and alert on the change. On October 4, 2026, the Zoro OP01-001 Parallel leader showed a TCGPlayer market price of $619.64 and a CardMarket trend of €610.16, while the regular Zoro was at $2.06. The full tracker is about 120 lines of plain JavaScript, needs Node 18+ and has no npm dependencies.

This is the One Piece version of our Pokémon card price tracker tutorial. It's the same idea, but the data has its own quirks. One Piece variants share card numbers, many promos have no CardMarket listing, and set codes range from OP01 to ST-04 PRE. A tracker that ignores these quirks will report wrong prices without any visible error. The code below handles all three.

We focus on one job: a script you run on a schedule that tells you when a card on your watchlist has moved. We skip the UI and the database server at first. A watchlist file, a history file and a cron line cover most of what collectors and small shop owners need. Once that works, the production section covers what to change when it grows.

If you haven't used the API yet, our overview of fetching real-time OPCG card prices explains the data model. For how BerryWallet compares with other options, see BerryWallet vs one-piece-api.com.

Key Takeaways

  • 🔑 Track the id field, not card_number. EB02-010 points to two different Luffy products: one at $1,534.62 and one at $30.99. Keying on the card number mixes them up.
  • 📦 Fetch prices one set at a time. /op/prices?set_code=OP01 returns the whole set in one call. A 50-card watchlist from one set costs one or two requests instead of 50.
  • 💱 Never compare TCGPlayer and CardMarket numbers directly. TCGPlayer is in USD and CardMarket is in EUR. $619.64 vs €610.16 doesn't mean the markets agree until you convert one of them.
  • 🕳 Expect cardmarket: null. Both Luffy promos in our sample have no CardMarket data. Code that reads .cardmarket.prices.trend without a null check will crash on the first promo.
  • 📈 Use market_price for alerts, not high_price. The Dodgers Luffy has a high_price of $16,999 against a market price of $1,534.62. One overpriced listing shouldn't trigger an alert.
  • ⏱ Save a snapshot only when updated_at changes. Running the script more often than the data refreshes fills your history with duplicate rows and flattens your charts.
  • 🚨 CardMarket's avg1 vs avg30 gives you a free spike detector. Zoro Parallel's 1-day average (€770) was 25.4% above its 30-day average (€613.93). That's often the first sign of a move.


What You Will Build

You'll build a command-line tracker that does four things on each run:

  1. Reads a watchlist.json file listing the cards you care about, each with optional alert rules.
  2. Fetches current prices, grouping cards by set so each set costs as few requests as possible. Cards whose set you don't know are looked up through search.
  3. Adds a snapshot to history.json, but only when the source data has actually refreshed.
  4. Prints a price table and lists every alert that fired: target price reached, move since the last snapshot, or a short-term spike on CardMarket.

The project has three files: client.js (HTTP and retries), tracker.js (the logic) and watchlist.json (your cards). Run it by hand or from cron. The history file it builds is your price history, which matters because the One Piece endpoints don't include a history endpoint (see the FAQ).

Our test case is Roronoa Zoro, OP01-001, the leader from Romance Dawn. Its two versions differ in price by a factor of about 300, which makes it a good stress test for variant handling:

CardVariantTCGPlayer marketTCGPlayer lowCardMarket trendCardMarket 30-day avg
Roronoa Zoro (001)Normal$2.06$1.49€1.87€2.09
Roronoa Zoro (001) (Parallel)Foil$619.64$695.01€610.16€613.93
Monkey.D.Luffy (010) (Dodgers x ONE PIECE)Foil$1,534.62$1,599.95— (no CardMarket data)—
Monkey.D.Luffy (Sound Loader – Luffy Edition 2025)Normal$30.99$30.99— (no CardMarket data)—

Data from the BerryWallet API, captured October 4, 2026 (TCGPlayer updated_at 11:49, CardMarket updated_at 10:22).

Notice that the Zoro Parallel's TCGPlayer low price ($695.01) is above its market price ($619.64). The market price reflects recent sales, and the low price is the cheapest current listing. When the low price is above the market price, sellers are asking more than buyers recently paid. That's the kind of signal a tracker should surface.

Prerequisites

  • Node.js 18 or newer. We use the built-in fetch and top-level await in ES modules. You don't need to install any packages.
  • A BerryWallet API key (covered in Step 1).
  • Basic JavaScript. If you can read an async function, you can follow along.
  • Optional: curl for the first test request, and a machine that can run cron (any Linux box, a Mac, or a small VPS).

Create the project:

mkdir op-price-tracker && cd op-price-tracker
npm init -y
npm pkg set type=module

Setting "type": "module" lets you use import and top-level await without a build step.

Step 1: Get Your API Key

BerryWallet is the One Piece side of the PokéWallet API. It uses the same host and the same key, with every One Piece route under /op/. Sign in at /auth/login, then create a key from your dashboard. Plan tiers and request quotas are listed on the pricing page. There's a free tier, and that's enough for a personal watchlist.

Every request authenticates with one header:

X-API-Key: your_key_here

The authentication section of the BerryWallet docs covers the details. Two rules for this tutorial:

  • Keep the key out of the code. Put it in an environment variable, so it never ends up in a commit.
  • Don't call the API from a browser. Anyone who opens dev tools can read a key shipped to the front end. This tracker runs on a server or your own machine, which is where the key belongs.
export BERRYWALLET_API_KEY="your_key_here"

Step 2: The First Request

Before writing any JavaScript, check that your key works with curl:

curl -s -H "X-API-Key: $BERRYWALLET_API_KEY" \
  "https://api.pokewallet.io/op/search?q=luffy&limit=2"

Here is the real response (trimmed: long IDs shortened, a few fields removed):

{
  "success": true,
  "data": [
    {
      "id": "op_7a01fd97…046f",
      "card_number": "EB02-010",
      "name": "Monkey.D.Luffy (010) (Dodgers x ONE PIECE)",
      "sub_type_name": "Foil",
      "rarity": "L",
      "card_type": "Leader",
      "tcgplayer": {
        "url": "https://www.tcgplayer.com/product/641620",
        "prices": {
          "low_price": 1599.95,
          "mid_price": 2432.5,
          "high_price": 16999,
          "market_price": 1534.62,
          "updated_at": "2026-10-04T11:49:43.703577"
        }
      },
      "cardmarket": null
    }
  ],
  "total": 250,
  "page": 1,
  "limit": 2
}

This one response shows three of the problems the tracker has to handle:

  • card_number is not unique. The second result, which we trimmed out, is also EB02-010: the Sound Loader Luffy at $30.99. The id is the only field that identifies a single product.
  • cardmarket can be null. Many promos and US-only products aren't listed on CardMarket.
  • total: 250. A broad query like "luffy" matches hundreds of rows. Search is for finding cards, not for watching them.

The same request in Node:

// first.js
const res = await fetch('https://api.pokewallet.io/op/search?q=luffy&limit=2', {
  headers: { 'X-API-Key': process.env.BERRYWALLET_API_KEY },
});
const body = await res.json();

for (const card of body.data) {
  const tcg = card.tcgplayer?.prices;
  console.log(
    card.id.slice(0, 12),
    card.card_number,
    card.sub_type_name.padEnd(6),
    `$${tcg?.market_price ?? '—'}`,
    card.name,
  );
}
node first.js
# op_7a01fd975 EB02-010 Foil   $1534.62 Monkey.D.Luffy (010) (Dodgers x ONE PIECE)
# op_e1cc8f79c EB02-010 Normal $30.99 Monkey.D.Luffy (Sound Loader - Luffy Edition 2025)

Now look at the endpoint the tracker actually relies on, /op/prices:

curl -s -H "X-API-Key: $BERRYWALLET_API_KEY" \
  "https://api.pokewallet.io/op/prices?set_code=OP01&limit=2"
{
  "success": true,
  "group_id": "3188",
  "data": [
    {
      "id": "op_f80044ea…b5cd",
      "card_number": "OP01-001",
      "name": "Roronoa Zoro (001) (Parallel)",
      "variant": "Foil",
      "tcgplayer": {
        "low_price": 695.01, "mid_price": 772.5, "high_price": 3000,
        "market_price": 619.64, "direct_low_price": null,
        "updated_at": "2026-10-04T11:49:43.703577"
      },
      "cardmarket": {
        "avg": 547.2, "low": 500, "trend": 610.16,
        "avg1": 770, "avg7": 630.83, "avg30": 613.93,
        "updated_at": "2026-10-04T10:22:10.294748"
      }
    }
  ],
  "total": 159
}

This response has a different shape from search. Prices sit directly under tcgplayer and cardmarket instead of under .prices, and the variant field is called variant instead of sub_type_name. The tracker reads from both endpoints, so we'll normalize the two shapes in one function.

To find set codes, call /op/sets?language=en. It returned 78 sets when we checked, and the codes aren't consistent: OP01, OP-PR, ST-04 PRE. Codes can contain spaces, so always URL-encode them. The URL API in the client below does that for you.

Step 3: Building It Out

The client: one place for HTTP, auth and retries

// client.js
const BASE_URL = 'https://api.pokewallet.io';
const API_KEY = process.env.BERRYWALLET_API_KEY;
if (!API_KEY) throw new Error('Set BERRYWALLET_API_KEY before running the tracker');

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

export async function apiGet(path, params = {}, { retries = 4 } = {}) {
  const url = new URL(path, BASE_URL);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v));

  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url, { headers: { 'X-API-Key': API_KEY } });

    if (res.status === 429 || res.status >= 500) {
      if (attempt >= retries) throw new Error(`HTTP ${res.status} after ${retries} retries: ${url}`);
      await sleep(1000 * 2 ** attempt + Math.random() * 250); // 1s, 2s, 4s, 8s + jitter
      continue;
    }
    if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);

    const body = await res.json();
    if (!body.success) throw new Error(`API returned success=false for ${url}`);
    return body;
  }
}

// Collects every row for a paginated endpoint. Stops on a short page or once `total` is reached,
// so it terminates even if the endpoint ignores `page`.
export async function apiGetAll(path, params = {}, pageSize = 100, maxPages = 20) {
  const rows = [];
  for (let page = 1; page <= maxPages; page++) {
    const body = await apiGet(path, { ...params, limit: pageSize, page });
    rows.push(...body.data);
    if (body.data.length < pageSize || rows.length >= (body.total ?? 0)) break;
  }
  return rows;
}

Pagination: list responses include total, and search and set responses also return page and limit. Check the prices endpoint docs for the maximum limit on your plan. If it's below 100, the loop simply makes more requests. The two stop conditions mean the loop can't run forever, and because the tracker indexes results by id, any duplicate rows are harmless.

The watchlist

Each entry needs an id. Add set_code if you know it, which lets the tracker use the cheap per-set fetch. If you don't know the set, add a q search term instead and the tracker will look the card up through search.

[
  {
    "id": "op_f80044eaa90595e9caa8d1344cabd70506f20a1eb072a7f233954ef8fbc3f6d3ab0e39d140eddf015c4d23225fd5b1cd",
    "label": "Zoro OP01-001 Parallel",
    "set_code": "OP01",
    "alertBelow": 550,
    "alertMovePct": 8
  },
  {
    "id": "op_0f7d2687ec1143b5118027ccb9fb52060de6c5eb63cb4e7bf04f9b7b7666d2ad51f0acbabeff512c13fbd2fb4538de82",
    "label": "Zoro OP01-001",
    "set_code": "OP01",
    "alertMovePct": 25
  },
  {
    "id": "op_7a01fd975e2bd571726d00b78a4590439b2607f952480b25627cd12df29f84b11bdadcc4bf33add08bf09ac0d51d01046f",
    "label": "Luffy EB02-010 Dodgers",
    "q": "luffy dodgers",
    "alertBelow": 1400,
    "alertMovePct": 10
  }
]

Each card gets its own move threshold. A 25% swing on a $2 card is noise. An 8% swing on a $600 card is about $50, which is enough to act on.

The tracker

// tracker.js
import { readFile, writeFile } from 'node:fs/promises';
import { apiGetAll } from './client.js';

const SPIKE_PCT = 20; // CardMarket 1-day avg vs 30-day avg

const watchlist = JSON.parse(await readFile('watchlist.json', 'utf8'));
const history = await readFile('history.json', 'utf8').then(JSON.parse).catch(() => []);

// /op/prices nests prices directly; /op/search nests them under `.prices`. Flatten both.
function normalize(row) {
  const tcg = row.tcgplayer?.prices ?? row.tcgplayer ?? null;
  const cm = row.cardmarket?.prices ?? row.cardmarket ?? null;
  return {
    id: row.id,
    name: row.name,
    variant: row.variant ?? row.sub_type_name,
    tcgMarket: tcg?.market_price ?? null, // USD
    tcgLow: tcg?.low_price ?? null,
    tcgUpdated: tcg?.updated_at ?? null,
    cmTrend: cm?.trend ?? null,           // EUR
    cmAvg1: cm?.avg1 ?? null,
    cmAvg30: cm?.avg30 ?? null,
    cmUpdated: cm?.updated_at ?? null,
  };
}

async function fetchCurrent(items) {
  const found = new Map();

  // One paginated call per set, however many cards you watch in it.
  const sets = [...new Set(items.filter((i) => i.set_code).map((i) => i.set_code))];
  for (const set_code of sets) {
    for (const row of await apiGetAll('/op/prices', { set_code })) found.set(row.id, normalize(row));
  }

  // Cards without a set code: search, then match on id.
  for (const item of items.filter((i) => !i.set_code && i.q)) {
    const rows = await apiGetAll('/op/search', { q: item.q }, 100, 5);
    const hit = rows.find((r) => r.id === item.id);
    if (hit) found.set(hit.id, normalize(hit));
  }
  return found;
}

const pct = (now, before) => ((now - before) / before) * 100;

function checkAlerts(item, cur, prev) {
  const alerts = [];
  if (item.alertBelow != null && cur.tcgMarket != null && cur.tcgMarket <= item.alertBelow) {
    alerts.push(`market $${cur.tcgMarket} is at/below target $${item.alertBelow}`);
  }
  if (item.alertMovePct != null && prev?.tcgMarket && cur.tcgMarket != null) {
    const move = pct(cur.tcgMarket, prev.tcgMarket);
    if (Math.abs(move) >= item.alertMovePct) {
      alerts.push(`market moved ${move.toFixed(1)}% since ${prev.tcgUpdated} ($${prev.tcgMarket} → $${cur.tcgMarket})`);
    }
  }
  if (cur.cmAvg1 && cur.cmAvg30) {
    const spike = pct(cur.cmAvg1, cur.cmAvg30);
    if (Math.abs(spike) >= SPIKE_PCT) alerts.push(`CardMarket 1-day avg ${spike.toFixed(1)}% vs 30-day avg`);
  }
  return alerts;
}

const current = await fetchCurrent(watchlist);
const now = new Date().toISOString();
const report = [];

for (const item of watchlist) {
  const cur = current.get(item.id);
  if (!cur) { console.warn(`⚠️  ${item.label}: not found — check id / set_code / q`); continue; }

  const prev = history.filter((h) => h.id === item.id).at(-1);
  const alerts = checkAlerts(item, cur, prev);

  // Only store a snapshot when the source data has actually refreshed.
  if (!prev || prev.tcgUpdated !== cur.tcgUpdated || prev.cmUpdated !== cur.cmUpdated) {
    history.push({ fetchedAt: now, ...cur });
  }

  report.push({
    card: item.label,
    variant: cur.variant,
    'TCG $': cur.tcgMarket ?? '—',
    'TCG low $': cur.tcgLow ?? '—',
    'CM trend €': cur.cmTrend ?? '—',
    alerts: alerts.length,
  });
  for (const a of alerts) console.log(`🚨 ${item.label}: ${a}`);
}

console.table(report);
await writeFile('history.json', JSON.stringify(history, null, 2));

Running it against the October 4 data gives:

🚨 Zoro OP01-001 Parallel: CardMarket 1-day avg 25.4% vs 30-day avg
┌─────────┬──────────────────────────┬─────────┬─────────┬───────────┬────────────┬────────┐
│ (index) │ card                     │ variant │ TCG $   │ TCG low $ │ CM trend € │ alerts │
├─────────┼──────────────────────────┼─────────┼─────────┼───────────┼────────────┼────────┤
│ 0       │ 'Zoro OP01-001 Parallel' │ 'Foil'  │ 619.64  │ 695.01    │ 610.16     │ 1      │
│ 1       │ 'Zoro OP01-001'          │ 'Normal'│ 2.06    │ 1.49      │ 1.87       │ 0      │
│ 2       │ 'Luffy EB02-010 Dodgers' │ 'Foil'  │ 1534.62 │ 1599.95   │ '—'        │ 0      │
└─────────┴──────────────────────────┴─────────┴─────────┴───────────┴────────────┴────────┘

On the first run, the only alert that fires is the spike detector. The move alerts need a previous snapshot, so they can't fire until the second run. The spike alert is a real signal. CardMarket's 1-day average for the Zoro Parallel was €770, while the 7-day average was €630.83 and the 30-day average €613.93. A single day's average can come from just one or two sales, so read it as "go look at recent sales", not "the price has changed". That's also why we don't alert on the 7-day average, which was only 2.8% above the 30-day figure.

The Luffy row shows the null handling: there's no CardMarket price, so the table prints — and the tracker carries on. If you remove the optional chaining (?.) in normalize, this row crashes the script.

Scheduling it

crontab -e
# every 6 hours; the updated_at check stops duplicate snapshots
0 */6 * * * cd /home/you/op-price-tracker && BERRYWALLET_API_KEY=xxx /usr/bin/node tracker.js >> tracker.log 2>&1

We haven't published a refresh frequency for One Piece prices in this tutorial, and you don't need one. The updated_at check means running too often costs you a request but doesn't corrupt your history.

Handling Rate Limits

Rate limits depend on your plan and are documented in the rate limits section of the BerryWallet docs, with plan quotas on /pricing. We won't repeat the numbers here because they're per plan and can change. What matters is designing the tracker so the limit rarely matters:

  • Batch by set. This is the biggest saving. Watching 40 OP01 cards takes 2 requests at limit=100 (159 rows), not 40. The fetchCurrent function groups the watchlist by set before making any calls.
  • Use search only for discovery. A broad search like "luffy" has 250 matches, so looking up one card can take several pages. Use search once to find a card's id, then set its set_code in the watchlist once you know it. The search fallback is capped at 5 pages for this reason.
  • Back off on 429 and 5xx. apiGet retries with exponential backoff and jitter (1s, 2s, 4s, 8s) and then gives up loudly. A tracker that fails visibly is better than one that silently skips a run.
  • Never retry 4xx errors other than 429. A 401 means your key is wrong and a 404 means your path is wrong. Retrying won't fix either one. The errors reference lists the codes.
  • Run requests in sequence. The loops above run one request at a time on purpose. For a personal watchlist, parallel requests only make you hit the limit sooner.

If you outgrow per-set batching, for example tracking every card in every set, check the quotas on /pricing. Don't build a tighter retry loop to squeeze more out of a lower tier.

Going to Production

The script above works well for a personal watchlist. Here's what to change when other people, or money, depend on it:

  1. Move history out of a JSON file. history.json is rewritten in full on every run, which is fine for a few thousand rows and wasteful beyond that. Use SQLite with a table keyed on (id, tcg_updated, cm_updated) and a unique constraint. The database then does the deduplication.
  2. Keep the two currencies in separate columns. Store tcg_market_usd and cm_trend_eur as two columns and never merge them into one "price" field. If you want a single number, convert at query time with a dated exchange rate, and say which rate you used. This matters most if you're deciding between selling on TCGPlayer or CardMarket.
  3. Treat the timestamps carefully. updated_at comes without a timezone offset (2026-10-04T11:49:43.703577), and the two sources update at different times. Store the strings exactly as received for deduplication, and don't treat them as local time.
  4. Send alerts somewhere you'll see them. Replace console.log with a POST to a Discord or Slack webhook, or an email. Deduplicate alerts as well: a card sitting below its target fires on every run unless you record that you already notified for that updated_at.
  5. Watch for missing cards. The not found warning matters. A product can be merged, renamed or re-listed, and a watchlist entry that silently stops updating is worse than one that errors.
  6. Store the key as a secret. Use your platform's secret manager or a .env file excluded by .gitignore. The key should never appear in the repo or in logs.
  7. Fail loudly. Exit with a non-zero code when apiGet throws, so cron monitoring (or a simple health check) notices a broken run.

A useful next step is a "spread" alert. When tcgLow is more than about 10% above tcgMarket, as with the Zoro Parallel at $695.01 vs $619.64, sellers are asking well above recent sales. That usually means either a price rise that sales haven't caught up with yet, or optimistic listings that will drop. In either case, as a buyer, don't buy at the low listing. Wait and watch the market price for another week.

For a sense of which cards deserve a tracker, our One Piece TCG rarity guide explains why a Parallel leader and its base version can be 300 times apart in price.


Frequently Asked Questions

Is the BerryWallet API free?

There's a free tier that's enough for a personal watchlist like the one in this tutorial. Request quotas and paid tiers are listed on the pricing page. If you track whole sets, check your quota before scheduling frequent runs.

What's the base URL for the One Piece card API?

https://api.pokewallet.io, with every One Piece route under /op/: /op/sets, /op/sets/{set_code}, /op/search and /op/prices. Authenticate with the X-API-Key header. The quick start has the full reference.

Why does the same card number show up twice?

One Piece reuses card numbers across variants and products. OP01-001 covers both the $2.06 Zoro and the $619.64 Parallel, and EB02-010 covers two different Luffy promos. Always store and match on the id field, which identifies a single product.

Are CardMarket prices in euros?

Yes. CardMarket figures (trend, avg, avg1, avg7, avg30) are in EUR, and TCGPlayer figures are in USD. Keep them in separate fields and convert explicitly if you need to compare them.

Why is cardmarket null for some cards?

The product has no CardMarket listing that's matched to it. This is common for US-only promos like the Dodgers Luffy. Your code should treat null as "no data", not zero, and keep tracking the TCGPlayer side.

Can I get price history for One Piece cards from the API?

The One Piece endpoints in the BerryWallet docs cover sets, cards, search, prices and images, with no separate history endpoint. That's why this tracker builds its own history from snapshots. CardMarket's avg1, avg7 and avg30 fields give you some short-term trend information from day one.

How do I get every card in a set?

Call /op/sets/{set_code} for full card details or /op/prices?set_code=... for prices only. Both return a total field, which is 159 for OP01. Use the apiGetAll helper from this tutorial to collect all the pages.

Can I write this in Python instead of JavaScript?

Yes. The API is plain HTTPS with JSON, so requests or httpx work the same way. Keep the same structure: one client with backoff, per-set batching, matching on id, and saving snapshots only when updated_at changes.


💬 Price disclaimer: All prices in this article come from live BerryWallet API responses captured on October 4, 2026. TCGPlayer data (USD) was last updated at 11:49 and CardMarket data (EUR) at 10:22 that day. Prices have changed since then. The code examples are for educational purposes, and the alerts and signals described are not financial advice.


Track One Piece Card Game Prices Live with BerryWallet

The tracker in this tutorial runs on the same TCGPlayer and CardMarket data that powers BerryWallet. If you'd rather not run a cron job yourself, the app gives you price alerts, price history and portfolio tracking with no code.

  • 🔔 Price alerts — get notified the moment a chase card hits your target
  • 📊 Live prices from TCGPlayer & CardMarket as listings appear
  • 📈 Historical charts — track every card's price movement over time
  • 💰 Portfolio tracking — see what your collection is actually worth
  • 💬 Community discussion in our Discord

Get Started:


This article is for informational purposes only and is not financial advice. Details reflect information available at the time of writing; release dates, pricing and set contents are subject to change. Card prices are volatile — always verify current market pricing before buying or selling.