<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: ggxforce</title>
    <description>The latest articles on DEV Community by ggxforce (@ggxforce).</description>
    <link>https://dev.to/ggxforce</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4139570%2F7c0548e4-f0ab-46ed-824c-c2b50fa90752.jpg</url>
      <title>DEV Community: ggxforce</title>
      <link>https://dev.to/ggxforce</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ggxforce"/>
    <language>en</language>
    <item>
      <title>What 'the price of a card' means when your users buy in Europe</title>
      <dc:creator>ggxforce</dc:creator>
      <pubDate>Wed, 23 Sep 2026 13:28:31 +0000</pubDate>
      <link>https://dev.to/ggxforce/what-the-price-of-a-card-means-when-your-users-buy-in-europe-2ggp</link>
      <guid>https://dev.to/ggxforce/what-the-price-of-a-card-means-when-your-users-buy-in-europe-2ggp</guid>
      <description>&lt;p&gt;I built a Pokémon TCG API for the European market, after two years of running a collection app on US-centric data. What I got wrong about prices, currencies, print lines and 'the number'."&lt;br&gt;
tags: api, postgres, go, buildinpublic.&lt;/p&gt;

&lt;p&gt;Two years ago I started a card-collection app for the European market. The very first feature request was the obvious one: "what is this card worth?" And the very first thing I learned is that the data I could get my hands on answered a different question than the one my users were asking.&lt;/p&gt;

&lt;p&gt;They buy on European marketplaces, in euros, often a French or German printing, and increasingly Japanese cards that never got a Western release. The catalogue data I could find gave me one number per card, in dollars, from one US marketplace, for the English printing, with no date on it. Not wrong. Just an answer to somebody else's question.&lt;/p&gt;

&lt;p&gt;So I built the backend the app needed. Then developers started asking for the backend instead of the app, and I opened it as an API. This is what I got wrong along the way, and what the API does differently because of it. It runs in Europe, on one dedicated box, in Go on top of Postgres, and today it holds about 57,800 cards in 651 sets, of which 380 are Japanese and 96 are Simplified Chinese, plus about 2,100 sealed products (numbers as of 23 September 2026, live on the coverage page).&lt;/p&gt;
&lt;h2&gt;
  
  
  "The price" is five questions
&lt;/h2&gt;

&lt;p&gt;"What is this card worth?" sounds like one question. It is five: on which market, in which currency, in what condition, for which printing, and as of when. A single number silently answers all five for you, and in Europe it usually answers them wrong.&lt;/p&gt;

&lt;p&gt;So the API does not return a price. It returns rows, and every row carries its own answers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CARDMARKET"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"variant"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"LOW"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"basis"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ASKING"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;412.00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"EUR"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"locale"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"printing"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"HOLOFOIL"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"grading"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"as_of"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-22"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sample_n"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"provenance"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Cardmarket"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;source&lt;/code&gt; says who observed the figure. &lt;code&gt;basis&lt;/code&gt; says what kind of figure it is: &lt;code&gt;ASKING&lt;/code&gt; is what a seller wants today, &lt;code&gt;GUIDE&lt;/code&gt; is a reference the source computes from its own history, &lt;code&gt;DERIVED&lt;/code&gt; is something we computed ourselves. &lt;code&gt;locale&lt;/code&gt; is the language of the printing. &lt;code&gt;as_of&lt;/code&gt; is the day the figure is for. &lt;code&gt;provenance&lt;/code&gt; is a string you can print next to the number.&lt;/p&gt;

&lt;p&gt;Next to that row sits a TCGplayer guide figure in USD, for the same card. The two disagree, and they should: one is the cheapest copy a European seller is asking for, the other is a guide computed on the US market. They are two facts about two markets. An API that averaged them would produce a number nobody can find on any website, and the first customer who challenges it would be right.&lt;/p&gt;

&lt;h2&gt;
  
  
  EUR and USD never get averaged
&lt;/h2&gt;

&lt;p&gt;This was the rule that cost me the most to learn. Early on I had a "price" column. It was in euros for some sources and dollars for others, and a conversion ran in a job somewhere. The number looked fine on a list page, until a collector compared it with the marketplace and asked which of the two was lying.&lt;/p&gt;

&lt;p&gt;Now a Cardmarket row is in EUR because Cardmarket trades in EUR, and a TCGplayer row is in USD for the same reason. The API never converts a quote for you. Conversion is a product decision: it depends on the day, the rate you trust, and what you tell your user about it. That belongs to the thing showing the price, not to the thing storing it.&lt;/p&gt;

&lt;p&gt;There is one exception, and it is published rather than hidden. Sorting a collection by value needs one comparable figure per card, so we compute a composite index in EUR: take the current rows for one card, one language, one printing; keep only market-level figures from sources we may redistribute; drop series with no observation in thirty days; convert the non-EUR ones with the ECB daily reference rate; take the &lt;strong&gt;median&lt;/strong&gt;, not the mean, because with two or three sources one bad quote moves a mean a long way and a median almost not at all. The result is a row like any other, with &lt;code&gt;source: "PTCG_INDEX"&lt;/code&gt;, &lt;code&gt;basis: "DERIVED"&lt;/code&gt; and &lt;code&gt;sample_n&lt;/code&gt; equal to the number of figures behind it. It exists to sort. Show your user the row from their own market.&lt;/p&gt;

&lt;h2&gt;
  
  
  A French box is not an English box
&lt;/h2&gt;

&lt;p&gt;Sealed product taught me the same lesson from another angle. On a European marketplace an English booster box and a French booster box of the same set are two listings with two prices, often far apart. My first model had one product with one price, and the French collector was looking at the English figure.&lt;/p&gt;

&lt;p&gt;Now a sealed product has a &lt;code&gt;kind&lt;/code&gt;, the set it belongs to, a pack count, an image, and a price route where &lt;code&gt;locale&lt;/code&gt; is the language the box was printed in. Same row shape as a card, minus the fields a box does not have: &lt;code&gt;condition&lt;/code&gt;, &lt;code&gt;printing&lt;/code&gt; and &lt;code&gt;grading&lt;/code&gt; are always null there. Match the row to what the user actually holds.&lt;/p&gt;

&lt;h2&gt;
  
  
  A region is a catalogue, not a translation
&lt;/h2&gt;

&lt;p&gt;The Japanese line was the second wall. It releases first, it has more sets than the Western line, and a growing share of European collectors buy it. Every dataset I found treated it either as missing or as a translation of the Western set with the same name.&lt;/p&gt;

&lt;p&gt;It is neither. A Japanese set has its own code, its own card list and numbering, its own release date, and often cards that never crossed the ocean. So the API keeps three print regions, &lt;code&gt;WEST&lt;/code&gt;, &lt;code&gt;JP&lt;/code&gt; and &lt;code&gt;CN&lt;/code&gt;, as three catalogues. A Japanese card is a card in its own right, with its own id, number, rarity, illustrator and image. Where we know a Western card and a Japanese card are the same printing in two languages, the Western card carries a &lt;code&gt;jp_twin_id&lt;/code&gt;; where we do not know, it is null, and null means "unknown", never "was not printed in Japan". I would rather serve a null than a wrong twin, because the twins are exactly the cards whose prices differ most between the two markets.&lt;/p&gt;

&lt;p&gt;Two things people confuse, so the API keeps them apart: the region says where a card was printed; &lt;code&gt;lang&lt;/code&gt; says which language you want the card's &lt;em&gt;name&lt;/em&gt; in. &lt;code&gt;lang=ja&lt;/code&gt; on a Western card returns its Japanese name. It does not turn it into a Japanese card.&lt;/p&gt;

&lt;h2&gt;
  
  
  The engineering choices that came out of this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rows first, numbers derived.&lt;/strong&gt; Every price is an observation with a source and a date; every single number the API serves is computed from those rows by a published rule. When a customer challenges a figure, the answer is a query, not an apology.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A dimension that is not in the key silently picks a winner.&lt;/strong&gt; One marketplace publishes two lowest asking prices per language, one over all sellers and one over sellers shipping within the EU. My series identity did not include that distinction, so whichever row arrived last overwrote the other. A customer reading his rows carefully caught it. The fix put the distinction in the key and exposed it on the row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A rule applied in two places has to agree with itself in both.&lt;/strong&gt; Sources carry a 24-hour delay before a figure is served. The cache kept the newest row per series and the delay was enforced only when serving. For a daily source, those two rules together made a whole series disappear: the only cached row was too young, it was discarded, and the servable one from yesterday was gone. Two hundred thousand rows in the cache, zero quotes in responses. Now the delay is applied when choosing what to cache.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cursor pagination and ETags everywhere&lt;/strong&gt;, because the first thing every collector app does is download the whole catalogue, and the second thing is do it again tomorrow. A change feed written by database triggers gives a sync position as a single integer, so "what changed since yesterday" is one small call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Say what is not there.&lt;/strong&gt; The catalogue has no game text on Japanese and Chinese cards, no sets printed in Korea, and thinner price coverage on older Japanese sets. The coverage page measures all of that live, from the API, and the docs say it in plain words. This turned out to matter more than I expected: about half of our signups now come from answers given by AI assistants, and the pages they cite are the ones that make verifiable claims, gaps included.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I am still unsure about
&lt;/h2&gt;

&lt;p&gt;Whether a credit model (1 credit for a catalogue call, 2 for prices) reads better than plain request counts. Whether the 24-hour delay is a feature or a reason people leave. And what makes a developer's first call to a new API happen faster: a playground, a copy-paste curl, a Postman collection, or an SDK. Four out of ten signups never make a single call, and I would like to understand why.&lt;/p&gt;

&lt;p&gt;If you build for collectors in Europe, or you have run an API where "the number" turned out to be several numbers, I would genuinely like to hear how you handled it. The docs, with a trial key that needs no card, are at &lt;a href="https://pokemontcgapi.com/?utm_source=devto&amp;amp;utm_campaign=post_eu-price-api" rel="noopener noreferrer"&gt;https://pokemontcgapi.com/?utm_source=devto&amp;amp;utm_campaign=post_eu-price-api&lt;/a&gt;&lt;/p&gt;

</description>
      <category>pokemonapi</category>
      <category>tcgapi</category>
      <category>api</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
