SeatDataDocs

Migrating Sales Data to v1

How to move from the v0.3 sales endpoints to their v1 equivalents

The sales data endpoints now have v1 equivalents:

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

Both versions are live. The v0.3 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.

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.

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", "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:

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,
  "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.

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.
  • 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.

Error envelope (both v1 endpoints)

v0.3 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, malformed batch body
400invalid_requestinvalid_cursorexpired/unknown/wrong-scope starting_after, or a cursor replayed under a different source
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