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

> ## Agent Instructions
> To integrate AGG, start with Quickstart: REST (https://docs.agg.market/quickstart/rest), then Order lifecycle & statuses (https://docs.agg.market/concepts/order-lifecycle).
> Track every trade until it reaches a terminal status. Before retrying a failed or timed-out call, read Errors, retries & idempotency (https://docs.agg.market/concepts/errors).
> The API reference is generated from https://docs.agg.market/openapi/openapi.json.

# Catalog Sync

> Keep your copy of AGG's events and markets in sync without polling: a live WebSocket feed plus a REST catch-up from your last position.

# Keep your market catalog in sync without polling

If you keep your own copy of AGG's events and markets, for search, alerts or your own listing
pages, you need to know when a market is listed, changes or goes away. Polling
[List venue markets](https://docs.agg.market/api-reference/markets/list-venue-markets.md) sorted by `updatedAt` gets you
part of the way, at the cost of a full page read every few seconds. It also misses changes that do
not touch the row itself. A market that joins or leaves a [matched cluster](https://docs.agg.market/recipes/matched-clusters.md)
keeps its `updatedAt`, so a poller never sees it become comparable across venues.

The catalog feed tells you what changed instead.

## How it works

There are two halves, and you use both:

* The **`catalog` WebSocket channel** pushes a `catalog_change` frame for every event or market
  that is created, changes or is deleted. It is live only: nothing is replayed after a reconnect.
* **[`GET /catalog/changes`](https://docs.agg.market/api-reference/markets/list-catalog-changes)** returns the same items
  from any point in the last 24 hours, oldest first, paged by cursor. This is how you fill the gap
  while you were away.

To sync, subscribe first and hold the frames that arrive. Then page the REST endpoint from your
last position minus about a minute of overlap, apply what it returns, and apply the held frames.
After that, apply frames as they arrive. Do the same catch-up after every reconnect and every
[`stream_interrupted`](https://docs.agg.market/api/websocket#stream-interruptions) that lists `catalog`. The SDK's
`watchCatalog` does all of this for you.

### Items are state, not history

Each item carries an entity's **current** state, not a single edit. Two edits close together can
arrive as one item, and the overlap means you will see some items twice. Both are fine if you
apply an item only when it is newer than what you hold for that `(entity, id)`:

```ts theme={null}
import type { CatalogChange } from "@agg-build/sdk";

function isNewer(held: { version: number; changedAt: string } | undefined, item: CatalogChange) {
  if (!held) return true;
  if (item.version !== held.version) return item.version > held.version;
  return Date.parse(item.changedAt) > Date.parse(held.changedAt);
}
```

| Field | Meaning |
| - | - |
| `entity` | `event` or `market`. Key your storage on `entity` + `id`. |
| `op` | `created`: new since the position you asked from. `updated`: anything else that changed. `deleted`: gone. Treat `created` and `updated` the same way, as an upsert. A reconnect can announce an entity as `created` more than once (the catch-up window overlaps what you already saw), so if you alert on new markets, de-duplicate alerts by `id`. |
| `version` | Increases on every structural write to the entity. |
| `changedAt` | When the latest change touching the entity was recorded. Persist the newest one you applied: it is your resume position. |
| `venue` | The entity's venue. `null` only on deletes. |
| `event` / `market` | The snapshot, matching `entity`. Absent on deletes. |

A **delete** carries only the id: no snapshot and `venue: null`. Deletes are sent even when you
filter by venue, because the server may no longer know the venue. Drop the id if you hold it and
ignore it otherwise.

### Matching fields

Every snapshot says where the entity sits in its [matched cluster](https://docs.agg.market/recipes/matched-clusters.md):

| Field | Meaning |
| - | - |
| `matched` | Matched to the same event or market on at least one other venue. |
| `clusterAnchorId` | The cluster's anchor: the row that list endpoints fold the cluster into. Equals `id` when this entity is the anchor, `null` when unmatched. |
| `clusterVenues` | Venues in the cluster, the anchor's venue first. Just this entity's venue when unmatched. |

Anchors can change. When a cluster gains or loses a member, every affected entity gets a new
item, and the anchor may move to another member. Store each entity under its own `id` and treat
`clusterAnchorId` as a pointer you update, never as a key. These fields follow your app's
venue settings: a member whose anchor is on a venue your app hides is reported unmatched, the
same way list endpoints show it as its own row. Market snapshots also carry
`outcomes[].id`, which is the id the [orderbook socket](https://docs.agg.market/recipes/websocket-orderbook.md) takes.

Snapshots carry the fields that identify and place an entity: titles, status, dates, categories,
outcomes and matching. Prices and volume are not catalog changes. Read those from
[market data](https://docs.agg.market/api/market-data.md).

## Quick start (SDK)

`watchCatalog` subscribes, catches up, de-duplicates and re-runs the catch-up after reconnects.
`onChange` is called once per newer state of an entity.

```ts theme={null}
import { createAggClient } from "@agg-build/sdk";

const client = createAggClient({
  baseUrl: "https://api.agg.market",
  wsUrl: "wss://ws.agg.market/ws",
  appId: process.env.AGG_APP_ID,
  apiKey: process.env.AGG_API_KEY, // server-side only; sent as x-app-api-key
});

// onChange is not awaited. Chain your writes so they land in order.
let writes = Promise.resolve();

const handle = client.watchCatalog({
  since: (await loadCatalogPosition()) ?? new Date(Date.now() - 60 * 60_000),
  onChange: (change) => {
    writes = writes.then(() =>
      change.op === "deleted"
        ? db.remove(change.entity, change.id)
        : db.upsert(change.entity, change.id, change.event ?? change.market),
    );
  },
  onSynced: ({ lastChangedAt }) => console.log("catalog live from", lastChangedAt),
  onError: (err) => console.error("catalog feed", err),
  onResyncRequired: async () => {
    const startedAt = new Date();
    await relistCatalog(); // see "Resync after 24 hours" below
    handle.resync(startedAt);
  },
});

// Save the position only once everything before it is written.
setInterval(() => {
  const position = handle.lastChangedAt;
  if (position) void writes.then(() => saveCatalogPosition(position));
}, 10_000);

await handle.ready; // first catch-up done
```

| Option | Default | Notes |
| - | - | - |
| `since` | required | Where to start. At most 24 hours ago. Pass your saved `lastChangedAt` to resume. |
| `venues`, `entities` | all | Narrow the feed. Applied on the server, on both halves. |
| `matchedOnly` | `false` | Deliver only matched entities. An entity this watcher already delivered is still delivered when it leaves its cluster or is deleted, so you can drop it. A new watcher does not know what an earlier process delivered. |
| `overlapMs` | `60000` | How far before the resume position each catch-up re-reads. |
| `pageLimit` | `200` | Catch-up page size, 1 to 500. |
| `onSynced` | | A catch-up finished and frames now apply directly. Fires after every catch-up. |
| `onResyncRequired` | | The resume position is older than 24 hours. Catch-up stops until you call `resync()`. |

The handle has `stop()`, `lastChangedAt`, `resync(since)` and `ready`. Persist `lastChangedAt`
somewhere durable once the changes before it are stored, and pass it back as `since` when your
process restarts. An older position is always safe: it only re-reads more. Without `socket`,
`watchCatalog` opens its own connection and `stop()` closes it. To share a connection you already
have, pass it as `socket`. On a backend, give the client your `apiKey` and the connection
authenticates with it. See [Server connections](https://docs.agg.market/api/websocket.md#server-connections).

### First sync

Before your first watch, you hold nothing. Note the time, list everything once with
[List venue events](https://docs.agg.market/api-reference/markets/list-venue-events.md) and
[List venue markets](https://docs.agg.market/api-reference/markets/list-venue-markets.md), then start `watchCatalog` with
`since` set to the time you noted. Changes made while you were listing arrive through the
catch-up.

## React

`useCatalogFeed` from `@agg-build/hooks` runs `watchCatalog` for the life of the component, on
its own connection (the client needs `wsUrl`). It takes the same options plus `enabled`, and
returns `{ status, lastChangedAt, error, resync }`. `status` is `idle`, `syncing`, `live`,
`error` or `resync_required`.

```tsx theme={null}
import { useCatalogFeed } from "@agg-build/hooks";

function CatalogSync({ since }: { since: string }) {
  const { status, lastChangedAt, error } = useCatalogFeed({
    since,
    venues: ["polymarket", "kalshi"],
    matchedOnly: true,
    onChange: (change) => catalogStore.apply(change),
  });

  if (error) return <p>Catalog sync error: {String(error)}</p>;
  return (
    <p>
      Catalog {status}, up to {lastChangedAt ?? "start"}
    </p>
  );
}
```

Pass a fixed `since`, such as the position you loaded at startup. Changing `since`, `venues`,
`entities` or `matchedOnly` restarts the feed; changing the callbacks does not. After
`resync_required`, re-list and call the returned `resync(startedAt)`.

Most catalogs belong on a server. Use the hook for live UI, such as a "new markets" list, not as
your system of record.

## Raw protocol

Any language works. Connect, subscribe, catch up, then apply frames. The same `isNewer` check
makes the overlap harmless.

**1. Subscribe.** Open `wss://ws.agg.market/ws?appId=YOUR_APP_ID`. From a backend, send your key
in the `x-app-api-key` handshake header and no `Origin`. After `connected`, send:

```json theme={null}
{ "action": "subscribe", "channel": "catalog", "venues": ["polymarket", "kalshi"], "id": "cat-1" }
```

Wait for `{ "type": "subscribed", "channel": "catalog", "id": "cat-1" }`. Hold any
`catalog_change` frames that arrive until the catch-up finishes. Frame shapes are in
[WebSocket Protocol](https://docs.agg.market/api/websocket.md#6-catalog-feed).

**2. Catch up.** Page from your saved position minus 60 seconds until `hasMore` is `false`.
Repeat multi-value parameters (`venues=a&venues=b`); a comma list is rejected.

```ts SDK theme={null}
let cursor: string | undefined;
const since = new Date(Date.parse(savedChangedAt) - 60_000);
do {
  const page = await client.getCatalogChanges(
    cursor ? { cursor } : { since, venues: ["polymarket", "kalshi"] },
  );
  for (const change of page.data) applyIfNewer(change);
  cursor = page.hasMore ? page.nextCursor : undefined;
} while (cursor);
```

```bash cURL theme={null}
curl -s "https://api.agg.market/catalog/changes?since=2026-10-21T11:59:00Z&venues=polymarket&venues=kalshi&limit=200" \
  -H "x-app-id: $AGG_APP_ID" \
  -H "x-app-api-key: $AGG_API_KEY"
# then repeat with ?cursor=<nextCursor> until "hasMore": false
```

```json theme={null}
{
  "data": [
    {
      "entity": "market",
      "id": "vm_8f2c",
      "op": "created",
      "version": 1,
      "changedAt": "2026-10-21T12:00:41.512Z",
      "venue": "kalshi",
      "market": { "id": "vm_8f2c", "venueEventId": "ve_51aa", "question": "…", "matched": false, "clusterAnchorId": null, "clusterVenues": ["kalshi"], "…": "…" }
    }
  ],
  "nextCursor": "MTc5MjU5MzI0MTUxMjAwMHxtYXJrZXQ6dm1fOGYyYw",
  "hasMore": false
}
```

`nextCursor` is opaque and always set. A page can hold fewer than `limit` items, or none, while `hasMore` is
still `true`, so stop on `hasMore`, not on an empty page. `limit` is 1 to 500 and defaults
to 200. Full parameters are in [List catalog changes](https://docs.agg.market/api-reference/markets/list-catalog-changes.md).

**3. Go live.** Apply the held frames with the same check, then apply each new frame as it
arrives. Save the newest `changedAt` you applied.

**4. Repeat the catch-up** from your saved position minus 60 seconds after every reconnect and
every `stream_interrupted` that lists `catalog`. Keep holding frames while it runs.

A Python backend, for example (`httpx` and `websockets` 14+). `load_position()` returns your saved
`changedAt` as epoch seconds, and `apply_if_newer()` is the check above plus your write:

```python theme={null}
import asyncio, json, os
from datetime import datetime, timezone
import httpx, websockets

API = "https://api.agg.market"
app_id, app_key = os.environ["AGG_APP_ID"], os.environ["AGG_API_KEY"]
HEADERS = {"x-app-id": app_id, "x-app-api-key": app_key}
VENUES = ["polymarket", "kalshi"]

class ResyncRequired(Exception):
    pass

async def catch_up(http):
    since = datetime.fromtimestamp(load_position() - 60, timezone.utc).isoformat()
    params = {"since": since, "venues": VENUES}
    while True:
        r = await http.get(f"{API}/catalog/changes", params=params, headers=HEADERS)
        if r.status_code == 410:
            raise ResyncRequired()
        r.raise_for_status()
        page = r.json()
        for change in page["data"]:
            apply_if_newer(change)
        if not page["hasMore"]:
            return
        params = {"cursor": page["nextCursor"]}

async def run():
    url = f"wss://ws.agg.market/ws?appId={app_id}"
    async with httpx.AsyncClient() as http, websockets.connect(
        url, additional_headers={"x-app-api-key": app_key}
    ) as ws:
        held, task, again = [], None, False
        async for raw in ws:
            msg = json.loads(raw)
            kind = msg.get("type")
            if kind == "connected":
                await ws.send(json.dumps({"action": "subscribe", "channel": "catalog", "venues": VENUES}))
            elif kind == "catalog_change":
                held.append(msg)
            elif (kind == "subscribed" and msg.get("channel") == "catalog") or (
                kind == "stream_interrupted" and "catalog" in msg.get("channels", [])
            ):
                again = True  # catch up after every ack and every interruption
            if task is not None and task.done():
                task.result()  # raises ResyncRequired or the HTTP error
                task = None
            if again and task is None:
                again = False
                task = asyncio.create_task(catch_up(http))
            if task is None:  # live: apply held and new frames directly
                for change in held:
                    apply_if_newer(change)
                held.clear()
```

The held frames are applied on the first message after the catch-up finishes. The gateway sends a
`heartbeat` at least every 30 seconds, so they never wait longer than that.

Reconnect with backoff when the socket closes, as in
[Reconnection](https://docs.agg.market/api/websocket.md#reconnection). The new connection runs the catch-up again.

## Resync after 24 hours

`GET /catalog/changes` keeps 24 hours. A `since`, or a cursor, older than that returns `410`:

```json theme={null}
{
  "message": "Position is older than the catalog feed retention window; re-list with GET /venue-events and GET /venue-markets, then resume from when that re-list started",
  "code": "resync_required"
}
```

Note the time, re-list everything with
[List venue events](https://docs.agg.market/api-reference/markets/list-venue-events.md) and
[List venue markets](https://docs.agg.market/api-reference/markets/list-venue-markets.md), and resume the feed from the
time you noted. In the SDK, that is `onResyncRequired` followed by `handle.resync(startedAt)`;
`isCatalogResyncRequired(err)` recognizes the error from `getCatalogChanges`. Keep the frames that
arrive while you re-list; the catch-up after `resync` covers them anyway.

## Limits and timing

* **24-hour window.** Resume positions older than that need a [resync](#resync-after-24-hours).
* **No replay on the socket.** Frames sent while you were disconnected are not resent. The REST
  catch-up is the only way to fill a gap.
* **Latency.** A frame follows a recorded change within a few seconds. How soon a new listing is
  recorded depends on how often AGG discovers that venue's catalog: typically 30 to 60 seconds
  after the venue lists a market.
* **Your app's settings apply.** Venues and categories disabled for your app are left out of both
  halves, except deletes, which only carry an id.
* **Settings changes are not replayed.** Disabling a venue or category for your app hides future
  items; it does not send deletes for what you already hold. Re-list after changing them.
* **Market categories.** A market's `categories` are its event's. When only the event's
  categories change, the event gets a new item and its markets do not; read categories from the
  event when they matter.
* **Filters.** Up to 64 venues per subscribe. Subscribing again on the same connection replaces
  your filter. There is no server-side matched-only filter; the SDK's `matchedOnly` filters on
  the client.
* **Rate limits.** `GET /catalog/changes` has its own Catalog feed group in
  [Rate limits](https://docs.agg.market/concepts/rate-limits.md#endpoint-limits), separate from Discovery. A catch-up runs
  once per reconnect, not on a timer: at current volume, expect roughly 40 pages of 500 items for
  every hour you were disconnected. Once you are on the feed, you should not need to poll
  `GET /venue-events` or `GET /venue-markets` for new listings.
* **Connections.** The `catalog` channel works on browser and API-key connections. Backends
  connect with `x-app-api-key` and no `Origin`; the connection limits are in
  [WebSocket limits](https://docs.agg.market/api/websocket.md#limits).

## Related

> **[WebSocket Protocol](https://docs.agg.market/api/websocket.md#6-catalog-feed)**
>
> `catalog` subscribe, ack and `catalog_change` frames.

> **[List catalog changes](https://docs.agg.market/api-reference/markets/list-catalog-changes.md)**
>
> REST catch-up parameters and responses.

> **[Matched Clusters](https://docs.agg.market/recipes/matched-clusters.md)**
>
> What `matched` and the cluster anchor mean on list responses.

> **[Building market views](https://docs.agg.market/recipes/building-market-views.md)**
>
> Turning the events and markets you hold into screens.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.