I run a visa agency in Bali. The other half of my week goes into Apify Actors, and one of them reads Eventbrite search pages: you hand it a search URL or a city, it pages through the results and returns one row per event — name, dates with the time zone, venue with coordinates, category, tags, image. Switch on the detail pass and it also opens each event page for the ticket price range, the organizer and the description.
That detail pass costs the buyer a second charge per event. I spent today measuring what it actually buys, on two search pages: Bali, Indonesia and Berlin, Germany, a hundred-event cap each.
Bali returned 69 events across 4 pages in 28.0 seconds. Berlin returned 100 in 22.2 seconds. Then I diffed two fields that should never be the same — summary, which comes free from the search listing, and description, which the buyer pays for.
They were identical on 168 of 169 rows. The 169th had no description at all.
The meta tag is not the description
Eventbrite event pages carry JSON-LD, the schema.org/Event block search engines read. It has offers with lowPrice and highPrice, an organizer, start and end timestamps with the UTC offset — all genuinely useful, none of it on the search page. It also has a description. I read that field, shipped it, and called it "full description" in my own documentation.
It is the meta description. Capped at 140 characters, built from the first sentences of the listing — which is exactly what the search page already hands over as summary, for free.
Across those 69 Bali events, description averaged 108 characters. Three rows sat exactly at 140, the cap. The field was not wrong, it was just a duplicate the buyer was paying me to deliver twice.
The text the organizer actually wrote is in the same HTML I had already downloaded, a few kilobytes further down, inside the Next.js payload:
const m = html.match(/<script id="__NEXT_DATA__" type="application\/json">([\s\S]*?)<\/script>/); const ctx = JSON.parse(m[1]).props.pageProps.context; const mods = ctx.structuredContent.modules; // [{type:'text', text:'<p>…'}, {type:'image', url:…}] structuredContent.modules is the event body as the organizer built it — text blocks, images, the occasional video embed. Joining the text blocks and stripping tags gives the real thing. I refetched all 69 event pages and measured both sources on the same bytes:
JSON-LD description | structuredContent text | |
|---|---|---|
| Pages that had it | 69 / 69 | 69 / 69 |
| Average length | 108 chars | 1,841 chars |
| Longest | 140 | 12,764 |
| Rows where it was longer | — | 67 of 69 |
| Rows with nothing at all | 0 | 2 |
Seventeen times more text, median ratio 7.8x, from a request I was already paying for. The two empty ones are honest: those organizers wrote no body copy, so the Actor falls back to the meta description rather than returning null.
The control run after the fix, same search, 20 events: description identical to summary on 0 of 20 rows, median length 647, longest 11,838. Prices and currency still filled on 20 of 20 — I did not trade one field for another.
While I was in there, the same payload gave up eleven more fields that cost nothing extra, because they ride on the page already being fetched: durationMinutes (filled on 69 of 69), salesStatus, refundPolicy, ageRestriction (9 of 69), doorTime, parking, organizerEvents, organizerFollowers, organizerVerified, descriptionBlocks, and nextSessionAt.
currency is the organizer's currency, not the country's
Now the finding I cannot fix, only document.
All 69 Bali rows carry country: "ID". Their prices came back in eight currencies:
USD 44 AUD 10 GBP 8 EUR 3 SGD 1 MYR 1 NZD 1 HKD 1 Zero in rupiah. A dive trip in North Bali is priced 5299.5 HKD. A free Bitcoin meetup in Canggu is priced 0 GBP. A bracelet workshop in Bali is served from eventbrite.ch at 7.49 EUR. Those 69 events arrived across eight different Eventbrite domains — .com, .com.au, .co.uk, .sg, .ch, .fr, .ie, .hk.
This is not a parsing artifact. I checked both independent sources on the page — the JSON-LD priceCurrency and basicInfo.currency in the Next.js payload — and they agree on 69 of 69 rows. Eventbrite publishes the price in the currency of the organizer's account. A yoga teacher who registered in Sydney sells a Canggu sound bath in Australian dollars, and the site never converts.
Berlin is why nobody notices: 95 of its 100 rows are EUR, because Berlin events are mostly run by Berlin organizers. Bali is a destination — its events are sold by Australians, Brits, Singaporeans — so the same behaviour becomes obvious. If your search covers a tourist region, sorting those rows by priceMin sorts by nothing at all, and averaging them produces a number with no unit.
Two more things to distrust in the price fields. priceMin equals priceMax on 49 of 69 rows, which usually means one ticket type, not a one-price event. And priceMax is whatever the most expensive offer on the page is: the Bali Venture Capital World Summit reports 168.94 to 57662.15 EUR, a 341x spread, because the top of that range is a sponsorship package nobody attends as a guest.
A series row is one date out of hundreds
Eventbrite recurring events are a single search result. My rows flag them — isSeries, seriesId, occurrencesInSeries — and on the Bali page 36 of 69 rows were series, covering 1,568 dates between them. The biggest single row: 421 occurrences. A sound bath studio: 283.
So "69 events in Bali" and "69 things happening in Bali" are very different counts, and a weekly calendar built from the row's one date will keep rediscovering the same workshop. The new nextSessionAt confirms what that one date means — on all 8 series rows in my control run it matched startsAt exactly, i.e. it is the next session on sale. The other 282 are not in the row, and the search page does not have them.
Berlin again shows the contrast: 7 series rows out of 100, 14 occurrences total.
Two smaller traps from the same two runs. organizerName is null on 11 of 69 Bali rows — null in both page sources, because those organizers never set a public name; you get organizerUrl with a numeric id and nothing else. And one organizer, Eurasia Research, owns 9 of the 69 rows with near-identical academic conferences, so an unfiltered "events in Bali" list is 13% one seller. Date range of that list: 6 November 2026 to 29 October 2030. Only 40 of 69 fall in the current month.
Calling it
{ "startUrls": ["https://www.eventbrite.com/d/indonesia--bali/all-events/"], "maxEvents": 100, "includeDetails": true } A real row from today's run, trimmed:
{ "type": "event", "eventId": "1991054633005", "name": "Chakras sound sanctuary - SOUND HEALING sessions | Sound bath Bali", "startsAt": "2026-11-06T14:00:00", "timezone": "Asia/Makassar", "isSeries": true, "occurrencesInSeries": 283, "nextSessionAt": "2026-11-06T14:00:00+08", "venueName": "ADDA YOGA CANGGU - Meditation | Breath | Sound", "city": "Kec. Kuta Utara", "country": "ID", "latitude": -8.643315, "longitude": 115.1427276, "category": "Religion & Spirituality", "priceMin": 29.89, "priceMax": 29.89, "currency": "AUD", "isFree": false, "organizerName": "ADDA YOGA", "organizerEvents": 700, "organizerFollowers": 19, "durationMinutes": 90, "refundPolicy": "custom", "salesStatus": "sales_ended", "descriptionBlocks": 11, "urgency": ["salesEndSoon"] } Note salesStatus: "sales_ended" on an event the search page listed as upcoming — 3 of 69 Bali rows were sold out or closed. That field did not exist this morning.
From Python, run and read in one call:
import requests rows = requests.post( "https://api.apify.com/v2/acts/lergassy~eventbrite-scraper/run-sync-get-dataset-items", params={"token": "apify_api_..."}, json={"location": "Berlin, Germany", "dates": "this_weekend", "maxEvents": 200}, timeout=600, ).json() events = [r for r in rows if r.get("type") == "event"] print(len(events), "events;", len({r["currency"] for r in events if r.get("currency")}), "currencies") The Actor is Eventbrite Scraper. It bills per event row, with a second charge only if you ask for the detail pass; error rows are free and there is no start fee. The detail pass is also what the time goes on: the same Bali search finished in 8.2 seconds with details off and 28.0 seconds with them on, because that is 69 extra page loads.
When it is the wrong tool. There is no search across all of Eventbrite from the API side — you bring a search URL or a city, and Eventbrite's own search decides what exists. Past events are gone; search pages show upcoming only, so a history has to be accumulated by running on a schedule. Attendee lists, ticket inventory and sales numbers are not public and never will be. And one operational detail worth knowing: Eventbrite's WAF answers a flagged search request with HTTP 405, not 403 or 429 — from my own server the search page returns 405 while individual event pages return 200 all day. The Actor climbs a proxy ladder when that happens; if you are writing your own, treat a 405 on a GET as a bot check rather than a method error, which is what I spent an afternoon assuming.

