Get sales data (batch)
/v1/events/sales/batchReturns sales records for up to 100 events per request, combined
across both id lists after deduplication. Each row is a real sale
transaction. Rows have the same shapes as on
GET /v1/events/{event_id}/sales, including the per-source key
differences described there.
The v1 equivalent of POST /v0.3/salesdata/batch - same request
body, same response shape, same billing. Differences: responses are
application/json with no forced gzip, rows include
listing_id, and a malformed body returns a JSON error envelope
instead of plain text. See the
migration guide.
There is no pagination and no id_type parameter here - the two id
lists in the body drive the request, and all rows are returned for
each event.
Sources
The optional source parameter behaves exactly as it does on
GET /v1/events/{event_id}/sales: sh (the default),
vs, or all. Send it on the URL - it is a query-string
parameter, not a body field:
POST /api/v1/events/sales/batch?source=allThe response carries a top-level sources object, keyed by the
same client-sent event ids as results. Each value lists both
marketplaces, sh first, regardless of the source filter.
results and errors are unchanged in shape.
An invalid source returns 400 invalid_param with param set
to source, free of charge.
results and errors are keyed by the exact identifier string you
sent: Marketplace Event IDs appear under the marketplace id,
SeatData Event IDs under the SeatData id (a SeatData ID and a
Marketplace ID that resolve to the same event are echoed under both
keys).
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? |
|---|---|
| Event in the batch returns at least one sale | Yes - 1 pull per such event |
| Event in the batch has no sales | No (free) |
| Balance runs out mid-batch | Events already affordable are served and charged; the rest come back as payment_required in errors |
| No event in the batch is affordable | No data - the request returns 402 |
Pass Authorization: Bearer <api_key>. Preferred for new integrations.
In: header
Query Parameters
Marketplace filter, sent on the URL rather than in the body. sh (the default) returns exactly the rows and charges callers received before multi-source support. vs returns only sales observed on the second marketplace. all returns both, merged newest-first. Any other value returns 400 invalid_param, free of charge.
"sh"Value in
- "sh"
- "vs"
- "all"
Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
application/json
application/json
application/json
application/json
application/json
application/json
curl -X POST "https://example.com/v1/events/sales/batch" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "event_ids_sh": [ 105294241, 105288127 ], "event_ids": [ 225220 ] }'{ "results": { "property1": [ { "timestamp": 1789489812, "quantity": 2, "price": 145, "zone": "Lower Level", "section": "112", "row": "12", "listing_id": 4871203955, "source": "sh", "all_in_price": null } ], "property2": [ { "timestamp": 1789489812, "quantity": 2, "price": 145, "zone": "Lower Level", "section": "112", "row": "12", "listing_id": 4871203955, "source": "sh", "all_in_price": null } ] }, "errors": { "property1": "not_found", "property2": "not_found" }, "sources": { "property1": [ { "source": "sh", "collecting_since": "2026-01-15", "tracked_for_event": true, "status": "ok" } ], "property2": [ { "source": "sh", "collecting_since": "2026-01-15", "tracked_for_event": true, "status": "ok" } ] }, "events": { "property1": { "seatdata_event_id": 1117621, "sh_event_id": 159009391, "vs_event_id": 5895194 }, "property2": { "seatdata_event_id": 1117621, "sh_event_id": 159009391, "vs_event_id": 5895194 } }}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"} ] } ```
Get listings GET
Returns the listings SeatData holds for a single event - active listings and delisted ones - ordered by `listing_id` ascending, then by id ascending (a stable tiebreak for paging; a `listing_id` can appear more than once when the marketplace reuses it). The v1 equivalent of `GET /v0.1.1/listings/get`. Same rows, same billing - plus cursor pagination, a JSON response envelope, an `active` filter, `created_at` / `updated_at` on every row, and structured JSON errors. `active` is a JSON boolean here (`0`/`1` on v0.1.1). Responses are `application/json`, and v1 never forces gzip the way v0.1.1 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/). ## Billing rules | Scenario | Charged a pull? | |---|---| | First page, and the event's listings refreshed since your last charged pull | Yes - 1 pull | | First page, nothing refreshed since your last charged pull | No | | Follow-up paginated page (cursor present) | No (continuation) | | No listings under the filter | No (empty result, free) | `has_refreshed` on the first page tells you whether that call was charged. Refresh state is shared with `GET /v0.1.1/listings/get`: a charged pull of an event through either version counts as your last charged pull for both. Fetching every page through pagination costs the same single pull as one v0.1.1 call. The `active` value does not change what a page costs. ## Pagination Cursor-based. The first page includes `total_count`, `has_refreshed` and `last_refreshed_at`; continuation pages omit them. `next_cursor` is opaque - pass it back verbatim via `starting_after`; don't parse it. Cursors are scoped to the event and the `active` value they were issued under, and expire after 1 hour. Replaying a cursor with a different `active` value returns `400` `invalid_cursor`. Use one `active` value for every page of a walk. `limit` applies per request and is not carried by the cursor; send it again on each continuation. Pages are not a snapshot. Listings SeatData observes while you page can appear or change between pages, and `total_count` is the count at the first page. A row is never repeated across the pages of one walk. ### Example: fetching every page ```python tab="Python" import requests base = "https://seatdata.io/api/v1/events/12345/listings" headers = {"Authorization": "Bearer YOUR_API_KEY"} rows = [] params = {"limit": 1000, "active": "true"} 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 = {"active": "true", "limit": 1000, "starting_after": page["next_cursor"]} print(len(rows), "active listings") ``` ```bash tab="cURL" API_KEY="YOUR_API_KEY" BASE="https://seatdata.io/api/v1/events/12345/listings" URL="$BASE?limit=1000&active=true" while [ -n "$URL" ]; do PAGE=$(curl -s "$URL" -H "Authorization: Bearer $API_KEY") echo "$PAGE" | jq -c '.data[]' >> listings.jsonl CURSOR=$(echo "$PAGE" | jq -r '.next_cursor // empty') URL=${CURSOR:+"$BASE?active=true&limit=1000&starting_after=$CURSOR"} done wc -l listings.jsonl ```