VenuNite Data API
Loading current catalog coverage…
Event discovery through GET https://venunite.com/v1/events and authenticated ticket handoffs. The event parameter, response-field and error tables below are rendered from the same contract the server enforces. A few numbers in the prose — the key ceilings, the venue cap — are copied by hand from the modules named in the comments beside them. Spec version 1.7.1.
vn_live_… value is shown once; only its SHA-256 digest is stored, so copy it into your secret store before you dismiss it.key_limit_reached, 429 key_issuance_rate_limited); nothing revokes itself.Verified accounts without a previous developer subscription can try live REST data for 7 days and 1,000 event rows total, whichever comes first. No credit card or automatic charge. The first valid REST request starts the clock. All keys share the one-time allowance; new keys do not reset it. Up to 25 rows per response, the next 30 days and Basic filters, including radius searches. Repeated results use rows again; empty responses use none. Trial rate: 60 quota units per minute shared across keys; a geographic query uses 5 units. Free MCP is separate. After the trial, subscribe to continue REST with the same key.
Trial responses include X-DataApi-Access: trial, X-Trial-RowBudget-Limit, X-Trial-RowBudget-Remaining and X-Trial-Expires-At (UTC). Remaining rows account for requests in progress at admission; refresh your dashboard for current settled usage. The paid X-RowBudget-* headers describe a separate rolling 24-hour allowance.
Send the key as a bearer token on every request:
Authorization: Bearer vn_live_...A missing, malformed or revoked key is 401 invalid_api_key. A valid key whose account has no active developer plan or eligible trial is 402 subscription_required — deliberately distinct, so a lapsed subscription never looks like a broken key. Every response carries an x-request-id header; quote it if you write to us. Trial expiry and exhaustion return 402 trial_expired and 402 trial_exhausted respectively.
Events start from now by default, so a first request needs no dates. Results are ordered by startDate then id.
These examples fetch one page (up to 25 events), not every event in the date window. If page.hasMore is true, send page.nextCursor as cursor with the same filters to continue. Repeating a request without cursor starts at the first page again. Copy a complete pagination example.
curl --fail-with-body -H "Authorization: Bearer vn_live_..." \
"https://venunite.com/v1/events?state=CO&limit=25"import httpx
r = httpx.get(
"https://venunite.com/v1/events",
headers={"Authorization": "Bearer vn_live_..."},
params={"state": "CO", "limit": 25},
)
r.raise_for_status()
result = r.json()
for event in result["data"]:
print(event["startDate"], event["title"], "@", event["venue"]["name"])
print("Pagination:", result["page"])// Node 18+: save as first-page.mjs and run with node.
const r = await fetch(
"https://venunite.com/v1/events?state=CO&limit=25",
{ headers: { Authorization: "Bearer vn_live_..." } },
);
if (!r.ok) throw new Error(await r.text());
const { data, page } = await r.json();
for (const e of data) {
console.log(e.startDate, e.title, "@", e.venue.name);
}
console.log("Pagination:", page);All parameters are optional, and every non-geo parameter composes with the others and with the cursor. The geo filters are the exception: pass radius or polygon, never both (400 conflicting_geo_filters), and lat/lng/radius_miles only as a set (400 invalid_coordinates). An unrecognized state, date, cursor or geo value is a 400 with a typed error code (below), never a silently empty page — state is checked against the 51 valid US codes — the 50 states plus DC.
Two parameters do not work that way. city is matched exactly and case-sensitively, so a misspelt city returns an empty page. source is checked for shape only: a token with illegal characters is 400 invalid_source, but there is no list of valid sources to check a well-formed one against, so a token that names nothing simply matches nothing and returns an empty page. page.count is 0 whether the source is misspelt or real-but-empty for your window, and the response does not echo the source back, so one response cannot tell you which. Widen the window and drop your other filters before concluding a source is empty rather than wrong.
| parameter | type | description |
|---|---|---|
state | string | Two-letter US state code, case-insensitive. Filters on the venue's state. An unrecognized code is a 400, not an empty result. e.g. state=CO |
city | string | Exact venue city name, case-sensitive. Combine with `state` to disambiguate (there are many Springfields). e.g. city=Denver |
starts_after | string · date-time | ISO 8601 date or date-time; the instant the window opens. Defaults to now. Events that have not ended by it are returned, including ones that started earlier and are still running, so `startDate` can be earlier than this value. Must not exceed the account's lookahead horizon (403 lookahead_not_in_plan). |
starts_before | string · date-time | ISO 8601 date or date-time; the latest `startDate` returned. Defaults to starts_after + 90 days (30 for geo), capped by the tier horizon. Explicit dates beyond the tier horizon return 403 lookahead_not_in_plan. Otherwise clamped to the technical window: + 365 days (90 for geo). |
source | string | Filter to one or more source prefixes. Repeat the parameter or comma-separate. Accepts the bare vendor name (`usda`) or the internal token (`scraper:usda`). Matched as a prefix, so `scraper:ticketmaster` covers all its per-market feeds. Validated for SHAPE only, unlike `state`: a malformed token is 400 invalid_source, but a well-formed token that names no real source is not an error — there are 24,409 distinct source prefixes in production, so there is no allowlist to check against, and the filter simply matches nothing. One caveat: underscores are legal in a source token AND are single-character wildcards in the underlying `LIKE '<prefix>%'` match, which applies no ESCAPE — so a token containing `_` can match sources it does not literally name. `page.count` is 0 both for a misspelt source and for a real source with no events in the window, and the response does not echo the source back, so the two are indistinguishable from a single response: widen the window and drop your other filters before concluding a source is empty rather than wrong. e.g. source=usda |
exclude_aggregators | boolean · default false | When true, omit events sourced from the major ticketing aggregators (Ticketmaster, Eventbrite, AXS, SeatGeek, AEG, Live Nation, DICE, TicketWeb) — the long-tail inventory those platforms do not carry. This uses the same aggregator list as the coverage figure published on the Data API page, so the filter and the claim are built from one definition; the published figure matches case-insensitively (ILIKE) and this filter case-sensitively (LIKE). |
exclude_sold_out | boolean · default false | When true, omit events flagged isSoldOut — the 1.0.0 result set. Since 1.1.0 sold-out events are included and flagged (0.6% of the default 90-day window, measured 2026-09-02). |
limit | integer · min 1 · max 100 · default 25 | Events per page, 1-100; outside that range is 400 invalid_limit. Paid plans serve up to 100 per call. Trial pages are clamped to 25 and to the remaining total allowance. The response does not echo the effective limit; `page.count` is the actual delivered count. A short page alone is not the end of the feed: use `page.hasMore`, subject to your remaining allowance and expiry. |
cursor | string | Opaque token from a previous response's `page.nextCursor`. Omit it only for a new query or refresh; omitting it on repeated requests restarts page one. Keep the original filters and date parameters, and let your HTTP client URL-encode the token. |
Use either lat + lng + radius_miles or polygon; both at once is 400 conflicting_geo_filters.
| parameter | type | description |
|---|---|---|
lat | number · min -90 · max 90 | Latitude of the circle center, decimal degrees (-90..90). Requires `lng` and `radius_miles`. e.g. lat=39.740208 |
lng | number · min -180 · max 180 | Longitude of the circle center, decimal degrees (-180..180). Requires `lat` and `radius_miles`. e.g. lng=-104.894849 |
radius_miles | number · > 0 · max 25 | Circle radius in statute miles, greater than 0 and at most 25. Sets `venue.distanceMiles` on every event returned. Up to the 2,000 nearest matching venues are considered. e.g. radius_miles=5 |
polygon | string | Custom polygons require Business or Enterprise access; they are not included in Basic. Subscribers and evaluations active at the September 3, 2026 rollout retain their previous features as Legacy access while the original subscription remains active, including its existing trial expiry. A replacement subscription uses Basic unless separately upgraded. See your access at /developers/keys. 3 to 32 `lat,lng` vertices separated by `;`, in ring order (a delivery zone, a trade area). The ring closes itself — repeating the first vertex last is accepted and not counted toward the 32. The bounding box may be at most 50 miles across its diagonal. The extra pair the JSON Schema pattern permits over 32 is that optional closing vertex; 33 DISTINCT vertices match the pattern and are still rejected with 400 invalid_polygon. Self-intersecting rings are not rejected but give undefined membership. Up to the 2,000 venues nearest the polygon's bounding-box center are considered. Mutually exclusive with `lat`/`lng`/`radius_miles`. e.g. polygon=39.758,-104.895;39.752,-104.872;39.728,-104.872;39.722,-104.895;39.728,-104.918;39.752,-104.918 |
include_centroids | boolean · default false | When true, a geo filter also admits venues whose coordinate is a ZIP or city centroid (`venue.geocodePrecision` of `zip` or `city`). Those points can be several miles from the building, so this widens coverage at the cost of positional accuracy. Without a geo filter it changes no result — but it is part of the cursor's filter fingerprint either way, so flipping it mid-page invalidates a pinned cursor (400 invalid_cursor) even on a query with no geo filter at all. On an unpinned legacy token it is not compared, and the page set silently changes instead. |
lat/lng. Every event's venue.distanceMiles is the great-circle distance from your point to the venue's stored coordinate, rounded to 0.01. Up to the 2,000 venues nearest your point are considered — the same cap the polygon filter applies; if a circle holds more, the venues dropped are the farthest ones.lat,lng vertices separated by ;, in ring order (a delivery zone, a trade area). The ring closes itself; repeating the first vertex last is accepted and not counted. The bounding box may be at most 50 miles across its diagonal. Self-intersecting rings are not rejected but give undefined membership. Up to the 2,000 venues nearest the bounding-box center are considered. venue.distanceMiles is null on polygon queries — there is no single point to measure from.venue.geocodePrecision: street identifies the building; unknown is a source-supplied point that has not been classified (usually the venue itself); zip and city are centroids that can sit several miles from the door; null is a coordinate we have not classified at all — treat it exactly as cautiously as unknown. A geo filter admits street and unknown by default; pass include_centroids=true to drop the precision gate entirely — that admits the zip and city centroid tiers and the unclassified (null) coordinates as well, trading positional accuracy for coverage. Treat a distance exactly as well as its tier.hasMore is false when it is exhausted (a token from an earlier version pins no window — see Pagination); to walk further out, start a new request with starts_after at the previous window's end. A starts_before beyond the technical window maximum is clamped, but an explicit date beyond your account's lookahead horizon — on the tiers that have one; Legacy and Enterprise carry lookaheadDays: null and policyHorizonEnd returns null for them, so the 403 branch cannot fire — is rejected with 403, and the response does not echo the effective window, so compute it client-side.data: [] with hasMore: false. But data: [] with hasMore: true is also normal on any query: the page is picked from the serving snapshot and then hydrated live, so a cancellation, a soft delete, a spam reclassification, a primary-source demotion or a hidden venue between those two steps can drop some or all of a page's rows. nextCursor continues from the last snapshot row in the page, not the last row delivered, so page on hasMore/nextCursor — never on data.length.venue.latitude/longitude.curl --fail-with-body -H "Authorization: Bearer vn_live_..." \
"https://venunite.com/v1/events?lat=39.740208&lng=-104.894849&radius_miles=5&limit=100"import httpx
r = httpx.get(
"https://venunite.com/v1/events",
headers={"Authorization": "Bearer vn_live_..."},
params={
"lat": 39.740208,
"lng": -104.894849,
"radius_miles": 5,
"limit": 100,
},
)
r.raise_for_status()
result = r.json()
for event in result["data"]:
v = event["venue"]
print(f'{v["distanceMiles"]:.2f} mi {(v["geocodePrecision"] or "-"):8} {event["title"]}')
print("Pagination:", result["page"])const qs = new URLSearchParams({
lat: "39.740208",
lng: "-104.894849",
radius_miles: "5",
limit: "100",
});
const r = await fetch(`https://venunite.com/v1/events?${qs}`, {
headers: { Authorization: "Bearer vn_live_..." },
});
if (!r.ok) throw new Error(await r.text());
const { data, page } = await r.json();
for (const e of data) {
console.log(e.venue.distanceMiles, e.venue.geocodePrecision, e.title);
}
console.log("Pagination:", page);Custom polygons require Business or Enterprise access; they are not included in Basic. Subscribers and evaluations active at the September 3, 2026 rollout retain their previous features as Legacy access while the original subscription remains active, including its existing trial expiry. A replacement subscription uses Basic unless separately upgraded. See your access at /developers/keys. Compare tiers.
A six-vertex delivery-zone ring about 2.5 miles across. Vertices are lat,lng (latitude first), separated by ;. Add starts_before to narrow the 30-day default window, and any other filter to compose (for example exclude_aggregators=true).
# lat,lng vertices in ring order, separated by ";" (the ring closes itself)
curl --fail-with-body -H "Authorization: Bearer vn_live_..." \
"https://venunite.com/v1/events?polygon=39.758,-104.895;39.752,-104.872;39.728,-104.872;39.722,-104.895;39.728,-104.918;39.752,-104.918&limit=100"import httpx
# lat,lng vertices in ring order; httpx encodes ";" as %3B, which the API accepts
zone = "39.758,-104.895;39.752,-104.872;39.728,-104.872;39.722,-104.895;39.728,-104.918;39.752,-104.918"
r = httpx.get(
"https://venunite.com/v1/events",
headers={"Authorization": "Bearer vn_live_..."},
params={"polygon": zone, "limit": 100},
)
r.raise_for_status()
page = r.json()
print("Pagination:", page["page"])// lat,lng vertices in ring order; URLSearchParams encodes ";" as %3B, which the API accepts
const zone = "39.758,-104.895;39.752,-104.872;39.728,-104.872;39.722,-104.895;39.728,-104.918;39.752,-104.918";
const qs = new URLSearchParams({ polygon: zone, limit: "100" });
const r = await fetch(`https://venunite.com/v1/events?${qs}`, {
headers: { Authorization: "Bearer vn_live_..." },
});
if (!r.ok) throw new Error(await r.text());
const { data, page } = await r.json();
console.log("Pagination:", page);{ data: Event[], page: { nextCursor, hasMore, count } }. Every key is present on every event; optional fields are null, never omitted. A radius-query page (limit=1) looks like this — nextCursor is abbreviated, a real token is a few hundred opaque characters:
{
"data": [
{
"id": 1073422,
"title": "Thursday Night Trivia",
"slug": "thursday-night-trivia-2026-09-10",
"eventUrl": "https://venunite.com/events/thursday-night-trivia-2026-09-10",
"startDate": "2026-09-11T01:00:00.000Z",
"endDate": null,
"cost": "Free",
"isFree": true,
"age": "21+",
"isSoldOut": false,
"isLowTickets": false,
"ticketUrl": null,
"website": "https://example-brewery.com/events",
"ticketLinkStatus": "available",
"imageUrl": null,
"categories": ["Nightlife"],
"venue": {
"id": 322961,
"name": "Example Brewery",
"city": "Denver",
"timezone": "America/Denver",
"state": "CO",
"latitude": 39.7418,
"longitude": -104.9127,
"geocodePrecision": "unknown",
"distanceMiles": 0.96
}
}
],
"page": {
"nextCursor": "eyJ2Ijoy...In0",
"hasMore": true,
"count": 1
}
}| field | type | description |
|---|---|---|
idrequired | integer | |
titlerequired | string | |
slugrequired | string | |
eventUrlrequired | string · uri | Canonical VenuNite event page. Use this URL when citing or sharing a listing; website remains the external source. Pass id to POST /v1/ticket-handoffs for a supported ticket seller handoff. |
startDaterequired | string · date-time | |
endDate | string | null · date-time | |
cost | string | null | Resolved display cost after reconciling source text with structured isFree evidence. Returns "Free" when admission resolves free; hides stale legacy-free text as null when isFree is explicitly false; otherwise returns trimmed source text or null when no display amount is known. Inspect isFree to distinguish free, known non-free with an unknown amount, and unknown admission status. |
isFree | boolean | null | True for explicitly unconditional free admission, false for known non-free admission, and null when the source provides no trustworthy admission evidence. |
isSoldOutrequired | boolean | True when the source reports the event sold out. Sold-out events are included in the feed so inventory is complete; filter on this flag (or pass exclude_sold_out=true) to hide them. ticketLinkStatus reflects link health only and stays `available` for a sold-out event with a working link. |
isLowTicketsrequired | boolean | True when the source reports limited remaining tickets. |
age | string | null | |
ticketUrl | string | null | VenuNite ticket redirect for the first usable ticket or event URL after confirmed-broken links are removed. Null when no safe destination is available. |
website | string | null | Sanitized external event website after confirmed-broken links are removed or replaced. |
ticketLinkStatusrequired | string available | unavailable | missing | Available when a safe ticket or event destination can be served; unavailable when stored candidates are unusable; missing when none were supplied. |
imageUrl | string | null | |
categoriesrequired | array | Zero or more of a 21-category taxonomy, e.g. Music, Comedy, Sports, Food & Drink. May be empty. |
venue | object | null | Always an object on /v1/events: hydration requires a visible venue, so a venue-less event is never delivered — it is dropped from the page instead. Typed nullable because the shared serializer emits null when an event has no venue. |
venue fieldsAlways an object on /v1/events: hydration requires a visible venue, so a venue-less event is never delivered — it is dropped from the page instead. Typed nullable because the shared serializer emits null when an event has no venue.
| field | type | description |
|---|---|---|
id | integer | |
name | string | |
city | string | Matched EXACTLY and case-sensitively by the `city` filter — pass "Denver", not "denver". |
state | string | |
timezone | string | null | IANA time zone of the venue (e.g. `America/Denver`). `startDate` and `endDate` are UTC, so this is required to render local time — without it an evening event west of UTC formats to the wrong calendar day. Null where the zone could not be resolved. |
latitude | number | null | |
longitude | number | null | |
geocodePrecision | string | null street | zip | city | unknown | null | Precision of the coordinate pair. Only `street` identifies the venue building; `unknown` is a source-supplied point that has not been classified (usually the venue itself, occasionally a shared or approximate pin); ZIP/city coordinates are centroids suitable for broad coverage, not exact distance or map-pin claims. |
distanceMiles | number | null | Great-circle miles from the query's `lat`/`lng` to the venue's stored coordinate, rounded to 0.01. Only set on radius queries; null on polygon queries and on every other request. The distance is exactly as good as `geocodePrecision`: a `zip` or `city` centroid can sit several miles from the building. |
Basic can query published events starting within the next 30 days; Business within the next 90 days, measured from the request time. An explicit starts_after, starts_before or cursor beyond that horizon returns 403 lookahead_not_in_plan. Omitted end dates are capped to the account's horizon. Legacy and Enterprise retain the technical per-query window limits without an additional tier lookahead cap; availability and any Enterprise agreement still apply.
Pass page.nextCursor back as cursor with the same filters until page.hasMore is false. The cursor uses (startDate, id) to give events with the same start time an unambiguous order. Treat it as opaque; use your HTTP client to encode it, and do not parse or construct it. An empty data array is not the end of the feed — page until hasMore is false, never on data.length.
Next page and refresh are different operations. A request without cursor starts at page one, including when you move the dates forward by a few seconds. It can return the same earliest events repeatedly while later events remain on other pages. limit controls one page, not the total for your date window. page.count is the delivered count on that page, not a total match count.
Use the request parameter cursor. page=2, offset and next_cursor or nextCursor return 400 unsupported_pagination on admitted /v1/events requests. Any presence is rejected, including an empty value, page=1, offset=0, or an alias sent alongside a valid cursor. The response's page.nextCursor is camelCase; the request parameter is cursor. The free website endpoint /api/events has a different page-number contract.
Set your filters once per traversal. If you provide explicit starts_after/starts_before, calculate them once and reuse the exact values on every page. You can also omit both dates; the returned cursor pins the effective window. Reset the cursor only when deliberately starting a new query or refresh. hasMore: false ends this window, not all future events.
Refreshes can return IDs you already saved; this endpoint is not a changes-since feed. Upsert by event ID when storing results. Every returned row, including a repeated event, counts again toward any applicable paid row allowance or the trial's total allowance. Results can change between requests; a cursor is not a frozen database export.
The complete examples below fetch successive pages at 25 rows per request until the window ends or an HTTP error stops the loop. Trial expiry or exhaustion also stops it. Honor Retry-After when present; after resolving a retryable error, retry the failed request with the same query and cursor. Do not fall back to page one. The sample pacing is for a non-geo query; geographic queries cost more quota units (see rate limits).
An event stays in the window until it ends. One already in progress when the window opens, such as a festival on its second day, comes back on the first pages with a startDate earlier than starts_after; its end is endDate, or startDate when endDate is null.
Every token the API mints now also pins the effective window and a hash of your filters: it pages within that window and never extends it, and continuing it with a changed filter or date is 400 invalid_cursor rather than a quietly different result set (limit may change). A token from an earlier version pins nothing. Those older date/id tokens are still accepted, so deployed clients keep working, but they carry only the position: the window is recomputed on every request from starts_after or, without it, from now — so it slides forward with the clock as you page — and the filters are not compared at all, so a changed filter silently changes the result set instead of erroring. If you persist cursors across restarts or deploys, discard the old ones and page again from the start to get the pinned behavior. To go further out than the window, start a new request with starts_after at the previous window's end, within your account's lookahead horizon.
import time
import httpx # pip install httpx
# Keep this query fixed for the whole traversal. If you supply explicit
# starts_after/starts_before, calculate them once here, not inside the loop.
query = {"city": "Denver", "state": "CO", "limit": 25}
headers = {"Authorization": "Bearer vn_live_..."} # Replace with your key.
cursor = None
with httpx.Client(headers=headers, timeout=30) as client:
while True:
params = dict(query)
if cursor:
params["cursor"] = cursor
r = client.get("https://venunite.com/v1/events", params=params)
r.raise_for_status() # Stop on errors; do not restart at page one.
result = r.json()
for event in result["data"]:
print(event["id"], event["title"]) # Or upsert by event ID.
page = result["page"]
if not page["hasMore"]:
break # Even an empty/short data array can have another page.
next_cursor = page["nextCursor"]
if not next_cursor or next_cursor == cursor:
raise RuntimeError("Pagination did not advance; stopping.")
cursor = next_cursor
time.sleep(1) # Pace this non-geo query; geo queries cost more.// Node 18+: save as paginate.mjs and run with node. Keep this query fixed.
// If you supply
// explicit starts_after/starts_before, calculate them once here, not in the loop.
const query = new URLSearchParams({ city: "Denver", state: "CO", limit: "25" });
const headers = { Authorization: "Bearer vn_live_..." }; // Replace with your key.
let cursor = null;
while (true) {
const qs = new URLSearchParams(query);
if (cursor) qs.set("cursor", cursor);
const r = await fetch(`https://venunite.com/v1/events?${qs}`, { headers });
if (!r.ok) throw new Error(await r.text()); // Stop; do not restart at page one.
const { data, page } = await r.json();
for (const event of data) {
console.log(event.id, event.title); // Or upsert by event ID.
}
if (!page.hasMore) break; // Even an empty/short data array can have another page.
if (!page.nextCursor || page.nextCursor === cursor) {
throw new Error("Pagination did not advance; stopping.");
}
cursor = page.nextCursor;
await new Promise(resolve => setTimeout(resolve, 1000)); // Non-geo pacing.
}Errors are { error: { code, message, requestId } }. Branch on code; message is prose and may change.
HTTP/1.1 400 Bad Request
x-request-id: 3f9c2a7e-...
X-RateLimit-Limit: 60
{
"error": {
"code": "conflicting_geo_filters",
"message": "Pass either lat/lng/radius_miles or polygon, not both.",
"requestId": "3f9c2a7e-..."
}
}Two codes are 503: rate_limit_unavailable when the shared limiter cannot prove admission, and policy_unavailable when your account policy could not be read. Both carry Retry-After, neither reserves nor delivers an event row, and retrying is normally right. One caveat on policy_unavailable: it also covers a policy row that fails validation, which no amount of retrying clears — if it repeats, send us the requestId. A permanent account state is deliberately not one of these. No policy row for the key, a policy row older than this route's contract, an unpaid plan, and a key whose owner is not the account the policy resolved to all answer 403 account_not_provisioned instead, which retrying never clears — check the plan at /developers/keys or send us the requestId. Those four used to share the 503 and its “retry shortly”, which a client that honors Retry-After retries against forever.
| code | status | when |
|---|---|---|
invalid_api_key | 401 | Missing, malformed, unknown, or revoked key. |
subscription_required | 402 | The key is valid but its owner has no active developer plan or eligible REST trial. A previous subscription is not extended by a new trial. Manage access at /developers/keys. |
trial_expired | 402 | The one-time seven-day REST trial has ended. Subscribe to continue; new keys cannot restart it. Includes X-Trial-* headers. |
trial_exhausted | 402 | The account's total 1,000 trial rows have been used. Repeated results count again. Subscribe to continue; there is no daily reset. Includes X-Trial-* headers. |
email_verification_required | 403 | Verify your email before starting the REST trial. |
trial_capacity_reserved | 429 | Concurrent requests temporarily hold the remaining trial rows. Includes Retry-After: 5 and X-Trial-* headers; this is not permanent exhaustion. |
trial_unavailable | 503 | The trial allowance cannot be verified or this request's reservation expired. No unmetered data is returned. Includes Retry-After: 5. |
feature_not_in_plan | 403 | The account's feature tier does not include custom polygons. All keys on an account share feature access; changing keys cannot unlock a feature. |
lookahead_not_in_plan | 403 | An explicit date boundary or cursor exceeds the account's lookahead from now. Shortening the window or paging cannot bypass the tier horizon. |
account_not_provisioned | 403 | The key authenticates but its account carries no usable Data API policy: no policy row, a policy row older than this route's contract, a non-paid plan, or a policy row belonging to a different user. A PERMANENT account state, not an outage — these four used to share policy_unavailable's 503 and its Retry-After, which a client that honors Retry-After retries against forever. Check the plan at /developers/keys or quote the requestId to support. |
rate_limited | 429 | Over the budget for this key (60 requests/minute unless the account has a negotiated limit; a geo query counts as 5). Includes Retry-After. |
row_budget_exhausted | 429 | The delivered-EVENT-ROW budget for this account, over the rolling 24-hour window, is spent. Distinct from rate_limited on purpose: that meters requests (a limit=1 call and a limit=100 call cost the same), this meters rows, and one budget is drawn down by the REST API and the MCP server together. Only plans that carry a row budget can return this; a plan without one never does. Includes Retry-After and the X-RowBudget-* headers. |
rate_limit_unavailable | 503 | The shared rate-limit store could not confirm admission — unreachable, timed out, or unconfigured. NOT a quota verdict: it does not mean you are over your limit, and no event rows were delivered. During a dual-store migration one counter may already have charged the request, so this does not promise the attempt was free. Deliberately not reported as rate_limited, which would send a customer who made one request to debug a budget they have not touched. Includes Retry-After; safe to retry. |
unsupported_pagination | 400 | After authentication, access and request admission, page, offset, next_cursor or nextCursor is present, regardless of value or whether cursor is also supplied. Pass the previous response's page.nextCursor as cursor with the same filters and dates. No event rows are delivered and the trial does not start. Request-rate units still apply, as for other malformed requests. |
invalid_limit | 400 | limit is not an integer in 1..100. |
invalid_date | 400 | starts_after / starts_before is not an ISO 8601 date. |
invalid_date_range | 400 | starts_before is not after starts_after. |
invalid_cursor | 400 | cursor is invalid, or a cursor issued by this version was replayed with different filters or date parameters. Keep those parameters when paging; limit may change. Tokens minted before the window pin carry no version marker: they are still accepted and are NOT filter-checked, so changing a filter with one of those silently changes the result set instead. |
policy_unavailable | 503 | The shared account policy could not be READ. Retry after the Retry-After delay; no event rows were delivered. Not always transient: a policy row that fails validation raises this on every attempt, so if it persists past a few minutes, quote the requestId to support instead of retrying. A permanently unprovisioned account is account_not_provisioned (403), not this. |
invalid_state | 400 | state is present and is not a valid 2-letter US state code. An empty or whitespace-only `state` (like an empty `city`) is dropped rather than rejected, and the request runs with no such filter. |
invalid_source | 400 | source is not SHAPED like a source token — longer than 64 characters, not beginning with a letter or digit, or containing characters outside `a-z 0-9 . _ : -`. Shape is all that is checked: a well-formed token that names nothing is NOT an error — it filters to nothing and returns an empty 200. An EMPTY value (`?source=`, `?source=,`, whitespace) is not an error either: it is dropped before validation and the request runs with NO source filter, returning the unfiltered feed. |
invalid_exclude_aggregators | 400 | exclude_aggregators is not true or false. |
invalid_exclude_sold_out | 400 | exclude_sold_out is not true or false. |
invalid_coordinates | 400 | lat/lng are not both present as finite numbers in range (-90..90, -180..180), or radius_miles was passed without them. |
invalid_radius | 400 | radius_miles is missing alongside lat/lng, or is not a number in (0, 25]. |
invalid_polygon | 400 | polygon is not 3-32 `lat,lng` pairs separated by `;`, or its bounding box is wider than 50 miles across. |
conflicting_geo_filters | 400 | Both lat/lng/radius_miles and polygon were passed. Use one geo filter per request. |
invalid_include_centroids | 400 | include_centroids is not true or false. |
internal_error | 500 | Unexpected server error. Safe to retry. |
Two ceilings, and they are not the same ceiling. The per-minute limit below governs pace. Separately, your plan carries a volume allowance: the number of event rows we will deliver to your account over a rolling 24 hours, counted across every key you hold and across both REST and MCP. Basic is 100,000 rows and Business is 500,000. Rows are counted as delivered, so a page that returns fewer events than you asked for costs only what it returned, and an empty page costs nothing. The window is rolling, not a calendar day — the allowance recovers continuously rather than resetting at midnight UTC. Exhausting it answers 429 row_budget_exhausted; you can see it coming in the X-RowBudget-* headers described below. Legacy and Enterprise accounts carry no plan row allowance; an Enterprise allowance is agreed per account.
60 requests per minute per key, enforced server-side; a geo query (lat/lng/radius_miles or polygon) counts as 5. That cost is decided from the raw parameters before they are validated, so a malformed geo request — an out-of-range radius_miles, an unparsable ring — spends the same 5 units as a valid one and still answers 400 (or 403 feature_not_in_plan, if your plan carries no polygon access: that gate also runs before the ring is parsed). Both halves of that are measured, not nominal: on 2026-09-06 six geo requests against production moved X-RateLimit-Remaining from 55 to 25, and the malformed ones among them were charged the same 5 while answering 400. Accounts with a negotiated limit are metered as a whole (every key on the account shares one budget) with a sustained rate and a burst allowance. Every response carries X-RateLimit-Limit; on any response reached after your account rate is resolved — including both 429s and the 503s — it reports the rate that applies to you, while the 401, 402 and 500 paths report the published 60: the first two because no account rate has been resolved yet, the 500 because it is raised from the catch-all handler, which cannot see the resolved rate even when one was. A 200 and a 429 rate_limited also carry X-RateLimit-Remaining and X-RateLimit-Reset (unix seconds) when the limiter store can report them — treat absence as unknown, not zero. No other response does. Every 400 and the 401 carry X-RateLimit-Limit but neither counter (measured 2026-09-06 against production across the 401 and twelve of the thirteen 400 codes — every one except invalid_source); the 401 additionally carries WWW-Authenticate: Bearer, and every response carries x-request-id. So a rejected request tells you nothing about what it just spent — and a malformed geo request spends 5. Pace off your last 200. The other 429, row_budget_exhausted, carries neither: it is a verdict about rows, not about request rate, and reports X-RowBudget-Limit / X-RowBudget-Remaining instead — the same pair a 200 carries whenever your account is row-metered, which is how you pace against the row budget before you hit it. Both 429s carry Retry-After in delta-seconds. Pace against the headers rather than discovering the ceiling by hitting it. For a negotiated account budget, creating more keys does not increase the allowance. Feature access and lookahead are account-wide on every tier. Need more? Write to hello@venunite.com with what for.
POST /v1/ticket-handoffs accepts a site-issued free Builder or paid vn_live_ bearer key. Send only the positive integer event ID from a VenuNite result; the authenticated key supplies attribution and VenuNite selects a supported stored ticket seller. Destination URLs and partner overrides are not accepted.
curl -X POST 'https://venunite.com/v1/ticket-handoffs' \
-H "Authorization: Bearer $VENUNITE_API_KEY" \
-H 'Content-Type: application/json' \
--data '{"event_id":654662}'A successful request returns 201 with the fields below. Present handoff_url to the user. It expires after 15 minutes. Opening or previewing the page creates no affiliate click; choosing Continue redeems the handoff at most once and opens external checkout. Repeated Continue returns 409 without another seller redirect.
| field | type | meaning |
|---|---|---|
handoff_id | string | Opaque reference for the attributed handoff. This is not an order or reservation ID. |
event_id | integer · min 1 · max 2147483647 | |
event_url | string · uri | Canonical VenuNite event page. |
handoff_url | string · uri | Short-lived VenuNite confirmation page. Opening or previewing it does not record an affiliate click. The user's Continue action redeems the handoff at most once and opens the supported seller. Repeated Continue returns 409 without another seller redirect. |
expires_at | string · date-time | |
booking_mode | string | external_checkout |
requires_user_action | boolean | true |
Handoff creation has a separate limit of 10 requests per minute per account, shared across keys and REST/MCP. A missing supported seller returns 409 ticket_link_unavailable with the canonical event_url as a fallback. Creating a handoff never buys or holds tickets, checks live inventory, guarantees final prices, or reports a confirmed purchase. Use eventUrl from the event feed for general citations and sharing. The same action is available as create_ticket_handoff over MCP.
Handoff errors use {error: {code, message}, request_id, event_url?}. The fallback URL is present for ticket_link_unavailable. The request ID also appears in X-Request-Id. Creation is counted after authentication and before input validation, so an invalid authenticated request spends an allowance slot.
| code | status | when |
|---|---|---|
invalid_request | 400 | Content-Type is not application/json, JSON is malformed, event_id is not an integer from 1 through 2147483647, or extra fields were supplied. |
invalid_api_key | 401 | A valid site-issued vn_live_ bearer key is required. Free Builder and paid keys work; older legacy customer keys do not. |
event_unavailable | 404 | The event is not currently eligible for discovery, was removed, or was cancelled. |
ticket_link_unavailable | 409 | No supported ticket seller is available, the event is sold out, or ticket sales have ended. event_url provides the canonical event page fallback. |
invalid_request | 413 | The request body exceeds 1024 bytes. |
rate_limit_exceeded | 429 | The account exceeded 10 creation requests in a minute, shared across keys and REST/MCP. Includes Retry-After: 60. |
rate_limit_unavailable | 503 | The shared admission store could not confirm the request. Includes Retry-After: 60. |
handoff_unavailable | 503 | Authentication, signing configuration, or handoff storage is temporarily unavailable. Includes Retry-After: 60. |
The machine-readable source of this page: GET /v1/openapi.json — OpenAPI 3.1, public, no key needed. Feed it to a client generator for a typed SDK in your language.