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.3 | v1 |
|---|---|
GET /api/v0.3/salesdata/get | GET /api/v1/events/{event_id}/sales |
POST /api/v0.3/salesdata/batch | POST /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.3 | v1 | |
|---|---|---|
| Route | GET /api/v0.3/salesdata/get | GET /api/v1/events/{event_id}/sales |
| Event selector | query ?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 body | bare JSON array of rows | JSON object: { "event_id", "data": [rows], "has_more", "next_cursor", "total_count", "sources" } |
| Encoding | gzip, forced on every response whether or not you asked | application/json, no forced gzip — ordinary content negotiation |
| Pagination | none — returns all rows | cursor pages: ?limit= (default 100, max 200), ?starting_after=<next_cursor> |
| Row fields | timestamp, quantity, price, zone, section, row | same plus listing_id, source, all_in_price, and on vs rows norm_zone, norm_section |
| Marketplace | single marketplace only | ?source= selects sh (default), vs, or all |
| Row ordering | timestamp descending | timestamp descending, then id descending within a source. Under source=all, a shared timestamp orders sh first |
| Errors | plain-text body + status | JSON error envelope (see below) |
The three changes most likely to break a naive port
- Pagination can silently truncate. v0.3 returned every row in one array. v1 returns at most
limitrows (default 100) withhas_more: trueand anext_cursor. A client that reads onlydataand stops will miss every row past the first page. To get everything, keep calling withstarting_after=<next_cursor>untilnext_cursorisnull(has_morefalse). Raiselimitto 200 to cut round trips. - The response is an object, not an array. Read rows from
data, not the top level. - Gzip is no longer forced. v0.3 sets
Content-Encoding: gzipand compresses the body whatever yourAccept-Encodingsays, 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_countappears on the first page only (omitted on continuation pages), and counts rows under the activesourcefilter.sourcesappears on the first page only. It always carries two entries,shfirst, whateversourceyou sent.status: "unavailable"means that marketplace could not be read for this request; the response still succeeds with the rows that were available.collecting_sincereports the earliest sale observed for that event on that marketplace, counting sales these endpoints do not return. You can see acollecting_sinceolder than your oldest returned row.next_cursoris opaque — pass it back verbatim, don't parse it. It's scoped to that one event and thesourceit was issued under; a cursor from another event, endpoint, orsourcereturns400 invalid_cursor. Cursors expire after 1 hour (same as the stats endpoint).event_idechoes 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.
source | Returns |
|---|---|
omitted, or sh | The default. Unchanged rows, unchanged charges. |
vs | Only sales observed on the second marketplace. |
all | Both, 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 row | vs row | |
|---|---|---|
listing_id | integer | string |
zone | as reported | always "" — use norm_zone |
all_in_price | always null | the fee-inclusive price, when the marketplace showed one |
norm_zone, norm_section | absent | present |
Every row carries source and all_in_price:
| Field | Type | Meaning |
|---|---|---|
source | string | "sh" or "vs" — the marketplace the sale was observed on. |
all_in_price | number | null | Fee-inclusive price the marketplace showed. Always null on sh rows. |
A vs row carries two more:
| Field | Type | Meaning |
|---|---|---|
norm_zone | string | The 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_section | string | The 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
listing_idis a string, not an integer. It is an integer onshrows. Type your parser accordingly, and compare it as a string if you compare it at all.zoneis always"". That marketplace does not report a zone. Usenorm_zoneinstead.sectionmay combine zone and section into one label, such asLower Level 112.norm_sectionholds the section on its own.- Counts are a lower bound. These sales are observed from listing changes between pulls, so an event's
vsrows are not a complete record of its sales there. - Pick one
sourceper walk. Cursors are bound to thesourcethat issued them; replaying one under a differentsourcereturns400 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.3 | v1 | |
|---|---|---|
| Route | POST /api/v0.3/salesdata/batch | POST /api/v1/events/sales/batch |
| Body | { "event_ids": [...], "event_ids_sh": [...] } | same |
| Max events | 100 (combined, deduped) | same |
| Response | { "results": {"<sent id>": [rows]}, "errors": {"<sent id>": "not_found" | "payment_required"} } | same shape |
| Encoding | gzip | plain application/json |
| Row fields | v0.3 row | same plus listing_id, source, all_in_price, and on vs rows the normalized fields |
| Marketplace | single marketplace only | ?source= on the URL selects sh (default), vs, or all |
| Malformed body | plain-text 400 | JSON error envelope, 400 invalid_param |
results/errorsare 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_typeparameter — it returns all rows per event, driven by the two-list body. Only the single-event GET paginates. sourceis a query-string parameter here, not a body field:POST /api/v1/events/sales/batch?source=all.- The response adds a top-level
sourcesobject keyed by the same client-sent event ids asresults. Each value lists both marketplaces,shfirst, whateversourceyou sent.resultsanderrorsare 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"inerrors; 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:
| Status | type | code | When |
|---|---|---|---|
| 400 | invalid_request | invalid_param | bad id_type, bad source, malformed batch body |
| 400 | invalid_request | invalid_cursor | expired/unknown/wrong-scope starting_after, or a cursor replayed under a different source |
| 401 | authentication_error | missing_api_key / invalid_api_key | no/malformed key, or key not found |
| 402 | (balance envelope) | insufficient_balance / quota_and_balance_exhausted / frozen | account can't cover the charge |
| 403 | subscription_required | subscription_required | key valid but no active API access |
| 404 | not_found | event_not_found | unknown event (including id_type=marketplace with an unknown ID) |
| 429 | rate_limit_error | rate_limited | over 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_foundas JSON. - The 402 body is a flat balance envelope, not the nested
errorobject above:{"error": "payment_required", "code": "...", ...}with balance details and atop_up_urlfor adding funds.
For current rate limits, treat GET /v1/account and the rate-limit response headers as authoritative — see the Rate Limits guide.