<!-- Generated by api/scripts/gen_api_docs.py — do not edit by hand. -->
# API contract notes

Rules that apply across routes and tools: how calls are billed, the limits and what
happens past them, the error codes you can branch on, what each field means, and which
sources carry which data. The per-route parameter list is in the OpenAPI spec
(`/openapi-public.json`) and the MCP tool list is on `tools/list`; this page is what
they leave out.

Base URL `https://silphcoanalytics.xyz/api/v3`. MCP: `https://silphcoanalytics.xyz/api/v3/mcp/`.

## Units and billing

Billing is metered in **units**, not requests. One unit is one `tcg_card_id` read:

- A single-id call is 1 unit. An N-id call is N units, whatever `limit` you pass. `limit`
  trims rows per id, never the id count, so it does not lower the cost.
- `set_id` and `era` expand into cards and bill **1 unit per card touched**, not per row
  returned, and a `grade_key` or `finish` filter does not change the bill. `set_id=ex11` (114
  cards) bills 114 units for 36 returned rows because the scan read 114 cards. This is the
  rule and it is not changing. Before you spend, pass `dry_run=true` (see "Set-expanding
  calls") for `card_count` and `cost_units`; narrow with `rarity`, or cap the page with `max_cards`
  so the bill is bounded. One call covers one page of 250 cards.
- Set-ranking tools (`set_finish_series`)
  bill 1 unit per card in the page before ranking. A top-10 over a 127-card page costs 127 units;
  the 10 only trims the output.
- Flat-cost calls: `agg=sellers` (1 unit for any scope size), `agg=movers` (1), the seller
  routes (1 per call whatever the page size), `cohort=mark` (1 for the scan, plus 1 per sale
  row only with `include=sales`), `oracle` and its alias `quote` (1 per billable row; rows that are not priced are
  not billable), `search` in `q` mode (1; `ids` mode is 1 per resolved id), `resolve_cards` (free).
- Refused before the charge, so free: argument and schema validation (`invalid_input`,
  `invalid_query`), `batch_too_large`, and a `get_population`
  or `get_listings` request that fails validation. An error response carries no `cost_units`.
- Not free: MCP `get_sales` reserves its units for the resolved ids before it runs the scan, so
  an error raised by the scan itself (an unusable `cursor`, for example) is still billed. Check the
  arguments first and keep `cursor` exactly as issued.

Plan limits (UTC calendar month):

| Tier | Limit |
|------|-------|
| Playground (no key) | 2,000 units per UTC month, per IP, shared by REST and MCP |
| Free (API key) | 2,000 units per UTC month |
| Pro | 50,000 units per UTC month |
| Elite | 250,000 units per UTC month |

`X-Quota-Limit` and `X-Quota-Remaining` report the plan limit and what is left. Read your
account totals from `GET /keys/usage` (REST, in the public spec) or MCP `get_usage`:
`summary.units_this_month` is the figure that counts against the plan. `daily_limit` in that
response is the legacy field name for the plan limit in its own period (monthly on Free and Pro).

**Caching.** Responses can be served from the edge cache; `x-cache-status` says which
(`MISS`, `HIT`, `BYPASS`). A `HIT` replays the stored response, headers included, so
`x-quota-remaining` and `x-request-id` repeat for identical URLs. As observed, `x-quota-remaining`
moves on a `MISS` and stays put on a `HIT`; do not read a flat counter across identical URLs as a
broken meter.

## Request rate limits

Separate from the monthly unit quota above, every caller gets a request-rate token bucket:

| Caller | Burst | Sustained |
|--------|-------|-----------|
| Keyless (no API key, per client IP) | 120 | 10/s |
| Free key | 30 | 15/s |
| Pro key | 100 | 50/s |
| Elite key | 200 | 100/s |
| Internal | 2000 | 1000/s |

Every response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset`
(epoch seconds), plus `X-Request-ID`; all are CORS-exposed. Past the bucket the call is refused
with `429` `rate_limited` in the `error.v1` envelope, `details.retry_after_seconds` telling you how
long to wait; `Retry-After` says the same. A quota 429 (monthly units spent) is the same status and
code but also carries `X-Quota-Limit` / `X-Quota-Remaining: 0` and `details.required_tier` naming
the tier that would cover the call — that is how you tell "too fast" from "month spent".

Quote calls queue briefly instead of failing at once: each key runs a bounded number of concurrent quote requests (Free 1, Pro 2, Elite 4, Internal 8), and a request waits up to 8 s (`QUOTE_ADMISSION_WAIT_S`) for a slot before it is refused with 429.

## Limits and what happens past them

Request the largest page a route allows: billing is per id or per call, never per row.

| Route | Default | Max (keyed) | Over max | Playground cap |
|-------|---------|-------------|----------|----------------|
| `/api/v3/listings/sellers/{seller_key}/listings` | 1000 | 2000 | error | 50 |
| `/api/v3/listings/by-seller` | 1000 | 2000 | error | 50 |
| `/api/v3/sales (2+ ids)` | 20 | 500 | error | 50 |
| `/api/v3/sales (1 id)` | 10000 | 10000 | error | 50 |
| `/api/v3/sets/{set_id}/cards` | 100 | 500 | clamp | not enforced |
| `/api/v3/catalog` | 1000 | 5000 | clamp | 50 |
| `/api/v3/price` | 1 | 1 | error | not enforced |
| `POST /api/v3/price/batch` | 100 | 100 | error | 5 |
| `/api/v3/cards/{card_id}/price-history` | 365 | 2000 | error | not enforced |

Over max is `error` (the call is refused, naming the cap) or `clamp`
(you get a shorter page and the response says so).

### Batch caps (ids per call)

| Tier | Max ids |
|------|---------|
| keyed ids per call | 250 |
| keyed listings ids per call | 500 |
| keyed prices ids per call | 500 |
| keyed cards batch ids per call | 500 |
| playground ids per call | 50 |

An ids batch above the keyed cap is refused with 400 batch_too_large before any work is done — it is not a partial page. Agents that need a wider set should split the cohort, not retry the same call.

### Max ids per call, by route

| Route | Max ids per call |
|-------|------------------|
| `GET /api/v3/sales` (2+ ids) | 250 |
| `GET /api/v3/listings` | 500 |
| `GET /api/v3/prices` | 500 |
| `POST /api/v3/prices` (JSON body) | 1000 |
| `POST /api/v3/cards/batch` | 500 |
| `POST /api/v3/cards/population` | 50 |

Playground callers without an API key are batch-capped at 50 ids on these routes; `POST /api/v3/cards/batch` refuses playground callers with 403 instead of capping them.

One request has a 60-second budget at the gateway; the batch
caps are sized so a full batch answers inside it; MCP calls get a
300-second budget.

### Set and era scopes

`set_id` and `era` expand into cards, one page per call: 250 cards by default, `max_cards` up to 250, and `card_offset` for the next page. A set of any size answers with its first page; `scope.set_card_count` is the true size, `scope.truncated` says whether cards were left out and `scope.next_card_offset` is where the next page starts. Billing is 1 unit per card touched, filtered or not, and `dry_run` reports `card_count`, `set_card_count` and `cost_units` before anything is read or charged.

### Id parameter spellings on REST sales and listings

`GET /api/v3/sales` and `GET /api/v3/listings` take the card list in `ids=` (comma-separated `tcg_card_id` or TCGPlayer productIds) or `tcgplayer_id=`. The `tcg_card_id=` and `tcg_card_ids=` spellings used by `GET /api/v3/price` are accepted on both routes as the same list. Sending `ids` and a `tcg_card_id` alias with different values is a 400 `invalid_query`. `GET /api/v3/listings` also takes `card_id=` as an alias of `tcg_card_id=` and `item_ids=` as an alias of `item_id=` (comma list). An unknown param is a 400 `invalid_query` on `/listings` (naming the key in `details.unknown_params`, with a `did you mean` hint in the message when a close match exists) and still ignored with an `unknown_parameter` warning on `/sales`, so check `warnings` there when a read comes back empty.

### Sort and bound validation on sales and listings

- `sort` on `/api/v3/sales` and MCP `get_sales` takes `-date`, `date`, `-price`, `price`, `-volume`, `volume`, `-n`, `n`, `-median` or `median`; anything else is refused with the allowed list (422 `invalid_input` on REST, a tool error on MCP).
- `sort` on `/api/v3/listings` and MCP `get_listings` takes `price`, `newest`, `landed`, `ending` or `seller`, each optionally `-` for descending; anything else is a 400 `invalid_request`.
- `sort=price` on listings orders by the served ask (`ask_price_usd`, then `ask_usd`, then `price_usd`) across every source in one total order, not per source.
- `rarity=` is strict and case-insensitive: `rare` resolves to `Rare`, and an unknown tier (`Mythic`) is refused naming the valid tiers.
- `min_price_usd` and `max_price_usd` on `/api/v3/listings` bound the ask inclusively; a row with no ask is outside any bound.
- A multi-id `/api/v3/sales` read caps `limit` at 500 per id (400 above that). A single-id read keeps its own, larger cap.

### Search and MCP tool limits

| Surface | Limit | Over the limit |
|---------|-------|----------------|
| `/api/v3/search`, MCP `search` | `limit` 1 to 100 per page, `offset` 0 or more | 422 `invalid_input` naming the bound (REST); schema validation error (MCP) |
| MCP `get_listings` | 100 rows per card, 250 cards per call, 250 item ids per `item_ids` call | `limit_clamped` warning; set/era scopes above the card cap answer with their first page (`scope.truncated`, `card_offset`); an `item_ids` batch above the item cap is refused |
| MCP `get_population` | 100 cards per call | `ids_truncated` warning listing the dropped tokens; dropped cards are not billed |
| `/api/v3/sets/{set_id}/cards` | `limit` up to the clamp in the table above | `limit_clamped` entry in top-level `warnings`, plus `pagination.limit_truncated` and `pagination.max_limit` |

### Notes

- `/api/v3/listings/sellers/{seller_key}/listings` — One unit per call regardless of page size, so the fat default is the efficient path. To read a whole book, pass next_cursor back unchanged until it is null; there is no limit=all.
- `/api/v3/listings/by-seller` — Same page contract as sellers/{seller_key}/listings; also returns matched_sellers rows.
- `/api/v3/sales (2+ ids)` — limit is per card. At most 250 ids per call; larger cohorts need multiple calls, each still 1 unit per id touched.
- `/api/v3/sales (1 id)` — limit is per card. Omitting it returns the full tape; next_cursor pages it.
- `/api/v3/sets/{set_id}/cards` — offset/limit paging. An over-max limit is clamped down to the cap and the served page reports the limit it actually used, with a limit_clamped warning. Served from the sidecar when one is mounted and proxied to lor-api otherwise.
- `/api/v3/catalog` — An over-max limit is clamped to the cap rather than rejected.
- `/api/v3/price` — Single-cohort route by construction: one cohort per call, no limit/offset paging knobs to exceed — the 1s are arity, not a tuned cap. Batch cohorts with POST /price/batch instead.
- `POST /api/v3/price/batch` — Items (cohorts) per call: 100 keyed, 5 on the playground. An over-cap batch is refused with 400 BATCH_TOO_LARGE before any work is done. Billed one unit per priced item.
- `/api/v3/cards/{card_id}/price-history` — limit/offset paging enforced upstream by lor-api and forwarded verbatim; offset pages from 0 with no max. No playground row fence applies on this passthrough.

### Playground vs keyed

Playground callers (no API key) are row-capped at 50 and batch-capped at 50 ids. On the routes that fence it, an explicit limit above the playground cap is refused with 400 ROW_LIMIT_EXCEEDED rather than clamped. Keyed callers get the defaults and caps in the table above.

### Warnings you must read

A response can succeed and still say it did less than you asked. Check `warnings` (top level
on REST, `warnings` inside the result on MCP) for:

| Code | Meaning |
|------|---------|
| `limit_clamped` | Your `limit` was above the cap and was served as the cap. `max` and `requested` are in the entry. Follow `next_cursor` or `offset` for the rest. |
| `edition_print_run_ids` | `edition=` read a card as its print-run id (`gym2-6` becomes `gym2-1st-edition-6`). One entry's `rerouted` lists every `requested_id`/`read_as_id` pair, and returned rows carry the print-run ids. |
| `ids_truncated` | MCP `get_population` takes 100 cards. `dropped` lists the tokens that were not answered or billed; send them in another call. |
| `unknown_fields` | MCP `get_listings` `fields` named a path that no returned row carries. |
| `listings_stale_dropped` | Asks older than the freshness window were dropped; the entry reports the count and `max_age_days_by_source`. |

### Paging with cursors

- `next_cursor` is opaque and signed. Pass it back unchanged as `cursor=`, with the same
  filters, until it is `null`. Do not build or edit one.
- A multi-id `get_sales` or `get_listings` call returns **one** `next_cursor` carrying every
  id's own position. Page it until `null` and every id is covered.
- Seller routes (`GET /listings/by-seller`, `GET /listings/sellers/{seller_key}/listings`,
  MCP `get_seller_listings`) default to a 1000-row page and cap at 2000, at 1 unit per page,
  so the fat page is the efficient one. To read a whole book, loop on `next_cursor` until it is `null`.
  There is no `limit=all` or `return_all`: the unknown parameter is ignored, so
  `return_all=true&limit=2` returns 2 rows. `matched_sellers` is the list of matched sellers,
  ordered by `sort`.

## Set-expanding calls

`set_id` (and `era`) expand a scope server-side into cards. One call covers one page of
250 cards by default (`max_cards`, up to
250), starting at `card_offset`. A set of any size answers: a call with no
limit on `sv8pt5` (388 cards) returns its first 250 cards, not an error.

- `scope.set_card_count` is the set's true size, `scope.truncated` says whether the page left
  cards out, and `scope.next_card_offset` is the `card_offset` that reads the next page (`null`
  on the last one). REST reports the same object in `meta.scope`.
- **Billing is 1 unit per card touched**, filtered or not: a `get_listings` call with
  `grade_key=PSA:8` on a 114-card set bills 114 units, not the rows that matched. This is the
  rule for `get_listings`, `get_sales` (era) and
  `set_finish_series`.
- `dry_run=true` (MCP `get_listings`, `get_sales` for ids and era,
  `set_finish_series`; REST `GET /listings`) reads and
  charges nothing and reports `card_count` (the page), `set_card_count` (the true size),
  `cost_units`, `truncated` and `next_card_offset`. Run it before a wide set or era call.
- `limit` counts rows per card, so lowering it never shrinks the set; `max_cards` does.
- A set tape is cheaper than a per-card scan: `get_sales` `set_id=<set>` with
  `group=sale`.
- `GET /listings/export` is cursor-complete and refuses a set above
  400 cards (`export_too_large`) rather than export part of it.

## Error codes

Every `/api/v3` error is `contract: "error.v1"` with `error.code`, `error.message` and
`error.request_id`. Branch on `error.code` and HTTP status; when a route uses a sub-code it is
in `error.details.error`.

| Code | HTTP | Meaning |
|------|------|---------|
| `unauthorized` | 401 | Caller is not authenticated or credentials are invalid. |
| `invalid_query` | 400 | Request parameters or body failed validation (generic). |
| `invalid_sort` | 400 | Sort parameter is not one of the allowed values. |
| `invalid_grade_range` | 400 | `grade_min` exceeds `grade_max`. |
| `invalid_price_range` | 400 | `min_price` exceeds `max_price`. |
| `invalid_view` | 400 | View parameter is not one of the allowed values. |
| `invalid_input` | 422 | A request parameter was rejected (for example a paging bound on `/search`). The message names the field or bound. |
| `invalid_cursor` | 400 | `cursor` is not a token this route issued, or its filters no longer match. |
| `removed` | 410 | Route permanently retired. `details.replacement` names the successor, and a `Link: rel="successor-version"` header repeats it; both are `null`/absent when no single successor route exists. |
| `shelved` | 410 | Route retired for now and expected to return. Same `details.replacement` and header. |
| `not_available` | 404 | Path was never part of the public API. |
| `card_not_found` | 404 | No card with that `tcg_card_id` in that language. |
| `user_required` | 401 | The key has no user account behind it (`get_usage`, `/keys/usage`). |
| `quote_concurrency_exceeded` | 429 | Too many concurrent quote calls; retry after `details.retry_after_seconds`. |
| `forbidden` | 403 | Caller is authenticated but lacks permission for the resource. |
| `not_found` | 404 | Requested resource does not exist (generic). |
| `cert_not_found` | 404 | No cert with that number in the grader cert index (`/certs/{cert}`, `/sales?cert=`). |
| `invalid_cert` | 422 | Not a valid grader cert number (`/certs/{cert}`, `/sales?cert=`). |
| `cert_index_unavailable` | 503 | The grader cert index is temporarily unavailable; retry after `Retry-After` (300s). |
| `method_not_allowed` | 405 | HTTP method is not supported for this route. |
| `timeout` | 408 | Upstream dependency timed out or failed to respond in time. |
| `lor_unavailable` | 502 | lor-api (card/set/market data upstream) failed after retries/failover. Message is always generic — never an internal URL/host/port; upstream 404s map to `not_found` instead of this code. |
| `gone` | 410 | Resource existed but has been permanently removed. |
| `rate_limited` | 429 | Caller exceeded a rate or quota limit; see `Retry-After` header. |
| `internal` | 500 | Unexpected server-side failure. |
| `data_unavailable` | 503 | Required data layer is not yet loaded; retry after `Retry-After` (30s). |
| `supabase_unavailable` | 503 | Supabase/database dependency failed; retry after `Retry-After` (30s). |
| `storage_unavailable` | 503 | Local event persistence failed; retry after `Retry-After` (30s). |
| `cache_unavailable` | 503 | Response cache layer unavailable; retry after `Retry-After` (30s). |
| `mpp_payment_required` | 402 | MPP paywall — payment required (wire format is paymentauth.org, not error.v1). |

Notes on the codes you will meet most:

- `invalid_query` (400) is the generic validation failure. `details.error` carries the
  specific cause: `batch_too_large` (`details.max_ids` is the cap), `invalid_request`,
  `invalid_cursor`, `sidecar_unavailable`. A cohort-axis problem lists `details.accepted`.
- `invalid_input` (422) is a rejected parameter, for example `/search?limit=101` or a value the
  data service refuses. The message names the bound or the field.
- `removed` and `shelved` (410) are retired routes. When the 410 names a successor,
  `error.details.replacement` is that route path and the response repeats it in a
  `Link: <successor>; rel="successor-version"` header. A retirement with no single
  successor route answers with `details.replacement` `null` and carries no `Link`
  header, so read `details.replacement` and do not assume the header is there.
  Paths that were never part of the public API answer `404` with `error.code` `not_found` or `not_available`, in the same `error.v1` envelope with a `request_id`.
- `rate_limited` (429) means the plan quota or the request rate is exhausted; read `Retry-After`.
  `quote_concurrency_exceeded` (429) means too many concurrent quote calls; retry after
  `details.retry_after_seconds`.
- `user_required` (401) means the key has no user account behind it (`get_usage`, `/keys/usage`).

**MCP.** A tool failure is a JSON-RPC `200` with `result.isError: true`; read the structured
result: `error` is `invalid_input`, `invalid_query`, `batch_too_large`, `quota_exceeded`,
`data_unavailable` or `user_required`, with `message` and often `suggested_tool` and
`suggested_args`. Quote and resolve rows that cannot be priced carry a `machine_error` you can
branch on without parsing prose:

| `machine_error` | Row status | Act |
|-----------------|-----------|-----|
| `AMBIGUOUS_IDENTITY` | `ambiguous_identity` | Pick from `candidates`, then retry. |
| `LOCALE_MISMATCH` | `locale_mismatch` | Pass the right `language`. |
| `THIN_BOOK` | `insufficient_data`, `thin_single_venue` | No trustworthy mark; do not quote a price. |
| `STALE_AS_OF` | `stale` | The mark is older than its window; check `as_of`. |
| `UPSTREAM_BUSY` | `upstream_busy` | Wait `retry_after_s`, retry serially. |
| `BATCH_TOO_LARGE` | `batch_too_large` | Split the batch. |

## Fields that need a definition

### `is_outlier` and `outliers`

A sale is flagged `is_outlier=true` upstream when any of these hold: its price is outside the
IQR fence (3.0 x IQR) of that card's all-time sales for the same grader, with at least 5 sales
in the group; its price is above $25,000; or the silver layer marked the price anomalous. A flagged
sale is left out of the SilphCo Price unless the call passes `outliers=include`.

- The default sales tape hides flagged rows. `get_sales` reports `outliers_hidden` (how many the
  same filters would have returned) and `include_outliers=true` brings them back with the flag set.
- `outliers` on `quote` and `GET /price` is `exclude` (default) or `include`. It relaxes only
  the price-outlier flag. The identity gate (verified identity, no lots, autographs, proxies or
  reprints) always applies.
- `price.evidence_sale_ids` lists the sales the method used (the newest three clean sales for
  `median_3`). With `outliers=exclude`, none of them is flagged. `price.pool_n_sales` is the count
  of clean sales in the 180-day window for that cohort and outlier setting.
- A sale with `include_in_oracle=false` and no exclusion reason stays visible on the tape: that
  flag is pricing eligibility, not history visibility. A sale the lake closed (`include_in_oracle=false`
  with an `exclusion_reasons` entry such as `title_language_conflict`) is hidden by default:
  `get_sales` reports `excluded_hidden` and `include_excluded=true` brings it back with
  `excluded=true` and its reasons.

### Sale `outlier_reason`

`is_outlier` says a sale was flagged by the price fences; `outlier_reason` is the lake's
list of reason codes for that flag, in the order the lake recorded them (for example
`gold_iqr_fence`, `gold_abs_price_cap`, `fanatics_price_outlier`, `multi_item`). The field
is on every sale row: an unflagged row carries `[]`, and so does a flagged row whose rule
recorded no code (a flag-only rule). `null` means the serving tape does not publish the
column at all, never "no reason exists". Read it on the rows `include_outliers=true`
brings back: the default tape hides flagged rows, and the code names the rule that fired,
not a severity.

### `price.status` and an empty price

`price.value_usd` is the median of the newest three clean sales within 180 days by default
(`method=median_3`). `price.status` says how much to trust it:

| `price.status` | Meaning | `value_usd` |
|----------------|---------|-------------|
| `ok` | Three or more sales, and the newest is at most 90 days old. | set |
| `thin` | Fewer than three sales (for `method=last`, fewer than three clean sales in the window). | set |
| `stale` | The newest sale is more than 90 days old. | set |
| `suspect` | A higher grade of the same card prices below a lower grade. | set, unchanged |
| `none` | No clean sale in the window. | `null` |

There is no fallback. A `none` price never borrows another method, grade, finish, edition or
language, so a `null` is the answer, not a gap to fill. A `quote` row with `value_usd: null` has
row `status` `insufficient_data` (or `missing` with `price.reason=cohort_not_found`, or
`upstream_busy` with `price.reason=upstream_unavailable`). Row `status` is `ok` or `stale`
whenever a value is set, so read `price.status` for `thin` and `suspect`.

### `price.cohort.resolved_by`

`price.cohort` names the cohort the price came from. Its `resolved_by` comes from the price
service and is one of `explicit` (the caller pinned the finish and the grade), `default_print`
(the finish came from the default-print rule, or the grade defaulted to `raw:near_mint`) or
`fallback` (no cohort matched). The `meta.cohort.<axis>.resolved_by` echo on `chart-series`,
`/sales` and `/listings` is a different field with its own values: `explicit`, `default` or `none`.

### Sale `url` and `url_status`

`url_status` says why `url` looks the way it does. Read it before `url`.

| `url_status` | Meaning |
|--------------|---------|
| `stored` | The venue gave a real permalink and it is passed through. |
| `derived` | Built from a venue lot id or eBay item id on the row. A real permalink, computed. |
| `withheld` | The only link was a PriceCharting catalogue page, which is deliberately not served as proof of a trade. |
| `unlinked` | The source has no permalink to give. |

`url_status` is on `GET /sales`, MCP `get_sales`, and `quote` evidence rows. On
`GET /cards/{id}/sales` it is on rows served from the sidecar fallback only. That
route prefers the upstream tape, and upstream publishes a single computed
`listing_url` with the stored columns dropped, so a row served from it cannot
report whether its link was stored or derived; treat a card-sales row without
`url_status` as `url` of unknown provenance. Sources whose rows have no link,
measured on the newest rows of a sample of cards (2026-09-24 and 2026-10-02):

| Source (`sale_id` prefix) | Link behaviour |
|---------------------------|----------------|
| `ebay`, `ebay_hk` | Always linked. |
| `fanatics` | Most rows are linked, derived from the lot id or stored. Some are not: 16 of 19 Fanatics rows on `neo4-1st-edition-11` had no link in the card-page tape on 2026-10-02. |
| `cardindex` | Mostly linked (derived from the eBay item id); about 1 in 14 sampled rows `unlinked`. |
| `pricecharting` | Linked when the row carries an eBay item id (`derived`); catalogue-page-only rows are `withheld`. About a third of sampled rows have no link. |
| `alt_xyz` | More than half of sampled rows `unlinked`. |
| `beezie`, `tcg`, `courtyard`, `collector_crypt` | No permalink. On-chain rows carry the transaction in `sale_id`; TCGPlayer price points are not listings. |

Do not present an `unlinked` or `withheld` row as a verifiable sale.

### Sale `cert_number` and resolve `cert_match`

Every `sales.v2` row carries `cert_number`, the grader slab number when the lake
captured one, else `null`. A `null` is not published or not graded, never a guess.

`GET /sales?cert=` returns one slab's tape, newest first, from the local cert index
sidecar. The cert is 4-24 letters/digits and combines with nothing but `grader=`
and `limit=` (default 100, max 500); anything else is 400 `invalid_query`. An
unknown cert answers an empty page, never 404. A missing index answers 503
`cert_index_unavailable`. MCP `get_sales` takes the same lookup as `cert=` with
`cert_grader` and `limit`.

Rows on the cert path also carry the grader attestation when the lake has
verified the slab: `attested_grade_key` / `attested_grade_label` (the grader's
own record), `grade_verified` (tape grade agrees: true/false, null when either
side is missing), `attested_on` (verification snapshot date), and
`attested_population` (PSA total pop for the card). Null attested
fields mean unverified, never a guess.

A bare 8-10 digit `resolve`/`search` phrase that is not a product id resolves as a
slab cert number. One card is `exact`; several cards sharing the digits are
`ambiguous` with the best-supported identity first. The candidate carries
`cert_match` (`cert_number`, `grader`, `grade_key`, `sale_count`, `last_sold_at`).
A miss answers `missing`, never an error.

### Population

MCP `get_population` returns one row per
`(grader, grade, finish, stamp)`. The serving layer keeps one row per key: if the census holds
two rows for the same key the first is kept and the other is dropped, never summed, and the
upstream census is the latest snapshot per key, not a sum across snapshots. `population_total` is
the sum of the rows returned, so a `grader` filter changes it. The census has no edition column:
rows are not split into 1st Edition and Unlimited, so a count is for the print named by the
`tcg_card_id`. Where the catalog gives an edition its own id (for example
`base3-1st-edition-53`), query that id. A card whose rows carry no finish signal reports
`finish_collapsed` with a reason.

### Rarity

`rarity` filters on one exact catalog tier, matched case-insensitively. `Rare` is the plain Rare
tier only: it does **not** include `Rare Holo` or any other holo tier, so name the tier you want.
Tiers: `Common`, `Uncommon`, `Rare`, `Rare Holo`, `Rare Holo Star`, `Rare Holo EX`, `Rare Holo GX`, `Rare Holo V`, `Rare Holo VMAX`, `Rare Holo VSTAR`, `Rare Holo LV.X`, `Rare Prime`, `Rare BREAK`, `Rare Ultra`, `Rare Secret`, `Rare Rainbow`, `Radiant Rare`, `Double Rare`, `Ultra Rare`, `Illustration Rare`, `Special Illustration Rare`, `Hyper Rare`, `ACE SPEC Rare`, `Trainer Gallery Rare Holo`, `LEGEND`. Qualifier words name one tier each: `Prime` is `Rare Prime`, `Star` is
`Rare Holo Star`, `ex` is `Rare Holo EX` (the EX era; modern ex cards carry `Double Rare`), and
`LV.X` is `Rare Holo LV.X`. An unrecognised value is a 400/`invalid_query` listing the accepted
tiers, never an empty result.

### `edition=` and `grade=raw` on the sale tape

`edition` (first_edition, shadowless, unlimited) is exact on the row's typed `edition` column: a
marked print run (first_edition, shadowless) keeps only rows the lake published with that edition,
and a row published without an edition never matches one. `unlimited` is the default print, so
unmarked rows stay in an unlimited read. An id read also reads the print's own catalog id
(`gym2-6` reads `gym2-1st-edition-6` under `edition=first_edition`), warns with
`edition_print_run_ids` naming it, and returned rows carry that id. The same filter applies to
`set_id` with `group=sale`.

`grade=raw` — any token that resolves to `raw:near_mint` (`raw`, `nm`, `ungraded`, `near mint`) —
matches rows with a null `grade_key` as well as rows keyed `raw:near_mint`, because a sale the venue
never graded carries no grade key at all. Other raw conditions (`raw:lightly_played`) and slab keys
(`PSA:10`) stay exact and never match nulls.

### Listing and sale shipping

Sale rows have no shipping field: `price_usd` is the sold price as the venue recorded it, and
the tape does not say whether it included shipping. Listing rows carry
`shipping {cost_usd, free, type}`, `landed_price_usd` and `landed_price_basis`.
`ask_total_usd` is the same number as `landed_price_usd` and is kept for existing clients.

- `shipping.cost_usd` is the freight in USD, converted the way the ask is. `0.0` is observed free
  shipping and `shipping.free` is `true` for it. `null` is unknown, never zero, and `free` is `null`.
- `shipping.type` is `fixed`, `calculated` or `local_pickup` when the venue says, otherwise `null`.
  eBay Browse reports `calculated` for listings whose carrier rate depends on the buyer's address
  and publishes no amount without a buyer location, so those rows are `type: calculated` with
  `cost_usd: null`. Nothing here estimates a value and there is no known/estimated flag.
- `landed_price_usd` is ask plus shipping to the cent when the cost is known or free, otherwise
  `null`. `landed_price_basis` names why: `ask_plus_shipping`, `ask_free_shipping`,
  `shipping_calculated_needs_buyer_location`, `local_pickup_only`, `shipping_not_published` or
  `ask_missing`.
- `sort=landed` orders a listings page by `landed_price_usd` (`-landed` dearest first); rows
  with no landed price come last in both directions. A page keyed on `sort=price` is unchanged.
- `origin.ships_from_country_code` is the item's ship-from country (eBay Browse
  `itemLocation.country`), `null` where the source does not give one.
- The `data.shipping_fill_rate` block reports, per `source`, `listings` (rows the request
  matched across every page), `shipping_known`, `free`, `calculated_no_cost` and `fill_rate`
  (`shipping_known / listings`). It is computed before paging and covers the whole match.

## Listings sources: data, shipping, cadence

Fill by source on the listings book, measured 2026-10-03 over every active row of the served
listings sidecar. Fill is a measurement, not a promise; the right column states what the source
can and cannot supply. Every response reports the same measure for its own rows in
`data.shipping_fill_rate`.

| `source` (`listing_id` prefix) | Shipping known | Seller username | Photos | What the source gives |
|--------------------------------|----------------|-----------------|--------|-----------------------|
| `ebay_browse` (`ebay_browse:`) | about 75% | about 90% | few | eBay's own API: seller, feedback, ship-from country and shipping when the item states a fixed amount (fixed shipping is always filled). About a quarter of asks are `calculated`: eBay prices them from the buyer's address and publishes no amount, so `shipping.type` is `calculated` and the cost is null. `last_verified_live_at` is set. |
| `tcgplayer` (`tcgplayer:`) | all | never | none | Marketplace rows with shipping; no seller identity. |
| `cardindex` (`cardindex:`) | about 90% | about 80% | most | A scraped mirror of eBay. Not first-party, so the default book drops it; `last_verified_live_at` is never set. |
| `alt_xyz`, `alt_xyz_auction` (`ebay:`) | never | never | all | Plain `ebay:` rows. The alt.xyz feed carries no shipping or seller fields, so these are empty at source and cannot be filled from this API. |
| `fanatics`, `fanatics_auction` (`fanatics:`) | never | never (opaque tenant id only) | `fanatics` about half, `fanatics_auction` all | Auction bid count and end time; no shipping, buyer premium or seller name. |
| `mercari_us` (`mercari_us:`) | never | never | none | Price and title only. |
| `doubleholo` (`doubleholo:`) | never | never | none | Price and title only. |

Sources that never carry shipping or seller data, today: `alt_xyz`, `alt_xyz_auction`,
`fanatics`, `fanatics_auction`, `mercari_us`, `doubleholo`. Their `shipping`, `seller`, `terms`,
`protection` and `origin` blocks are present and null; `seller_known` is `false`. For a buyable
landed price use the default book (eBay first-party asks), where `landed_price_usd` is set when
shipping is known or free.

**Cadence.** There is no per-source refresh guarantee. Each row's `observed_at` is the last
batch that saw it. Batch hours (UTC) observed on 2026-10-02: `tcgplayer` and `doubleholo` about
00:00, `mercari_us` about 01:00 and 17:00, `cardindex` about 03:00, `alt_xyz` about 07:00 (some
15:00), `fanatics` about 07:00 and 19:00, `ebay_browse` about 12:00. `first_seen_at` is when
the listing first appeared. `listed_at` is the earliest known start of the listing (the venue
listing date when known, else `first_seen_at`), and `listed_at_source` says which (`venue` or
`first_seen`); both ride the Postgres book only and are absent on other reads. `days_listed`
runs from `listed_at` (`first_seen_at` when null) to `delisted_at` for an ended ask, else to
`last_seen_at` (the `observed_at` heartbeat). `first_seen_censored` is true when `first_seen_at`
falls on the marketplace tracking-start floor (ebay 2026-07-31, tcgplayer 2026-08-14, mercari_us
2026-07-20, others 2026-07-31), and `listed_at_censored` when a `first_seen`-sourced start is
censored: the ask has been up since that date or earlier. `sort=oldest_listed` and
`sort=newest_listed` order by `listed_at`; `min_days_listed` and `max_days_listed` (floats, at
least 0, min not above max) bound it. Asks with no `listed_at` are dropped when any of them is
set. `sort=newest` and `first_seen_since` work on `first_seen_at`. The default book drops asks whose `observed_date` is older than 3
days (7 for `ebay_browse`, which is revisited on a rotation); `include=all` bypasses the fence.

## `fields` projection (MCP `get_listings`)

`fields` is a JSON list, not a comma string. Entries are top-level keys (`ask_price_usd`) or
dotted paths into a nested block (`seller.username`, `shipping.cost_usd`): the result keeps
only that key inside its block. A path that no returned row carries is reported in an
`unknown_fields` warning instead of being dropped silently. `fields` applies to `agg=none` rows.
Without `fields`, rows carry every nested block with nulls for what the source lacks.

## List deletes

Deleting a list is deliberately asymmetric between the two surfaces, and it is not changing:

- REST `DELETE /api/v3/lists/{id}` deletes immediately and answers `204` — there is no
  confirm step, so existing clients keep their behavior.
- MCP `delete_list` and `delete_lists` are two-step: a first call with `confirm=false`
  (the default) reports each list's item count and changes nothing, and only a second call
  with `confirm=true` deletes. That gate exists for agent safety, not as a REST contract.

The canonical default Watchlist is refused on both surfaces; named watchlists can be deleted.

## MCP and REST

- The SilphCo Price (`GET /price`, `POST /price/batch`, contract `price.v2`) is the
  price answer. The liquidity midpoint (midprice) is retired (2026-10-11).
- The MCP `oracle` and `quote` tools serve the same price as REST.
- Remaining quota and the limits are in one free MCP call, `get_usage`: `period.remaining` and
  `resets_at`, `rate_limit`, `unit_costs`, and `limits` (every page size, batch cap and the
  set/era page). REST has `GET /keys/usage`.
- `get_listings agg=sellers` returns the same per-seller split as `GET /listings?agg=sellers`
  (`n_active`, `min_ask_usd`, `p50_ask_usd`, `share_pct`) for 1 unit. `get_seller_listings` is the
  tool for starting from one seller.
- Server names in an MCP client are chosen by your client configuration (Cursor labels each
  configured server `user-<name>`). Two entries that point at the same URL expose the same
  tools; the account they act as is set only by the `Authorization: Bearer` key in that entry's
  headers, or by the OAuth login. Name entries after the key they use.

The SilphCo Price (`GET /price`, `price.v2`) is the median of the newest three clean sales within 180
days for one grade, finish, edition and language. `fmv` is the deprecated legacy name
for the same price; `estimator=fmv` on price-history is the legacy lane name.
Raw sales (`/sales`) are tape rows, not an aggregate.
`/api/v3/status` and the other gated paths answer `404` in `error.v1` with a `request_id`.
