SeatDataDocs

Migrating to v1

How to move from the v0.x sales and listings endpoints to their v1 equivalents

The sales data and listings endpoints now have v1 equivalents:

v0.xv1
GET /api/v0.3/salesdata/getGET /api/v1/events/{event_id}/sales
POST /api/v0.3/salesdata/batchPOST /api/v1/events/sales/batch
GET /api/v0.1.1/listings/getGET /api/v1/events/{event_id}/listings

Both versions are live. The v0.3 and v0.1.1 endpoints are not deprecated and remain fully supported — v1 is the modern contract, not a forced cutover. v1 returns the same sales rows plus a listing_id on every row, and adds cursor pagination, a JSON response envelope, structured JSON errors, and an optional source filter for selecting which marketplace a row was observed on. For listings, v1 returns the same rows with active as a boolean plus created_at and updated_at, and adds an active filter.

Billing is identical

Migrating to v1 does not change what a pull costs. A first page returning at least one sale is one billable pull; an event with no sales is free; continuation (cursor) pages are free. Fetching an event's full history through v1 pagination costs the same single pull as one v0.3 call. Batch billing is unchanged: one pull per event in the batch that returns data. The source value does not change what a page costs, and a rejected source is free. Listings billing is unchanged too: a first page is charged only when the event's listings refreshed since your last charged pull, continuation pages are free, and refresh state is shared between v0.1.1 and v1.

Authentication — nothing to migrate

Same 64-character key, same headers. Authorization: Bearer <key> is preferred; the legacy api-key: <key> header also works on every endpoint.

Single-event sales: GET /v1/events/{event_id}/sales

What changed from GET /v0.3/salesdata/get:

v0.3v1
RouteGET /api/v0.3/salesdata/getGET /api/v1/events/{event_id}/sales
Event selectorquery ?event_id= (SeatData ID) or ?event_id_sh= (Marketplace ID)ID in the path; SeatData ID by default. For a Marketplace ID, add ?id_type=marketplace
Response bodybare JSON array of rowsJSON object: { "event_id", "seatdata_event_id", "sh_event_id", "vs_event_id", "data": [rows], "has_more", "next_cursor", "total_count", "sources" }
Encodinggzip, forced on every response whether or not you askedapplication/json, no forced gzip — ordinary content negotiation
Paginationnone — returns all rowscursor pages: ?limit= (default 100, max 200), ?starting_after=<next_cursor>
Row fieldstimestamp, quantity, price, zone, section, rowsame plus listing_id, source, all_in_price, and on vs rows norm_zone, norm_section
Marketplacesingle marketplace only?source= selects sh (default), vs, or all
Row orderingtimestamp descendingtimestamp descending, then id descending within a source. Under source=all, a shared timestamp orders sh first
Errorsplain-text body + statusJSON error envelope (see below)

The three changes most likely to break a naive port

  1. Pagination can silently truncate. v0.3 returned every row in one array. v1 returns at most limit rows (default 100) with has_more: true and a next_cursor. A client that reads only data and stops will miss every row past the first page. To get everything, keep calling with starting_after=<next_cursor> until next_cursor is null (has_more false). Raise limit to 200 to cut round trips.
  2. The response is an object, not an array. Read rows from data, not the top level.
  3. Gzip is no longer forced. v0.3 sets Content-Encoding: gzip and compresses the body whatever your Accept-Encoding says, so clients hard-code a decompression step. v1 does not, and that hard-coded step breaks on a body that is not gzipped. Remove it and let your HTTP client handle any encoding it negotiated.

Requests

GET /api/v1/events/12345/sales?limit=200 HTTP/1.1
Authorization: Bearer <your-64-character-key>

Marketplace Event ID instead of SeatData Event ID (each GET /v1/events/search result carries it as sh_event_id, null when unmatched):

GET /api/v1/events/98765/sales?id_type=marketplace HTTP/1.1
Authorization: Bearer <your-64-character-key>

Next page:

GET /api/v1/events/12345/sales?starting_after=<next_cursor> HTTP/1.1
Authorization: Bearer <your-64-character-key>

Success shape

{
  "event_id": 12345,
  "seatdata_event_id": 12345,
  "sh_event_id": 98765,
  "vs_event_id": 5551234,
  "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" }
  ]
}
  • total_count appears on the first page only (omitted on continuation pages), and counts rows under the active source filter.
  • sources appears on the first page only. It always carries two entries, sh first, whatever source you sent. status: "unavailable" means that marketplace could not be read for this request; the response still succeeds with the rows that were available.
  • collecting_since reports the earliest sale observed for that event on that marketplace, counting sales these endpoints do not return. You can see a collecting_since older than your oldest returned row.
  • next_cursor is opaque — pass it back verbatim, don't parse it. It's scoped to that one event and the source it was issued under; a cursor from another event, endpoint, or source returns 400 invalid_cursor. Cursors expire after 1 hour (same as the stats endpoint).
  • event_id echoes the literal value from the path. seatdata_event_id, sh_event_id, and vs_event_id appear on every page and give the SeatData Event ID and the Marketplace Event ID on each marketplace (null when unmatched), whichever id style you sent.

Each row is a real sale transaction. Sales whose seat details could not be determined are not returned, and are not counted in total_count.

Multi-source rows

The source parameter is optional and defaults to sh. A request that omits it returns exactly what it returned before, at the same cost, so no existing integration needs to change.

sourceReturns
omitted, or shThe default. Unchanged rows, unchanged charges.
vsOnly sales observed on the second marketplace.
allBoth, merged newest-first.

The two sources do not carry the same keys

Read source to tell which shape a row has. Do not assume a field is present on both.

sh rowvs row
listing_idintegerstring
zoneas reportedalways "" — use norm_zone
all_in_pricealways nullthe fee-inclusive price, when the marketplace showed one
norm_zone, norm_sectionabsentpresent

Every row carries source and all_in_price:

FieldTypeMeaning
sourcestring"sh" or "vs" — the marketplace the sale was observed on.
all_in_pricenumber | nullFee-inclusive price the marketplace showed. Always null on sh rows.

A vs row carries two more:

FieldTypeMeaning
norm_zonestringThe zone name used for the same seats in sh sales for that event or venue. Derived by matching, not reported by the marketplace. "" when no match is found.
norm_sectionstringThe same, for the section.

sh rows carry neither. Their names are the naming space vs rows are translated into, so they have nothing to translate.

Joining the two sources

The normalized fields exist so you can line a vs sale up against the sh sales for the same seats. Compare norm_section against section, and norm_zone against zone:

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"]
    ]

An empty norm_section or norm_zone means no match was found; those rows cannot be joined this way.

Reading `vs` rows

  1. listing_id is a string, not an integer. It is an integer on sh rows. Type your parser accordingly, and compare it as a string if you compare it at all.
  2. zone is always "". That marketplace does not report a zone. Use norm_zone instead.
  3. section may combine zone and section into one label, such as Lower Level 112. norm_section holds the section on its own.
  4. Counts are a lower bound. These sales are observed from listing changes between pulls, so an event's vs rows are not a complete record of its sales there.
  5. Pick one source per walk. Cursors are bound to the source that issued them; replaying one under a different source returns 400 invalid_cursor.

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.

Batch sales: POST /v1/events/sales/batch

What changed from POST /v0.3/salesdata/batch:

v0.3v1
RoutePOST /api/v0.3/salesdata/batchPOST /api/v1/events/sales/batch
Body{ "event_ids": [...], "event_ids_sh": [...] }same
Max events100 (combined, deduped)same
Response{ "results": {"<sent id>": [rows]}, "errors": {"<sent id>": "not_found" | "payment_required"} }same shape
Encodinggzipplain application/json
Row fieldsv0.3 rowsame plus listing_id, source, all_in_price, and on vs rows the normalized fields
Marketplacesingle marketplace only?source= on the URL selects sh (default), vs, or all
Malformed bodyplain-text 400JSON error envelope, 400 invalid_param
  • results/errors are keyed by the exact identifier string you sent (a SeatData ID and a Marketplace ID that resolve to the same event are echoed under both keys).
  • Batch has no pagination and no id_type parameter — it returns all rows per event, driven by the two-list body. Only the single-event GET paginates.
  • source is a query-string parameter here, not a body field: POST /api/v1/events/sales/batch?source=all.
  • The response adds a top-level sources object keyed by the same client-sent event ids as results. Each value lists both marketplaces, sh first, whatever source you sent. results and errors are unchanged in shape. A top-level events object, keyed the same way, gives each served event's seatdata_event_id, sh_event_id, and vs_event_id; ids listed in errors have no entry.
  • Billing is per-event: each event that returns rows is charged once; empty events are free. If the balance runs out mid-batch, already-affordable events are still served and the rest come back as "payment_required" in errors; if nothing is affordable, the endpoint returns 402. Identical to v0.3.

Single-event listings: GET /v1/events/{event_id}/listings

What changed from GET /v0.1.1/listings/get:

v0.1.1v1
RouteGET /api/v0.1.1/listings/getGET /api/v1/events/{event_id}/listings
Event selectorquery ?event_id= (SeatData Event ID) or ?event_id_sh= (Marketplace Event ID)ID in the path; SeatData Event ID by default. For a Marketplace Event ID, add ?id_type=marketplace
Response body{ "has_refreshed", "last_refresh_timestamp", "listings": [rows] }{ "event_id", "seatdata_event_id", "sh_event_id", "vs_event_id", "data": [rows], "has_more", "next_cursor", "total_count", "has_refreshed", "last_refreshed_at" }
Encodinggzip, forced on every responseapplication/json, no forced gzip — ordinary content negotiation
Paginationnone — returns all rowscursor pages: ?limit= (default 500, max 1000), ?starting_after=<next_cursor>
Filternone — active and delisted rows together?active=true or ?active=false; omit for both
Row fieldslisting_id, active (0/1), zone, section, row, quantity_start, quantity, pricesame, with active as a boolean, plus created_at, updated_at (ISO-8601 UTC, null when unknown)
Billing flaghas_refreshed as 0/1has_refreshed as a boolean
Refresh timelast_refresh_timestamp (unix seconds)last_refreshed_at (ISO-8601 UTC, null when never refreshed)
Row orderinglisting_id ascendinglisting_id ascending, then id ascending
Errorsplain-text body + statusJSON error envelope (see below)

The four changes most likely to break a naive port

  1. Pagination can silently truncate. v0.1.1 returned every row in one array. v1 returns at most limit rows (default 500) with has_more: true and a next_cursor. Keep calling with starting_after=<next_cursor> until next_cursor is null. Raise limit to 1000 to cut round trips.
  2. The response is an object, not an array, and the rows moved. Read rows from data, not listings.
  3. Booleans, not 0/1. active on every row and has_refreshed on the first page are JSON booleans. A strict check such as row.active === 1, or a typed client that decodes active into an integer field, fails on v1.
  4. Gzip is no longer forced. Remove any hard-coded decompression step and let your HTTP client handle the encoding it negotiated.

Requests

GET /api/v1/events/12345/listings?active=true&limit=1000 HTTP/1.1
Authorization: Bearer <your-64-character-key>

Marketplace Event ID instead of SeatData Event ID:

GET /api/v1/events/98765/listings?id_type=marketplace HTTP/1.1
Authorization: Bearer <your-64-character-key>

Next page (send the same active value you sent on the first page):

GET /api/v1/events/12345/listings?active=true&limit=1000&starting_after=<next_cursor> HTTP/1.1
Authorization: Bearer <your-64-character-key>

Success shape

{
  "event_id": 12345,
  "seatdata_event_id": 12345,
  "sh_event_id": 98765,
  "vs_event_id": null,
  "data": [
    { "listing_id": 7300750943, "active": true, "zone": "Upper Level", "section": "216",
      "row": "12", "quantity_start": 4, "quantity": 2, "price": 66.45,
      "created_at": "2026-09-12T04:40:40Z", "updated_at": "2026-09-29T09:21:07Z" }
  ],
  "has_more": true,
  "next_cursor": "opaque-token-string",
  "total_count": 1834,
  "has_refreshed": true,
  "last_refreshed_at": "2026-09-30T13:52:10Z"
}
  • total_count, has_refreshed, and last_refreshed_at appear on the first page only. total_count is counted at the first page under the active filter; listings observed while you page can change it.
  • has_refreshed is true when the call was charged. Refresh state is shared with v0.1.1: a charged pull through either version counts as your last charged pull for both.
  • next_cursor is opaque — pass it back verbatim. It is scoped to that one event and the active value it was issued under; a cursor replayed under a different active value returns 400 invalid_cursor. Cursors expire after 1 hour.
  • Rows are never repeated across the pages of one walk.
  • limit applies per request and is not carried by the cursor; send it again on each continuation.

Error envelope (all v1 endpoints on this page)

v0.3 and v0.1.1 returned plain-text errors and collapsed most failures into generic 400/401 responses. v1 returns a structured envelope so clients can branch on the specific failure:

{ "error": { "type": "...", "code": "...", "message": "...", "param": "..." } }

param appears only for input-validation errors. Codes these endpoints emit:

StatustypecodeWhen
400invalid_requestinvalid_parambad id_type, bad source, bad active, malformed batch body
400invalid_requestinvalid_cursorexpired/unknown/wrong-scope starting_after, or a cursor replayed under a different source or active value
401authentication_errormissing_api_key / invalid_api_keyno/malformed key, or key not found
402(balance envelope)insufficient_balance / quota_and_balance_exhausted / frozenaccount can't cover the charge
403subscription_requiredsubscription_requiredkey valid but no active API access
404not_foundevent_not_foundunknown event (including id_type=marketplace with an unknown ID)
429rate_limit_errorrate_limitedover the limit; response carries a Retry-After header

Two shapes to be aware of:

  • A non-integer event id in the GET path never reaches the API — URL routing rejects it with a plain HTTP 404 page, not a JSON envelope. Any integer id that doesn't match an event returns 404 event_not_found as JSON.
  • The 402 body is a flat balance envelope, not the nested error object above: {"error": "payment_required", "code": "...", ...} with balance details and a top_up_url for adding funds.

For current rate limits, treat GET /v1/account and the rate-limit response headers as authoritative — see the Rate Limits guide.

On this page