> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cromos.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Price snapshots

> The daily price file: format, naming, verification, and how to apply it.

`pokemon.card_prices.snapshot` and `pokemon.product_prices.snapshot` announce a file, not a set
of changed records. Card and sealed product prices rewrite most of their rows every day, so the
useful unit is not one changed price, it is the current file. This page is about that file. For
the event that announces it, see [Events](/webhooks/events).

## The file

Each snapshot is flat NDJSON, gzipped: one price row per line, and every line applies on its
own. Read and apply one line at a time rather than holding the whole file in memory.

### Card prices

One line per row, exactly as it appears in the file:

```
{"card_id":"sv08-125","variant_id":48212,"condition":"NM","currency":"USD","market":8999,"low":7450,"high":14999,"listing_count":25,"observed_on":"2026-07-31"}
```

`variant_id` is the same id [the card object](/cards/object)'s `variants[].id` carries, and the
same id `pokemon.card_variants.deleted` names: join on it directly, with no lookup table in
between. Rows are ordered by `(card_id, variant_id, condition)`, so a byte-diff between two days'
files lands on exactly the rows that changed.

### Sealed product prices

```
{"product_id":502001,"condition":"NM","variant":"Normal","currency":"USD","market":8094,"low":3949,"high":8829,"listing_count":25,"observed_on":"2026-08-09"}
```

A sealed product has no printing to key on, so its row carries `product_id` and `variant` where
a card row carries `card_id` and `variant_id`. Rows are ordered by `(product_id, condition,
variant)`.

Both rows share the rest of their shape. `currency` is always `USD` today: read it per row
rather than assume it, the same as the event's own `currency` field. `market`, `low` and `high`
are integer cents, `null` where nothing is priced, and `listing_count` is `null` under the same
condition. Where present, it counts how many active listings those three figures are computed
from, capped at 25.

## Naming

Each file is named by the **snapshot date** (the UTC date the file was published, not any date
inside it) and its own `sha256`.

```
https://assets.cromos.so/prices/cards/2026-08-05.c3f8a1e9b2d4f7a1c8e3b6d9f2a4c7e1b8d3f6a9c2e5b8d1f4a7c3e6b9d2f5a8.ndjson.gz
https://assets.cromos.so/prices/products/2026-08-05.a7e2c9f4b1d8e3a6c9f2b5d8e1a4c7f0b3d6e9a2c5f8b1d4e7a0c3f6b9d2e5a8.ndjson.gz
```

Read `url` from the event or from the manifest below rather than building it yourself. A url
always answers with the same bytes, so cache it for as long as you keep it. Two publishes on one
date answer at two urls.

**Each file supersedes every earlier one**, including an earlier publish of the same date. If a
delivery never arrives, there is nothing to replay: fetch the current file instead.

## `observed_on` rides on the row

The file is named by the date it was published. A row's own `observed_on` can be older,
sometimes by as much as 30 days: not every price is observed the moment a snapshot is taken.
Trust each row's own date for when that figure was true, never the date in the file's name.

## Verifying the download

`sha256` is the digest of the gzipped bytes exactly as downloaded, before decompression. Hash
what you fetched and compare it to the field of the same name, on the event or on the manifest
below, before you read a single row.

## Finding the current file

`GET /v1/prices/snapshots` answers with the newest snapshot of each kind:

```json theme={null}
{
  "data": {
    "cards": {
      "snapshot_date": "2026-08-05",
      "rows": 139577,
      "subjects": 19295,
      "url": "https://assets.cromos.so/prices/cards/2026-08-05.c3f8a1e9b2d4f7a1c8e3b6d9f2a4c7e1b8d3f6a9c2e5b8d1f4a7c3e6b9d2f5a8.ndjson.gz",
      "sha256": "c3f8a1e9b2d4f7a1c8e3b6d9f2a4c7e1b8d3f6a9c2e5b8d1f4a7c3e6b9d2f5a8",
      "bytes": 1583488
    },
    "products": {
      "snapshot_date": "2026-08-05",
      "rows": 4128,
      "subjects": 612,
      "url": "https://assets.cromos.so/prices/products/2026-08-05.a7e2c9f4b1d8e3a6c9f2b5d8e1a4c7f0b3d6e9a2c5f8b1d4e7a0c3f6b9d2e5a8.ndjson.gz",
      "sha256": "a7e2c9f4b1d8e3a6c9f2b5d8e1a4c7f0b3d6e9a2c5f8b1d4e7a0c3f6b9d2e5a8",
      "bytes": 84213
    }
  },
  "error": null
}
```

Either value is `null` where nothing has published for that kind yet. Call this to bootstrap a
mirror without waiting for a delivery, or to pick up a snapshot whose event you missed: the same
six fields, the same file, whichever way you learned about it. The call itself is one ordinary
`/v1` request, and the file it names costs nothing beyond that: it lives on `assets.cromos.so`,
outside `/v1` entirely.

## Retention

Each file, and the manifest entry naming it, stays available for 30 days. `GET
/v1/prices/snapshots` always answers with the newest of each kind, never a history.

A url is not a permanent name for one set of bytes: a later publish of the same date replaces
the file at the same url (see [Naming](#naming)). A `(url, sha256)` pair you stored from an
earlier delivery can stop matching what is at that url now. Verify against the `sha256` that
came with the url you are about to fetch, not one saved from an earlier delivery.

## Applying a snapshot

Fetch the file, verify it, then read it one line at a time:

```js theme={null}
import { createHash } from 'node:crypto'
import { createInterface } from 'node:readline'
import { Readable } from 'node:stream'
import { createGunzip } from 'node:zlib'

async function applyCardSnapshot(manifest) {
  const res = await fetch(manifest.url)
  const gzipped = Buffer.from(await res.arrayBuffer())

  const digest = createHash('sha256').update(gzipped).digest('hex')
  if (digest !== manifest.sha256) throw new Error('snapshot digest mismatch')

  const rows = createInterface({ input: Readable.from(gzipped).pipe(createGunzip()) })
  for await (const line of rows) {
    if (!line) continue
    const row = JSON.parse(line)
    await upsertPrice(row.card_id, row.variant_id, row.condition, row)
  }
}

// From a pokemon.card_prices.snapshot delivery: entries are sorted oldest to newest, so the
// newest file is the LAST entry, not the first.
await applyCardSnapshot(event.data.snapshot.at(-1))

// From GET /v1/prices/snapshots: `data.cards` is already the one current manifest, not an array.
await applyCardSnapshot(response.data.cards)
```

`manifest` is one manifest object: `snapshot_date`, `rows`, `subjects`, `url`, `sha256`, `bytes`.
`GET /v1/prices/snapshots` hands you that object directly. A delivery does not: unwrap it first.

A snapshot event's `data` is `{ currency, snapshot }`, and `snapshot` is an **array**, under the
same key every other event carries its records under. It holds one entry in the ordinary case,
and can hold more than one when a single delivery announces several days' files together.
Entries are sorted by `snapshot_date` **ascending**, so `snapshot[0]` is the OLDEST file in the
delivery, not the newest: always read the last entry, `snapshot.at(-1)`.

`GET /v1/prices/snapshots` answers differently: its `data.cards` and `data.products` are each
already a single manifest object (or `null`), not an array, because that endpoint only ever
answers with the newest of each kind. Write your own `upsertPrice`, keyed on `(card_id,
variant_id, condition)` for cards, or `(product_id, condition, variant)` for products: a row for
a key you already hold replaces it.

The file is a complete snapshot, not a set of changes. A printing priced yesterday and missing
from today's file is not priced today: if your own store still shows a price for it, its absence
from the file is the signal to clear it.
