> ## 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.

# Introduction

> An HTTP API over a trading card catalog.

Read a trading card catalog over HTTP: cards, the expansions and series they belong to, the
sealed products that contain them, and daily prices for all of it.

Every response is JSON. Every endpoint reads with `GET`, except `POST /v1/scan`. Every price is
USD, and an integer number of cents, never a decimal.

## Base URL

```
https://api.cromos.so
```

Every endpoint below `/v1` needs a key. See [Authentication](/authentication).

## Responses

Every read answers with the same two keys, and so does every failure. Exactly one of them is ever
non-null, so checking either one tells you about the other.

A detail endpoint puts the object in `data`:

```json theme={null}
{
  "data": { "id": "base1-4", "name": "Charizard", "rarity": "Rare" },
  "error": null
}
```

A list puts its rows and its cursor in `data` as well. `next_cursor` sits **inside** the envelope
rather than beside it, so `data` and `error` are the only two keys any response has:

```json theme={null}
{
  "data": {
    "items": [{ "id": "base1-1", "name": "Alakazam", "rarity": "Rare" }],
    "next_cursor": "eyJlIjoiYmFzZTEiLCJuIjozLCJpZCI6ImJhc2UxLTMifQ"
  },
  "error": null
}
```

`next_cursor` is `null` on the last page.

## Errors

Every failure has the same shape. Branch on `code`:

```json theme={null}
{ "data": null, "error": { "code": "not_found", "message": "card not found" } }
```

`message` is always present and never empty. It is written for a human reading a log, and its
wording is not part of the contract.

| Status | Code           | Meaning                                                  |
| ------ | -------------- | -------------------------------------------------------- |
| 400    | `bad_request`  | Parameters or a body the API could not accept            |
| 401    | `unauthorized` | Key missing, malformed, unknown, revoked or expired      |
| 404    | `not_found`    | No such id                                               |
| 429    | `rate_limited` | Over the request budget. See [Rate limits](/rate-limits) |
| 500    | `internal`     | Retry                                                    |

`POST /v1/scan` adds codes of its own. They are listed on [Scan a card](/scan).

## Paging

List endpoints page by cursor, not by offset, so rows do not shift under you between pages.

| Parameter | Meaning                                                                       |
| --------- | ----------------------------------------------------------------------------- |
| `limit`   | Rows per page. Defaults to 50, maximum 200                                    |
| `cursor`  | The `data.next_cursor` from the previous response. Omit it for the first page |

A `limit` above the maximum is clamped rather than refused, and a value that is not a number
falls back to the default.

To walk a whole expansion, keep calling until `next_cursor` comes back `null`. Three cards at a
time:

```bash theme={null}
curl "https://api.cromos.so/v1/cards?expansion_id=base1&limit=3" \
  -H "Authorization: Bearer $API_KEY"

curl "https://api.cromos.so/v1/cards?expansion_id=base1&limit=3&cursor=eyJlIjoiYmFzZTEiLCJuIjozLCJpZCI6ImJhc2UxLTMifQ" \
  -H "Authorization: Bearer $API_KEY"
```

Treat a cursor as opaque. It encodes sort position, and constructing one by hand will break.
Searching with `q` changes what a page contains, not how you walk it: rows come back best-match
first, each carrying a `score`, and the same cursor keeps working.

## Searching

There is no search endpoint. Every list takes `q` and searches its own resource:
[cards](/cards/list) by name or collector number, [series](/series/list),
[expansions](/expansions/list) and [products](/products/list) by name. `q` composes with that
list's own filters, so `?q=charizard&expansion_id=base1` searches inside one expansion.

You get back a ranked set of candidates, not one resolved id. See [List cards](/cards/list) for
how the ranking and its ties behave.

## Attaching related data

Some endpoints take `include` with a comma-separated list. On [List cards](/cards/list) it
accepts `variants`, `metadata` and `change`. Without it the key is **absent**, not `null` and not
`[]`, so
a response stays byte-identical to what it was before the field existed. A value the endpoint
does not recognise is dropped rather than refused.

## Caching

`/v1/*` responses carry an `ETag`. Send it back as `If-None-Match` and an unchanged resource
answers `304 Not Modified` with no body, which is cheaper to receive and cheaper to parse. It is
not cheaper against your [rate limit](/rate-limits): the limiter counts the request before the
`304` is produced, so a conditional request that comes back unchanged costs exactly as much of
your budget as a full `200` would.

## Versioning

The version is in the path. `/v1` is current, and a breaking change would arrive as `/v2` rather
than by altering `/v1` under you. Adding a field to a response is not breaking, so parse
defensively and ignore keys you do not recognise.

## Service health

`GET /health` is unauthenticated and reports whether the data being served is current:

```bash theme={null}
curl https://api.cromos.so/health
```

`status` is `ok` or `degraded`. Degraded means some corner of the catalog is staler than it
should be. The API keeps answering either way.

`feeds` is the part you can act on. Both `catalog` and `tcgplayer` report their own `status`, when
each last synced successfully as `synced_at`, and the newest `observed_on` each holds.
`observed_on` is always `null` for `catalog`, which carries no prices.

Those last two answer different questions, and the gap between them is the useful part. A recent
`synced_at` beside an old `observed_on` means nothing newer has been published for that feed,
which is a different problem from a feed that has not synced at all.

`degraded` while every feed reads `ok` is a real state, and a quiet one: what you are being
served is current, and something behind it needs a look.

`last_updated` is the most recent successful sync across every feed: the last moment any served
data could have changed. It is `null` before the first sync.
