SeatDataDocs

Get event stats

GET/v1/events/{event_id}/stats

Returns the historical event_stats time series for a single event. Each snapshot row includes event-level aggregates (avg/median price, get-in price, listing fill rate) AND inline zone-level breakdowns for every zone in that snapshot.

Billing rules

ScenarioCharged a pull?
First request for an event (no cursor, snapshots exist)Yes - 1 pull
Follow-up paginated page (cursor present)No (continuation)
Repeat first-page request, no new snapshot since last paid pullNo (freshness rule)
Repeat first-page request, new snapshot logged in betweenYes - 1 pull
Event has no event_stats rows yetNo (empty result, free)

The freshness rule means polling for "is there fresh data?" only costs when there's actually new data to consume.

Pagination

Cursor-based. The first page response includes total_count and available_zones; subsequent paginated pages omit them. Cursors expire after 1 hour.

Authorization

AuthorizationBearer <token>

Pass Authorization: Bearer <api_key>. Preferred for new integrations.

In: header

Path Parameters

event_id*integer

SeatData internal event id (from /v1/events/search)

Query Parameters

start_date?string

Filter snapshots after this time. ISO-8601 date or datetime.

end_date?string

Filter snapshots before this time. ISO-8601 date or datetime.

limit?integer

Max snapshots per page (default 100, max 200)

Rangevalue <= 200
Default100
starting_after?string

Cursor from a previous response's next_cursor

Response Body

application/json

application/json

application/json

application/json

application/json

curl -X GET "https://example.com/v1/events/225220/stats?start_date=2026-04-01&end_date=2026-04-30" \  -H "Authorization: Bearer YOUR_API_KEY"
{  "event_id": 589342,  "available_zones": [    "Lower Bowl",    "Mezzanine",    "Upper Deck",    "Nosebleed"  ],  "total_count": 487,  "data": [    {      "timestamp": "2026-04-26T14:30:00Z",      "total_listings_all": 487,      "total_listings_active": 312,      "listing_fill_rate": 0.64,      "avg_price": 245.5,      "median_price": 198,      "get_in": 89,      "get_in_qty2plus": 145,      "zones": [        {          "zone_name": "Lower Bowl",          "avg_price": 412.75,          "median_price": 395,          "get_in": 285,          "get_in_qty2plus": 320        }      ]    }  ],  "has_more": true,  "next_cursor": "string"}

Search events GET

Modern replacement for the v0.3.1 POST search. Returns event metadata with standardization (normalized venue name, country, lat/lng, slug, performer, tour name) flattened into each item. Use this to discover `event_id` values for the paid stats endpoint. No charge per call. ## Pricing principle This endpoint returns metadata only - never time-series or aggregate data derived from observed marketplace activity. Sales/listings data is paid via the dedicated endpoints. ## Pagination Cursor-based. Pass `starting_after` with the value from `next_cursor` to fetch the next page. Cursors expire after 1 hour.

Get sales data GET

Returns sales records for a single event - each row is a real sale transaction. Rows are ordered by `timestamp` descending, then by id descending within a source (a stable tiebreak for paging). Under `source=all`, two sales sharing a timestamp are ordered `sh` first, then by that source's id tiebreak. The v1 equivalent of `GET /v0.3/salesdata/get`. Same data, same billing - plus cursor pagination, a JSON response envelope, a `listing_id` on every row, and structured JSON errors. Responses are `application/json`, and v1 never forces gzip the way v0.3 does - any compression is ordinary content negotiation your HTTP client handles for you. See the [migration guide](https://docs.seatdata.io/docs/api/migrating-to-v1/). ## Sources Sales are observed on more than one marketplace. The optional `source` query parameter selects which rows you receive. | `source` | Returns | |---|---| | omitted, or `sh` | The default. Exactly the rows and charges callers received before multi-source support. | | `vs` | Only sales observed on the second marketplace. | | `all` | Both, merged newest-first. | Every row carries a `source` field naming the marketplace it was observed on. The two sources do not carry the same keys, so read `source` to tell which shape a row has: | | `sh` row | `vs` row | |---|---|---| | `listing_id` | integer | string | | `zone` | as reported | always `""` - use `norm_zone` | | `all_in_price` | always `null` | the fee-inclusive price, when shown | | `norm_zone`, `norm_section` | absent | present | Sales whose seat details could not be determined are not returned by these endpoints, and are not counted in `total_count`. On `vs` rows, sales are observed from listing changes between pulls, so the per-event count is a lower bound. ### Joining the two sources `norm_zone` and `norm_section` exist so a `vs` row can be matched to the `sh` rows for the same seats. They hold the zone and section names used for those seats in `sh` sales for that event or venue, derived by matching rather than reported by the marketplace, and are `""` when no match is found. `sh` rows carry neither field - their names are the naming space being translated into, so there is nothing to translate. To join, compare a `vs` row's `norm_section` against a `sh` row's `section`, and its `norm_zone` against `zone`: ```python primary = [r for r in rows if r["source"] == "sh"] secondary = [r for r in rows if r["source"] == "vs"] for sale in secondary: same_seats = [ r for r in primary if r["section"] == sale["norm_section"] and r["zone"] == sale["norm_zone"] ] ``` Compare `listing_id` as a string if you compare it at all - it is an integer on `sh` rows and a string on `vs` rows. A `source` value outside the three above returns `400` `invalid_param` with `param` set to `source`. That response is free - it is rejected before any billing or data read. ## Quantity and price `sh` sales often reach us unenriched, without a quantity or price. For those we derive both from the listing data we hold for that listing; a `quantity` of `0` means it could not be reliably determined. `vs` sales carry both as reported. ## Billing rules | Scenario | Charged a pull? | |---|---| | First page returns at least one sale | Yes - 1 pull | | Follow-up paginated page (cursor present) | No (continuation) | | Event has no sales | No (empty result, free) | Fetching an event's full history through pagination costs the same single pull as one `GET /v0.3/salesdata/get` call. The `source` value does not change what a page costs. ## Pagination Cursor-based. The first page response includes `total_count`; subsequent paginated pages omit it. `next_cursor` is opaque - pass it back verbatim via `starting_after`; don't parse it. Cursors are scoped to the event and the `source` they were issued under, and expire after 1 hour. Replaying a cursor with a different `source` returns `400` `invalid_cursor`. Use one `source` value for every page of a walk. ### Example: fetching every page Each response returns at most `limit` rows. To fetch an event's full sales history, keep requesting with `starting_after=<next_cursor>` until `next_cursor` is `null`: ```bash tab="cURL" API_KEY="YOUR_API_KEY" BASE="https://seatdata.io/api/v1/events/12345/sales" URL="$BASE?limit=200" while [ -n "$URL" ]; do PAGE=$(curl -s "$URL" -H "Authorization: Bearer $API_KEY") echo "$PAGE" | jq -c '.data[]' >> sales.jsonl CURSOR=$(echo "$PAGE" | jq -r '.next_cursor // empty') URL=${CURSOR:+"$BASE?starting_after=$CURSOR"} done wc -l sales.jsonl ``` ```python tab="Python" import requests base = "https://seatdata.io/api/v1/events/12345/sales" headers = {"Authorization": "Bearer YOUR_API_KEY"} rows = [] params = {"limit": 200} while True: response = requests.get(base, params=params, headers=headers) response.raise_for_status() page = response.json() rows.extend(page["data"]) if page["next_cursor"] is None: break params = {"starting_after": page["next_cursor"]} print(len(rows), "sales") ``` ```js tab="JavaScript" const base = "https://seatdata.io/api/v1/events/12345/sales"; const headers = { Authorization: "Bearer YOUR_API_KEY" }; const rows = []; let url = `${base}?limit=200`; while (url) { const response = await fetch(url, { headers }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const page = await response.json(); rows.push(...page.data); url = page.next_cursor ? `${base}?starting_after=${encodeURIComponent(page.next_cursor)}` : null; } console.log(rows.length, "sales"); ``` ```php tab="PHP" <?php $base = "https://seatdata.io/api/v1/events/12345/sales"; $headers = ["Authorization: Bearer YOUR_API_KEY"]; $rows = []; $url = $base . "?limit=200"; while ($url !== null) { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $headers, ]); $response = curl_exec($ch); if ($response === false) { throw new Exception(curl_error($ch)); } curl_close($ch); $page = json_decode($response, true); $rows = array_merge($rows, $page["data"]); $url = $page["next_cursor"] !== null ? $base . "?starting_after=" . urlencode($page["next_cursor"]) : null; } echo count($rows) . " sales\n"; ``` ```go tab="Go" package main import ( "encoding/json" "fmt" "log" "net/http" "net/url" ) type salesPage struct { Data []json.RawMessage `json:"data"` HasMore bool `json:"has_more"` NextCursor *string `json:"next_cursor"` } func main() { base := "https://seatdata.io/api/v1/events/12345/sales" var rows []json.RawMessage pageURL := base + "?limit=200" for pageURL != "" { req, err := http.NewRequest("GET", pageURL, nil) if err != nil { log.Fatal(err) } req.Header.Set("Authorization", "Bearer YOUR_API_KEY") res, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } if res.StatusCode != http.StatusOK { log.Fatalf("HTTP %d", res.StatusCode) } var page salesPage if err := json.NewDecoder(res.Body).Decode(&page); err != nil { log.Fatal(err) } res.Body.Close() rows = append(rows, page.Data...) if page.NextCursor == nil { pageURL = "" continue } pageURL = base + "?starting_after=" + url.QueryEscape(*page.NextCursor) } fmt.Println(len(rows), "sales") } ``` ### Example: multi-source response `GET /api/v1/events/12345/sales?source=all&limit=2` returns one row from each marketplace, merged newest-first. Note the differing key sets - the second row carries the normalized fields, the first does not: ```json { "event_id": 12345, "data": [ {"timestamp": 1789489812, "quantity": 2, "price": 145.0, "zone": "Lower Level", "section": "112", "row": "12", "listing_id": 4871203955, "source": "sh", "all_in_price": null}, {"timestamp": 1789489140, "quantity": 2, "price": 139.0, "zone": "", "section": "Lower Level 112", "row": "14", "listing_id": "8815520431", "source": "vs", "all_in_price": 171.5, "norm_zone": "Lower Level", "norm_section": "112"} ], "has_more": true, "next_cursor": "opaque-token-string", "total_count": 42, "sources": [ {"source": "sh", "collecting_since": "2026-01-15", "tracked_for_event": true, "status": "ok"}, {"source": "vs", "collecting_since": "2026-08-30", "tracked_for_event": true, "status": "ok"} ] } ```