Matched Clusters
How AGG links the same event across venues, and which id to use as your cross-venue join key
The same real-world question is listed separately on every venue. AGG's matching pipeline links those listings into a matched cluster, and discovery responses return the cluster already assembled — you do not need to match markets yourself.
This page explains what a cluster is, which id identifies it, and where each cluster field lives on the response.
What a cluster is
A cluster is a set of venue events that AGG has determined are the same real-world event, plus the market- and outcome-level pairings between them.
Matched cluster
├── venueEvent (polymarket) ← anchor: the row discovery returns
├── venueEvent (kalshi) ← member
├── venueEvent (predict) ← member
└── venueEvent (limitless) ← member
Each venueEvent
├── matchedVenueEvents[] ← the same event on the other venues
└── venueMarkets[]
├── venueMarketOutcomes[] (Yes / No)
│ └── matchedVenueMarketOutcomes[] ← the aligned outcome elsewhere
└── matchedVenueMarkets[] ← the same market on the other venues
The cluster is exposed at all three levels, and each level is symmetric: you get the whole set regardless of which member you started from.
Matching combines deterministic canonicalization, in-house LLM scoring, and manual verification. Its output is the cluster you see on the response — you consume the result, not the process.
Which id to join on
GET /venue-events returns one row per cluster. Every cluster has one anchor event, and the
list collapses each cluster down to it — member rows are not returned separately.
Use the id of the event returned by GET /venue-events as your cross-venue key. It is the
cluster's canonical handle, and it is the only identifier that refers to the cluster rather than
to one venue's listing of it.
Do not invent your own cross-venue key from titles, slugs or aggKey (see
Where aggKey fits below), and do not assume two venues' identifiers can be
related to each other without AGG's matching — that is the problem matching exists to solve. Storing
a single venue's identifier as a durable handle you re-resolve from is a different thing, and is
supported: see the tip below.
Re-resolve rather than treating the id as permanent. Clusters can merge as matching improves; when they do, the surviving cluster keeps one anchor and the other's id becomes an ordinary member id. It stays a valid event id and still resolves, but it no longer represents the cluster.
Caching it for a session or a page is fine. If you store it long-term — a watchlist, a saved view — refresh it from discovery rather than assuming it will always come back as the cluster head.
For long-term storage, key on a venue's own identifier instead. A cluster anchor can change
when clusters merge; (venue, externalIdentifier) never does — it is the venue's own permanent
handle for that listing. Store that, then resolve it back to the live cluster whenever you need it
via Starting from a venue's own identifier below. This
is the durable pattern for watchlists, positions, reconciliation and anything you persist.
Cluster fields, and where they live
Two different scopes are easy to confuse:
| Field | Scope | What it tells you |
|---|---|---|
venues[], venueCount | the event row | Which venues that row's cluster spans — use it for venue badges. |
matchedVenueEvents[] | the event row | The same event on the other venues: id, venue, externalIdentifier, title, slug, status, and series.externalIdentifier (needed to build a canonical Kalshi URL). Empty array when the event is in no cluster. |
marketCount, groupMarketCount | the event row | How many markets that row carries. |
matchedVenueMarkets[] | each market | The same market on the other venues, with their outcomes. |
matchedVenueMarketOutcomes[] | each outcome | { venueMarketId, venueMarketOutcomeId } references to the aligned outcome on each matched venue. |
settlementDiff | the cluster | Resolution-criteria differences between members. See Settlement Differences. |
venues[] tells you how many venues a cluster spans; matchedVenueEvents[] tells you which
events they are. Before, the only way to enumerate the members was to flatten
venueMarkets[].matchedVenueMarkets[].venueEvent — which under-reported, because the embedded
market array on list responses is a top-three preview and a member with no matched markets never
appeared at all. Use matchedVenueEvents[] instead.
Cross-venue pricing hangs off matchedVenueMarkets[], not off the event row. To show a best price,
collect each market's own id plus its matchedVenueMarkets[].id, then batch them into one
GET /midpoints call, or subscribe to those ids on the
WebSocket midpoint stream.
Starting from a venue identifier
If you already hold a venue's identifier — a Kalshi ticker, a Polymarket id, a Predict slug — you can resolve it straight to its cluster. This works at all three levels, and it is bidirectional: it does not matter whether the identifier you hold belongs to the cluster's anchor or to one of its members. You get the same cluster either way.
Events
curl -G "https://api.agg.market/venue-events" \
--data-urlencode "venue=kalshi" \
--data-urlencode "externalIdentifier=KXCS2GAME-26AUG1214003DMAXNAVI" \
-H "x-app-id: $AGG_APP_ID"
The response row is the Kalshi event itself, and matchedVenueEvents[] carries the rest of the
cluster:
{
"venue": "kalshi",
"externalIdentifier": "KXCS2GAME-26AUG1214003DMAXNAVI",
"venueCount": 4,
"matchedVenueEvents": [
{ "venue": "polymarket", "externalIdentifier": "824593", "id": "…" },
{ "venue": "limitless", "externalIdentifier": "353490", "id": "…" },
{ "venue": "predict", "externalIdentifier": "cs2-navi-3dmax-2026-08-12","id": "…" }
]
}
Query any of those three identifiers instead and you get that venue's row back, with the other three
in its matchedVenueEvents[]. Same cluster, four different entry points.
Markets
curl -G "https://api.agg.market/venue-markets" \
--data-urlencode "venue=polymarket" \
--data-urlencode "externalIdentifier=3471056" \
-H "x-app-id: $AGG_APP_ID"
matchedVenueMarkets[] carries the same market on every other venue. Note that a cluster can contain
two markets from the same venue — Kalshi lists a head-to-head as one market per team, and both are
matched in, so [kalshi, kalshi, limitless, predict] is a correct result, not a duplicate.
Outcomes
curl -G "https://api.agg.market/venue-market-outcomes" \
--data-urlencode "externalIdentifier=$TOKEN_ID" \
-H "x-app-id: $AGG_APP_ID"
matchedVenueMarketOutcomes[] gives the aligned outcome on each matched venue as
{ venueMarketId, venueMarketOutcomeId } id pairs. Alignment is by meaning, not by label position —
where two venues frame the same question in opposite directions, the pairing still points at the
outcome that means the same thing. Fetch the full objects from
GET /venue-markets if you need more than ids.
Rules
venueis required on/venue-eventsand/venue-markets, because a venue identifier is only unique within its venue. Omitting it returns400.venueis optional on/venue-market-outcomes— outcome identifiers are globally unique — where it acts as a narrowing filter only.- Batch up to 20 identifiers per request by repeating the parameter. Over 20 returns
400. - Unknown identifiers are omitted from
datarather than returned as nulls, so a batch of 20 may return fewer than 20 rows. Match responses back to your input by readingexternalIdentifieron each item. - Members are returned as themselves. These lookups deliberately do not collapse a cluster to its anchor the way the browse listing does — you asked for a specific venue's row, so you get it.
Kalshi outcome identifiers cover one side only. Kalshi puts its ticker on the Yes-side outcome;
the No side has no identifier and cannot be looked up directly. Fetch the market instead and read
both outcomes from venueMarketOutcomes[]. Polymarket, Predict, Limitless, Myriad, Opinion and
Hyperliquid carry an identifier on every outcome.
Querying a member id
GET /venue-events/{id} does not resolve a member id up to its anchor — it returns exactly the row
you asked for. That row is still usable for rendering, because each of its markets carries the full
set of matchedVenueMarkets[] siblings regardless of which member you queried.
What differs between an anchor and a member response:
| Anchor id | Member id | |
|---|---|---|
matchedVenueEvents[] | full cluster | full cluster |
matchedVenueMarkets[] per market | full cluster | full cluster |
settlementDiff | cluster-wide | cluster-wide |
venueMarkets[] | the anchor's own markets | that member's own markets |
marketCount, groupMarketCount | anchor-scoped | member-scoped |
venues[], venueCount | the cluster's venues | usually the same; narrower if that member has no matched markets of its own |
title, description, image, slug | the anchor's | that venue's copy |
So a member row can be missing markets the anchor carries — props or extra legs with no counterpart
elsewhere — and its title and description are the member venue's wording. For a canonical view of
the cluster, query the id you got from GET /venue-events.
What does not differ is the cluster itself: matchedVenueEvents[], matchedVenueMarkets[] and
matchedVenueMarketOutcomes[] are complete from any member. If all you need is "which venues carry
this, and under which identifiers", any member answers it.
Market lists: preview vs full
The venueMarkets array embedded on GET /venue-events list items is a preview — up to three
markets chosen to represent the event on a card, not the complete set. Which three depends on the
event's shape, so do not treat them as the largest or most liquid markets, and do not render
counts or totals from the array length. Use marketCount.
GET /venue-events/{id} returns the event's markets uncapped. Both embedded arrays are deprecated:
fetch markets from GET /venue-markets?venueEventId=,
which is filterable, paginated, and returns the same matchedVenueMarkets[] siblings per market.
Note that ?venueEventId= is event-scoped by design: it returns that event's own markets, with
cross-venue siblings inline on each market — not a flattened list of every member's markets.
Where aggKey fits
aggKey is a deterministic canonical key computed at discovery time from each
venue's native data. It is one input to matching and a convenient fetch filter — it is not the
mechanism that produces clusters, and it is not a join key.
- It is
nullfor a substantial share of rows. Grouping byaggKeysilently under-groups every cluster whose members could not be canonicalized. - It is guaranteed identical across venues only where every venue exposes the same normalizable inputs — sports head-to-head and crypto up/down markets. Elsewhere it is derived from each venue's own slug or ticker and its own resolution timestamp, so two members of the same cluster can carry different keys.
Use aggKey to fetch related rows for a key you already hold. Use the cluster and its anchor id
for identity.