# Pkmn Prices API — LLM Integration Prompt

You are integrating with the Pkmn Prices API for Pokemon TCG card pricing data. Market prices are sourced from TCGPlayer (USD, by condition/variant) and Cardmarket (EUR, by condition/variant) — those two and no others. eBay contributes sold comps (USD, graded and ungraded) through `/listings/ebay` only; comps are individual sales and are never market prices, so eBay appears in no `prices` array and in no price history. Every current price row has one `market_price`; the retired Cardmarket Price Guide `low`/`trend`/`avg` fields are not returned. Every price object carries a `currency` field (`"USD"` or `"EUR"`) — never assume USD; read the currency and format the symbol accordingly (`USD`→`$`, `EUR`→`€`). Do not convert between currencies. EUR (Cardmarket) prices require the Pro plan or higher.

## Quick Reference

```
Base URL: https://api.pkmnprices.com/v1
Auth: x-api-key header (data endpoints)
Get a key: https://pkmnprices.com/dashboard
```

## Authentication

Data endpoints (`/cards`, `/sets`, `/sealed`, price history, listings) use API key auth:
```
x-api-key: pk_your_key_here
```

User account endpoints (`/me`, `/usage`, `/keys`, `/billing`) use JWT auth:
```
Authorization: Bearer your_jwt_token
```

`GET /health` requires no authentication.

## Plans

Credits are charged per item returned in the `data` array (minimum 1 per request). Limits reset daily.

| Plan | Daily Credits | Rate Limit | Max Keys | Restrictions |
|------|-------------|------------|----------|-------------|
| Free | 500 | 60/min | 1 | English cards only. USD prices only (no EUR/Cardmarket). No sealed products, price history, or listings. |
| Pro | 20,000 | 60/min | 5 | All languages (English, Japanese, German). Unlocks EUR (Cardmarket) prices, sealed, price history, and all listings. |
| Business | 200,000 | 200/min | Unlimited | Everything in Pro with higher limits. |

Check remaining credits via response headers: `x-credits-charged`, `x-credits-limit`.
Check rate limit via: `x-rate-limit`, `x-rate-remaining`.

## Languages

A card's language is a property of its **set**, and it decides which pricing that card can ever have.

| Language | Sets | Cards | Pricing | Tier |
|----------|------|-------|---------|------|
| English | 214 | 28,158 | USD (TCGplayer) + EUR (Cardmarket) | Free |
| Japanese | 443 | 29,660 | USD (TCGplayer) + EUR (Cardmarket) | Pro+ |
| German | 81 | 13,078 | **EUR (Cardmarket) only** | Pro+ |

`language` is normalised, so spell it however you like: `German`, `german`, `GERMAN`, `Deutsch`, `de` and `DE` all resolve to the same thing. Same for `English`/`en` and `Japanese`/`jp`/`ja`. Responses always return the canonical form (`"English"`, `"Japanese"`, `"German"`).

A value that isn't a language we carry is passed through untouched and simply matches nothing, rather than erroring.

**German cards have no USD price and never will.** TCGplayer does not sell German product, so their only pricing is Cardmarket in EUR. A request for German cards with `currency=usd` correctly returns nothing — that is an empty result, not an error and not a bug. Use `currency=eur` for German.

German coverage runs from HeartGold & SoulSilver (2010) to current sets; earlier German sets are not carried.

Free is restricted to English. A free key asking for `?language=Japanese` or `?language=German` gets a `403`, and a free key that omits `language` is served English only rather than the whole catalogue.

Do not confuse this with the **listing** language on the Cardmarket listing endpoints. That is the language of an individual marketplace offer, and it is a separate axis: one Cardmarket article carries offers in many languages.

## Currency

Each price object has a `currency` field (`"USD"` or `"EUR"`). Add a `currency` query param (case-insensitive) to any price endpoint to filter:

- omitted → all currencies the caller is allowed to see
- `usd` → USD only (TCGplayer)
- `eur` → EUR only (Cardmarket)

Supported on: `/v1/cards`, `/v1/cards/:id`, `/v1/cards/:id/prices/history`, `/v1/sealed`, `/v1/sealed/:id`, `/v1/sealed/:id/prices/history`. Cardmarket (EUR) prices exist on **cards and sealed**.

| Caller | `currency` | Result |
|--------|-----------|--------|
| Free | omitted or `usd` | USD only — EUR silently excluded |
| Free | `eur` | `403` — `"Cardmarket (EUR) prices require Pro or higher"` |
| Pro/Business | omitted | both USD + EUR |
| Pro/Business | `usd` / `eur` | that currency only |
| any | invalid value | `400` — `"currency must be USD or EUR"` |

Read `currency` per price and format the symbol (`USD`→`$`, `EUR`→`€`). Do not convert between currencies — display as-is.

## Pagination

Most endpoints use page-based pagination:

| Param | Type | Default | Max | Description |
|-------|------|---------|-----|-------------|
| page | integer | 1 | — | Page number |
| per_page | integer | 50 | 100 | Results per page |

Response:
```json
{
  "data": [...],
  "pagination": { "page": 1, "per_page": 50, "total": 1234, "total_pages": 25 }
}
```

The listings endpoints (`/cards/:id/listings/ebay`, `/cards/:id/listings/cardmarket`, `/cards/:id/listings/tcgplayer`, `/sealed/:id/listings/ebay`, `/sealed/:id/listings/cardmarket`, `/sealed/:id/listings/tcgplayer`) use cursor-based pagination instead (see below). Cards and sealed products carry the same three sources.

---

## Endpoints

### GET /health (No Auth)

```json
{ "status": "healthy", "database": "connected" }
```
Returns `503` with `"degraded"` / `"unreachable"` if the database is down.

---

### GET /v1/sets
List Pokemon TCG sets.

| Param | Type | Description |
|-------|------|-------------|
| name | string | Filter by set name (substring match) |
| language | string | Filter by language: `English`, `Japanese`, `German` (Pro+ for Japanese/German) |
| page | integer | Page number (default 1) |
| per_page | integer | Results per page (default 50, max 100) |

Response item:
```json
{ "id": 123, "tcg_player_id": 456, "name": "Obsidian Flames", "language": "English", "card_count": 230 }
```

### GET /v1/sets/:id
Get a single set by ID.

---

### GET /v1/cards
List cards with optional filtering. Does **not** return prices — use `GET /v1/cards/:id` to get a card's prices. Price-related params (`condition`, `variant`, `min_price`, `max_price`, `sort`) still filter/sort server-side.

| Param | Type | Description |
|-------|------|-------------|
| name | string | Filter by card name (substring match) |
| set_id | integer | Filter by set ID |
| tcg_player_id | integer | Get specific card by TCGPlayer ID |
| number | string | Filter by card number |
| total_set_number | string | Filter by total set number |
| rarity | string | Filter by rarity |
| stage | string | Filter by evolution stage (e.g. `Basic`, `Stage 1`, `Stage 2`) |
| card_type | string | Filter by card type (`Pokemon`, `Trainer`, `Energy`) |
| weakness | string | Filter by weakness type code (e.g. `F`, `R`) |
| energy_type | string | Filter by energy type (e.g. `Fire`); matches cards whose energy types include the value |
| language | string | Filter by language: `English`, `Japanese`, `German`. Free plan: English only (Japanese/German → `403`; omitted → English only). German has EUR prices only — see Languages |
| currency | string | Filter prices by currency: `usd` or `eur` (case-insensitive). Omit for all currencies you can access. EUR is Pro+ (Free + `eur` → `403`); invalid value → `400` |
| condition | string | Filter by price condition (e.g. `Near Mint`) |
| variant | string | Filter by price variant (e.g. `Holofoil`) |
| grade | string | Filter by graded tier |
| min_price | number | Minimum market price |
| max_price | number | Maximum market price |
| sort | string | `price_asc` or `price_desc` (requires price-related filter) |
| page | integer | Page number (default 1) |
| per_page | integer | Results per page (default 50, max 100) |

Response item (no `prices` — see `/v1/cards/:id`):
```json
{
  "id": 789,
  "tcg_player_id": 519184,
  "name": "Charizard ex",
  "image_url": "https://...",
  "number": "006",
  "total_set_number": "197",
  "rarity": "Double Rare",
  "artist": "PLANETA CG Works",
  "hp": 330,
  "set": { "id": 123, "name": "Obsidian Flames" }
}
```

### GET /v1/cards/:id
Get a single card by ID with all its prices.

Response includes the full card attributes, Cardmarket mapping identifiers,
and a `prices` array. `cardmarket_url` is the absolute Cardmarket product URL
and `cardmarket_product_id` is its stable numeric product ID. Both may be
`null` until a mapping has been established. Attribute fields are populated
from TCGplayer and may be `null`/empty for older cards, Trainers, or Energy:
`stage`, `card_type` (`Pokemon`/`Trainer`/`Energy`),
`weakness`, `resistance`, `retreat_cost`, `energy_type` (array), `ability`,
`flavor_text`, `attacks` (array of attack text):
```json
{
  "id": 789,
  "name": "Charizard ex",
  "cardmarket_url": "https://www.cardmarket.com/en/Pokemon/Products/Singles/Obsidian-Flames/Charizard-ex-OBF006",
  "cardmarket_product_id": 733537,
  "stage": "Stage 2",
  "card_type": "Pokemon",
  "weakness": "R",
  "resistance": null,
  "retreat_cost": 2,
  "energy_type": ["Fire"],
  "ability": "<strong>Ability — Infernal Reign</strong> ...",
  "flavor_text": null,
  "attacks": ["[2RR] Burning Darkness (180+) ..."],
  "set": { "id": 123, "name": "Obsidian Flames" },
  "prices": [
    {
      "source": "tcgplayer",
      "currency": "USD",
      "condition": "Near Mint",
      "variant": "Holofoil",
      "market_price": 42.50,
      "created_at": "2025-01-15T08:30:00Z"
    },
    {
      "source": "cardmarket",
      "currency": "EUR",
      "condition": "Near Mint",
      "variant": "Holofoil",
      "market_price": 35.00,
      "created_at": "2025-01-15T08:30:00Z"
    }
  ]
}
```

**Price sources:**
- `tcgplayer` — USD market prices with `condition` (Near Mint, Lightly Played, etc.) and `variant` (Holofoil, Reverse Holofoil, 1st Edition, etc.)
- `cardmarket` — EUR marketplace prices with native Cardmarket conditions (`Mint`, `Near Mint`, `Excellent`, `Good`, `Light Played`, `Played`, `Poor`) and printing variants. `market_price` is the lowest trusted current offer for that exact condition/variant; conditionless legacy Price Guide rows are excluded, as are signed, altered, graded and (on sealed products) opened offers (see [Special attributes](#special-attributes-signed-altered-graded-opened)), and as is any offer from a seller with no completed sales (see [`sell_count`](#sell_count)) — so the cheapest row in `/listings/cardmarket` is not always the `market_price`. **Pro+ only** — Free tier never receives these; a Free caller passing `?currency=eur` gets `403`.

Those are the only two. eBay is **not** a price source: its sold comps are individual sales rather than market prices, so they appear in no `prices` array and in no price history. They are on `/listings/ebay` only.

---

### GET /v1/cards/:id/prices/history (Pro+)
Price history for a card. Free plan returns `403`. Cardmarket history is condition-specific, so group EUR series by both `variant` and `condition`. History fields `avg`, `low`, and `high` are daily aggregates of saved condition prices; they are separate from the retired current-price Price Guide fields.

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| period | string | `30d` | Time range, format `Xd` where X is 1–365 |
| currency | string | — | `usd` or `eur`. EUR is Pro+ (Free + `eur` → `403`); invalid → `400` |
| condition | string | — | Exact match, e.g. `Near Mint` (TCGplayer) or `Excellent` (Cardmarket) |
| variant | string | — | Exact match, e.g. `Holofoil` |
| limit | integer | 30 | **Rows** per page, not days (max 365) |
| page | integer | 1 | Page number |

A row is one `(date, source, currency, condition, variant)` group, **not one
day**. A card with five TCGplayer conditions produces five rows per day, so the
default `limit` of 30 returns about six days of a 30-day window. Read
`pagination.total_pages` and page through, or raise `limit`, or narrow with
`condition`/`variant`. `pagination.total` counts every row the period holds
after filtering, not just the current page.

Response item:
```json
{
  "date": "2025-01-15",
  "source": "tcgplayer",
  "currency": "USD",
  "condition": "Near Mint",
  "variant": "Holofoil",
  "avg": 42.50,
  "low": 38.00,
  "high": 48.00,
  "sale_count": 156
}
```

---

### GET /v1/cards/:id/listings/ebay (Pro+)
Individual sold eBay card comps (graded and ungraded) sourced from PriceCharting. Free plan returns `403`. Uses cursor pagination. There is no bare `/v1/cards/:id/listings` endpoint — always specify the source (`/listings/ebay`, `/listings/cardmarket`, or `/listings/tcgplayer`).

**Two grades can print the same number.** A CGC Pristine 10 requires all four subgrades at ten and sells well above a CGC Gem Mint 10; a BGS Black Label 10 sits the same way against a plain BGS 10. Both carry `"grade": "10"` and are told apart by `grade_qualifier` (`"Pristine"`, `"Black Label"`, or `null` for the tier with no name of its own — which is nearly every comp). Filtering `grade=10` returns all tiers, as it always has; split the population yourself by reading `grade_qualifier`. It is `null` on comps collected before the tier was recorded, and on sales PriceCharting has aged out of its sold history.

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| graded | boolean | — | `true` = graded only; `false` = ungraded only; omit = both |
| grader | string | — | Filter by grading company (PSA, BGS, CGC, etc.) |
| grade | string | — | Filter by grade (1–10) |
| min_price | number | — | Minimum sale price |
| max_price | number | — | Maximum sale price |
| variant | string | — | Filter by the PriceCharting printing the comp was collected under, e.g. `Holofoil` |
| since | string | — | Only comps ingested after this instant. RFC 3339 timestamp or `YYYY-MM-DD` (midnight UTC). A malformed value is a `400`, never silently ignored |
| sort | string | date_desc | `date_desc`, `date_asc`, `price_asc`, `price_desc` |
| limit | integer | 20 | Results per page (max 20) |
| cursor | string | — | Base64 cursor from previous response |

Response:
```json
{
  "data": [
    {
      "id": 5678,
      "title": "PSA 10 Charizard ex 006/197 Obsidian Flames",
      "price": 275.00,
      "grader": "PSA",
      "grade": "10",
      "grade_qualifier": null,
      "variant": "Holofoil",
      "attribution": "exact",
      "sold_at": "2025-01-14",
      "ingested_at": "2025-01-16T04:12:07Z",
      "listing_url": "https://www.ebay.com/itm/..."
    }
  ],
  "pagination": { "has_more": false, "next_cursor": null, "count": 1 }
}
```

`grade` carries half grades as written (`"9.5"`, `"1.5"`), so treat it as an opaque string rather than an integer — a filter of `grade=9` matches PSA 9 and not BGS 9.5. Historic rows may still read `"9"` for a title that says 9.5 until the recovery sweep has passed over them.

`grader`, `grade`, `variant`, and `listing_url` are nullable (`null` for ungraded rows, comps ingested before the source printing was recorded, or when no marketplace URL is available).

**`attribution` — read this before pricing a variant.** Comps are collected per PriceCharting product page, and PriceCharting's catalogue is coarser than TCGplayer's: a Pokemon Center stamp, a deck exclusive or a promo reprint often has no page of its own. When several card entities map to one page, every one of them serves the same sold listings, with titles describing whichever printing the seller actually sold.

| Value | Meaning |
|-------|---------|
| `exact` | Collected under a page mapped to this card and no other. Safe to price from. |
| `shared` | Collected under a page shared with at least one other card. The same sale appears in those cards' feeds too, and the title may describe the other printing. |
| `unknown` | Ingested before the source printing was recorded, so sharing cannot be determined. |

A feed that is entirely `shared` is the other card's evidence, not this one's. Treat those rows as a price signal for the group rather than for the specific entity — or filter them out, which is what `attribution` exists to make possible.

**Polling for new comps — checkpoint on `ingested_at`, not `sold_at`.** `sold_at` is when the sale happened; `ingested_at` is when we collected it. A collection run routinely brings in sales that are weeks old, so a comp added today can carry a `sold_at` far behind the newest row you already hold. Filtering on sale date would step over those permanently.

The loop is: keep the highest `ingested_at` you have seen for a card, and pass it back as `since`.

```
GET /v1/cards/31194/listings/ebay?since=2026-09-01T02:40:15.126147Z&sort=date_desc
```

`ingested_at` and `snapshot_at` end in `Z` rather than `+00:00` precisely so they can go into a query string unescaped. If you build the value yourself and it carries a `+`, percent-encode it as `%2B` or the server reads it as a space and rejects the timestamp.

`since` composes with every other filter and with `cursor`. A card with nothing new returns an empty `data` array, which is the cheap outcome the parameter exists for — you are billed one credit per row returned, so a quiet card costs nothing rather than a full page of comps you have already seen. Re-passing the exact `ingested_at` string you were given is safe: the bound is exclusive, so the row you checkpointed on is not returned again.

---

### GET /v1/cards/:id/listings/cardmarket (Pro+)
Live Cardmarket marketplace offers for a card. Prices are EUR. Free plan returns `403`. Results are automatically restricted to the card's set language, so an English card cannot return German listings. Uses `(price, id)` cursor pagination and returns current seller offers cheapest first by default.

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| condition | string | — | Native Cardmarket condition (`Mint`, `Near Mint`, `Excellent`, `Good`, `Light Played`, `Played`, `Poor`) |
| variant | string | — | Printing variant. `Reverse Holo` and `Reverse Holofoil` are equivalent. |
| min_price | number | — | Minimum offer price (EUR) |
| max_price | number | — | Maximum offer price (EUR) |
| sort | string | price_asc | `price_asc`, `price_desc` |
| limit | integer | 20 | Results per page (max 20) |
| cursor | string | — | Opaque cursor from the previous response |

Response:
```json
{
  "data": [
    {
      "id": 3456,
      "article_id": 1789012345,
      "price": 38.50,
      "variant": "Reverse Holofoil",
      "condition": "Near Mint",
      "seller": "CardKingdomEU",
      "quantity": 2,
      "language": "EN",
      "comment": "Pack fresh, sleeved immediately",
      "updated_at": "2026-08-11T14:22:00+00:00",
      "signed": false,
      "altered": false,
      "graded": false,
      "grader": null,
      "grade": null,
      "opened": false,
      "sell_count": 2022
    }
  ],
  "pagination": { "has_more": true, "next_cursor": "cDozOC41OjM0NTY=", "count": 20 }
}
```

#### Special attributes: `signed`, `altered`, `graded`, `opened`

Cardmarket sellers flag copies that are not a plain card — signed by the artist or a player, altered/customised, or encapsulated by a grading company. These three booleans are always present on every listing, and `grader`/`grade` carry the slab details when they are known.

**A listing with any of the three set is excluded from market price derivation.** It is a real offer you can still show and link to, but it is not evidence of what the card is worth. This is why a card's Cardmarket listings can look wildly inconsistent with its prices:

```json
{
  "id": 3457,
  "price": 200.00,
  "condition": "Near Mint",
  "language": "JP",
  "comment": "signed by the artist at Worlds",
  "signed": true,
  "altered": true,
  "graded": false,
  "grader": null,
  "grade": null
}
```

A Japanese Poncho-wearing Pikachu can show a Near Mint offer at €200.00 while its Near Mint `market_price` is €3,800.00, because that €200 copy is signed and altered — a defaced card at a defaced card's price. Without the flags it is indistinguishable from a clean copy, and it would drag the derived price with it. **Do not compute your own "lowest Near Mint price" from these listings without first dropping rows where `signed`, `altered`, `graded`, or `opened` is true** — that is exactly the mistake the flags exist to prevent.

`graded` rows are the mirror image: a slabbed card is priced for the slab, usually far above the raw market price. `grader` and `grade` use the same shape and values as the graded eBay comps from `/listings/ebay` (`"PSA"` / `"10"`), so one renderer handles both. Both are `null` on ungraded rows, and either may be `null` on a graded row when Cardmarket's icon says the card is slabbed without naming the grader. Graded Cardmarket listings were not returned at all before this — they were dropped during scraping — so their appearance in a listings page is new, not a change in what is for sale.

`opened` is the sealed-product member of the set: the seller's own comment says the item is not sealed — an opened wrapper, or a display missing its shrink. It is always `false` on a card listing, where the distinction does not exist. A Jungle booster pack whose every other offer sits between €799 and €950 carries one at €10.00 reading *"Not Sealed (open, just the booster)"*; that is a real offer for an opened pack, not a €10 Jungle booster pack.

#### `sell_count`

The seller's completed sales, as Cardmarket reports them. It is the reason a market price can sit **above** the cheapest listing you can see: an offer from a seller with no completed sales does not set a price, so the cheapest row in a listings page is not always the `market_price`.

`null` is not zero. `null` means no count was recorded for that row — every listing stored before the field existed, and any row whose badge could not be read. `0` means Cardmarket reports the seller as having sold nothing. If you are reimplementing the price rule, treat only `0` as disqualifying and leave `null` alone.

---

### GET /v1/cards/:id/listings/tcgplayer (Pro+)
Live TCGplayer marketplace offers for a card. All prices are USD. Free plan returns `403`. Uses cursor pagination. Like Cardmarket listings, these are current seller offers (cheapest first by default), not sold sales.

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| condition | string | — | Filter by condition (e.g. `Near Mint`, `Lightly Played`, `Damaged`) |
| language | string | — | Filter by listing language |
| printing | string | — | Filter by printing/variant (e.g. `Unlimited Holofoil`, `1st Edition Holofoil`) |
| min_price | number | — | Minimum item price (USD, excludes shipping) |
| max_price | number | — | Maximum item price (USD, excludes shipping) |
| sort | string | price_asc | `price_asc`, `price_desc` |
| limit | integer | 20 | Results per page (max 20) |
| cursor | string | — | Base64 cursor from previous response |

Response:
```json
{
  "data": [
    {
      "id": 4567,
      "listing_id": 274183167,
      "printing": "1st Edition Holofoil",
      "condition": "Near Mint",
      "language": "English",
      "price": 4650.00,
      "shipping_price": 0.00,
      "seller_name": "Cash Cow Games",
      "seller_id": "120594",
      "seller_rating": 100.0,
      "seller_sales": "50000+",
      "quantity": 1,
      "listing_type": "standard",
      "direct_seller": false,
      "gold_seller": true,
      "verified_seller": false,
      "custom_title": null,
      "updated_at": "2026-06-17T03:44:00+00:00",
      "snapshot_at": "2026-09-03T02:41:12Z"
    }
  ],
  "pagination": { "has_more": true, "next_cursor": "NDU2Nw==", "count": 20 }
}
```

**`updated_at` is not a freshness signal — read `snapshot_at` instead.**

| Field | Answers |
|-------|---------|
| `updated_at` | When this listing's own fields (price, quantity, seller data) last changed. A listing that has been live and unchanged for a month keeps a month-old `updated_at`, however recently we confirmed it. |
| `snapshot_at` | When this product's listings were last confirmed against TCGplayer. Identical across every row of a response, because a snapshot replaces a product's listings wholesale. `null` if no successful snapshot has been recorded since the field was introduced. |

Listings are re-snapshotted daily. If `snapshot_at` is well over a day old, that product's last fetch did not succeed and you are looking at the previous snapshot — the rows are real offers as of `snapshot_at`, not as of now.

One thing `snapshot_at` cannot tell you: we collect from a US vantage point with no shipping-destination filter, so a snapshot can legitimately contain offers that TCGplayer's own site hides from you when you browse it from outside the US. A listing in the response that you cannot find on the site is more often that than staleness.

---

### GET /v1/sealed (Pro+)
List sealed products (booster boxes, ETBs, etc.). Does **not** return prices — use `GET /v1/sealed/:id` to get a product's prices. Free plan returns `403`.

| Param | Type | Description |
|-------|------|-------------|
| set_id | integer | Filter by set |
| name | string | Filter by product name (substring match) |
| language | string | Filter by language: `English`, `Japanese`, `German` (Pro+ for Japanese/German) |
| min_price | number | Minimum price |
| max_price | number | Maximum price |
| sort | string | `price_asc` or `price_desc` |
| page | integer | Page number (default 1) |
| per_page | integer | Results per page (default 50, max 100) |

Response item (no `prices` — see `/v1/sealed/:id`):
```json
{
  "id": 101,
  "tcg_player_id": 987654,
  "name": "Obsidian Flames Booster Box",
  "image_url": "https://...",
  "set": { "id": 123, "name": "Obsidian Flames" }
}
```

### GET /v1/sealed/:id (Pro+)
Get a single sealed product by ID, including its `prices` array. Response includes
nullable Cardmarket mapping identifiers (`cardmarket_url` is the absolute
Cardmarket product URL; `cardmarket_product_id` is its stable numeric product
ID). Both may be `null` until a mapping has been established. List endpoints
omit these fields. Prices come from TCGPlayer (USD) and Cardmarket (EUR) —
use `?currency=` to filter.

### GET /v1/sealed/:id/prices/history (Pro+)
Price history for a sealed product. Same parameters and response shape as card price history.

### GET /v1/sealed/:id/listings/tcgplayer (Pro+)
Live TCGplayer marketplace offers for a sealed product. Same parameters, response shape, and cursor pagination as `/v1/cards/:id/listings/tcgplayer`. All prices are USD. Free plan returns `403`. Sealed offers normally carry `condition: "Unopened"` and an empty `printing`, so the `condition`/`printing` filters are rarely useful here.

### GET /v1/sealed/:id/listings/ebay (Pro+)
Individual sold eBay comps for a sealed product, sourced from PriceCharting. Same response shape and cursor pagination as `/v1/cards/:id/listings/ebay`, minus the grading filters: sealed products are not graded, so `graded`, `grader`, and `grade` are not accepted and `grader`/`grade` are always `null` in the response. Accepts `min_price`, `max_price`, `since`, `sort` (`date_desc` default, plus `date_asc`, `price_desc`, `price_asc`), `limit` (max 20), and `cursor`. Free plan returns `403`.

`attribution` is returned here too and means the same thing, but sealed products map one PriceCharting page each with no printing to distinguish, so it is a property of the product rather than of the row: every comp in one response carries the same value, and `variant` is always `null`. There is no `variant` filter.

### GET /v1/sealed/:id/listings/cardmarket (Pro+)
Live Cardmarket marketplace offers for a sealed product. Prices are EUR. Free plan returns `403`. Uses `(price, id)` cursor pagination and returns current seller offers cheapest first by default. Response shape matches `/v1/cards/:id/listings/cardmarket`.

`opened` is meaningful here and nowhere else: it marks an offer whose seller says the product is not sealed, and like `signed`/`altered`/`graded` it is returned as a real offer but excluded from price derivation. Sealed products are single-language — the offers returned are the ones in the set's own language, so a Cardmarket product page showing offers in other languages will list more than this endpoint does.

`signed`, `altered`, `graded`, `grader` and `grade` behave exactly as on the card endpoint, including their exclusion from price derivation. They are almost always `false`/`null` here: a sealed product is unopened, so a flagged sealed offer is an oddity rather than the norm.

Two differences from the card endpoint. It is **not** language-scoped: a card shares one Cardmarket article across languages and has to be narrowed to its own, while a sealed product owns its rows outright, so every offer already belongs to it. And there is no `variant` filter — sealed rows are stored with an empty `variant`, so filtering on it could only ever match nothing.

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| condition | string | — | Native Cardmarket condition. Sealed offers usually carry none. |
| min_price | number | — | Minimum offer price (EUR) |
| max_price | number | — | Maximum offer price (EUR) |
| sort | string | price_asc | `price_asc`, `price_desc` |
| limit | integer | 20 | Results per page (max 20) |
| cursor | string | — | Opaque cursor from the previous response |

---

### GET /v1/me (JWT Auth)
Get your profile.
```json
{ "id": 1, "email": "user@example.com", "tier": "pro", "avatar_url": "https://...", "has_password": true, "email_verified": true, "created_at": "2025-01-01T00:00:00Z" }
```

Password signups start with `email_verified: false` until the magic link is used. OAuth signups and existing accounts are verified by default. `/me`, `/auth/refresh`, and `/auth/resend-verification` work while unverified; `/usage`, `/keys`, `/billing`, and password routes require a verified email.

### POST /v1/auth/verify-email (No Auth)
Confirm a password signup via the magic-link token. Body: `{ "token": "..." }`. On success returns `{ "ok": true }`, sets `email_verified`, and sends the welcome email. Idempotent if already verified.

### POST /v1/auth/resend-verification (JWT Auth)
Re-send the verification email for the authenticated user. Only applies when `email_verified` is false. Returns `{ "ok": true }`. Rate-limited to 3 requests per hour per IP.

### GET /v1/usage (JWT Auth)
Get credit usage across all your API keys.
```json
{
  "keys": [
    { "key_id": 1, "key_prefix": "pk_abc123...", "label": "my-app", "credits_used": 450 }
  ],
  "total_credits_used": 450,
  "total_daily_limit": 20000
}
```

---

## Errors

All errors follow this format:
```json
{ "error": { "code": "not_found", "message": "card not found" } }
```

| Status | Code | Meaning |
|--------|------|---------|
| 400 | `bad_request` | Invalid parameters |
| 401 | `unauthorized` | Missing or invalid API key / JWT |
| 403 | `forbidden` | Feature requires a higher plan |
| 403 | `email_unverified` | Email address is not verified (gated account/API-key routes) |
| 404 | `not_found` | Resource does not exist |
| 409 | `conflict` | Resource conflict (e.g. duplicate key) |
| 429 | `credit_limit_exceeded` | Daily credit limit reached |
| 429 | `rate_limit_exceeded` | Per-minute rate limit hit |
| 500 | `internal_error` | Server error |

---

## Typical Workflow

1. `GET /v1/cards?name=charizard&per_page=10` — search for cards
2. `GET /v1/cards/789` — get full pricing for a specific card
3. `GET /v1/cards/789/prices/history?period=30d` — view price trends (Pro+)
4. `GET /v1/cards/789/listings/ebay?grader=PSA&grade=10` — browse graded sales (Pro+)
5. `GET /v1/cards/789/listings/cardmarket?condition=Near%20Mint&variant=Reverse%20Holo` — browse language-matched live Cardmarket offers (Pro+)
6. `GET /v1/cards/789/listings/tcgplayer?condition=Near%20Mint` — browse live TCGplayer offers (Pro+)
7. `GET /v1/sealed?set_id=123` — check sealed product prices (Pro+)
8. `GET /v1/sealed/5678/listings/tcgplayer` — browse live sealed offers, cheapest first (Pro+)
