# List changes

> An append-only feed of catalogue mutations, so a mirror can resync without refetching everything.

- **Endpoint**: `GET /v1/changes`
- **Cost**: 1 credit
- **Minimum plan**: free
- **Cache-Control**: `public, max-age=30`

Source: https://pokemontcgapi.com/docs/api/changes/list

Written by database triggers, not by application code: nothing can mutate a row without appearing here, and ids are monotonic, so a single integer is a complete sync position. Each entry names the `kind`, the public `entity_id`, the `op` (`INSERT`, `UPDATE`, `DELETE`) and the `version` the row reached.

Follow `links.next` until `meta.has_more` is false, store `meta.next_since`, and poll. `meta.watermark` is the newest id in the feed and `meta.behind` how far your cursor is from it. `meta.oldest_available` matters on a first sync: if your stored position is older than it, the feed can no longer replay the gap and a full refetch is the honest answer.

## Query parameters

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `since` | integer | 0 | The `meta.next_since` of your previous page. `0`, or omitted, starts from the oldest entry still available. |
| `kind` | string | — | Comma-separated entity kinds: `CARD`, `SET`, `SEALED`, `PRICE`, `IMAGE`. |
| `limit` | integer | 50 | Rows per page, 1 to 250. Above 250 you get `LIMIT_EXCEEDED` — follow `links.next` instead of raising it. |

## Headers

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `X-Api-Key` **required** | string | — | Your API key. `Authorization: Bearer <key>` is accepted as an alias. |
| `If-None-Match` | string | — | Send back the `ETag` you stored. The API returns a strong ETag on every catalogue response, and a 304 is a header exchange: no body, no database row read. |

## Example request

**curl**

```bash
curl -s -G "https://api.pokemontcgapi.com/v1/changes" \
  --data-urlencode "limit=2" \
  -H "X-Api-Key: $PTCG_API_KEY"
```

**TypeScript**

`request.ts`

```ts
const url = new URL("https://api.pokemontcgapi.com/v1/changes");
url.searchParams.set("limit", "2");

const res = await fetch(url, {
  headers: { "X-Api-Key": process.env.PTCG_API_KEY ?? "" },
});

if (!res.ok) {
  const { error } = await res.json();
  throw new Error(`${error.code}: ${error.message} (${error.request_id})`);
}

const { data, meta } = await res.json();
```

**Python**

`request.py`

```python
import os, httpx

res = httpx.get(
    "https://api.pokemontcgapi.com/v1/changes",
    params={"limit": "2"},
    headers={"X-Api-Key": os.environ["PTCG_API_KEY"]},
)
res.raise_for_status()
payload = res.json()
```

## Example response

`200 OK` · `Cache-Control: public, max-age=30`

`response.json`

```json
{
  "data": [
    {
      "id": 1,
      "kind": "SET",
      "entity_id": "sv2a",
      "op": "INSERT",
      "version": 1,
      "changed_at": "2026-09-02T12:39:35.894Z"
    },
    {
      "id": 2,
      "kind": "SET",
      "entity_id": "svln",
      "op": "INSERT",
      "version": 1,
      "changed_at": "2026-09-02T12:39:35.894Z"
    }
  ],
  "meta": {
    "count": 2,
    "has_more": true,
    "next_since": 2,
    "watermark": 116242,
    "oldest_available": 1,
    "behind": 116240
  },
  "links": {
    "next": "https://api.pokemontcgapi.com/v1/changes?limit=2&since=2"
  }
}
```

## Errors

Every error body carries `error.code`, `error.message` and `error.request_id`. Switch on the code, never on the message.

| Status | Code | When |
| --- | --- | --- |
| 401 | `MISSING_API_KEY` | No `X-Api-Key` header and no bearer token on a route that requires one. |
| 401 | `INVALID_API_KEY` | The key does not match any account. |
| 400 | `INVALID_PARAMETER` | A query parameter has the wrong type or an unsupported value — a non-integer `limit`, an unknown `lang`, an unknown `region`, a sort key that is not sortable. |
| 400 | `LIMIT_EXCEEDED` | `limit` is above 250, or a batch request carries more than 100 ids. |

The full taxonomy, with what to do about each code, is on the [errors](https://pokemontcgapi.com/docs/errors) page.
