Lu.ma doesn't have a public events API. It doesn't need one — it ships the entire event object, address privacy flag included, inside a <script> tag on every page it renders.
Quick answer
Every Lu.ma page — a city feed, a category feed, or a single event — embeds its full server-rendered state in a <script id="__NEXT_DATA__"> tag as JSON. The event data lives at props.pageProps.initialData.data, and the same shape answers both "is this a list page" and "is this a single event page": if data.events is a list, it's a feed; if it isn't, data itself is one event wrapper. One extraction function, no separate URL-pattern branching. The other thing that shape teaches you fast: when an event's geo_address_info.mode is "obfuscated", the address field is null on purpose — that's Lu.ma respecting a host's privacy setting, not missing data.
How do you scrape a Next.js page's embedded JSON?
Three steps, no headless browser:
_NEXT_DATA_RE = re.compile(r'<script id="__NEXT_DATA__"[^>]*>(.*?)</script>', re.DOTALL)
def _extract_data(html: str) -> dict[str, Any] | None:
"""Parse ``__NEXT_DATA__`` and return ``props.pageProps.initialData.data``."""
match = _NEXT_DATA_RE.search(html)
if not match:
return None
payload = json.loads(match.group(1))
data = payload.get("props", {}).get("pageProps", {}).get("initialData", {}).get("data")
return data if isinstance(data, dict) else None
We fetch that HTML with curl-cffi, rotating a real browser TLS fingerprint per request out of a pool (chrome131, chrome124, firefox147, safari180) and backing off with retries on 403/408/429/5xx — Lu.ma sits behind Cloudflare, and a naive requests.get() doesn't reliably get past it. Once the HTML lands, the parsing above is pure and fully unit-tested against captured fixtures — no live network needed to verify a field mapping.
Why does the same function handle city feeds and single events?
Because Lu.ma's own frontend does the same detection:
events = data.get("events")
if isinstance(events, list):
for row in _emit_list(events, url, cfg):
yield row
else:
# Single event-detail page: the data object IS one event wrapper.
row = _build_row(data, source_url=url, include_guests=cfg.include_featured_guests)
Feed it "sf" (a city), "ai" (a category), or a single event URL, and the Actor auto-detects which shape it got back — you don't set a "mode" input at all.
Why is address sometimes null even for a real, upcoming event?
This one is a privacy feature, not a scraping failure:
def _address(geo: dict[str, Any]) -> str | None:
"""Return the street address, or null when obfuscated / missing."""
if geo.get("mode") == _OBFUSCATED_MODE:
return None
return geo.get("address") or None
Hosts running invite-adjacent or guests-only events can set Lu.ma to hide the exact address until registration. city and region still come through — only the street-level address field goes null. A client that doesn't check mode first will report "address missing" as a bug when it's actually the host's own setting working correctly.
The same care shows up in host_linkedin: Lu.ma only ever fills this for handles that start with /in/ (an actual profile path) — anything else resolves to null rather than building a broken URL from a partial or non-LinkedIn handle.
Is scraping public Lu.ma event pages legal?
Every field this Actor emits — event name, date, city, ticket price, public host name, public guest count — is visible on the public page without logging in. There's no attendee list scraping, no auth-gated data, and obfuscated addresses stay obfuscated in the output, which keeps the dataset aligned with what the host chose to publish.
FAQ
Does this need a Lu.ma account or API key?
No — it reads the same public HTML any visitor's browser loads.
What's in a row I don't get from just browsing the page?
The event's internal api_id, exact ticket price in cents, sold-out/approval-required flags, guest and ticket counts, and the primary host's public LinkedIn URL when Lu.ma exposes one — all flattened into one row instead of scattered across the rendered page.
Can I pull featured guests too?
Yes, opt in with includeFeaturedGuests — off by default to keep the row lean, since most buyers only need the event + host.
What does it cost?
$0.20 to start a run, then $0.004 per event written — about $4.00 per 1,000 events. The closest Store incumbent charges a flat $29/mo subscription regardless of volume.
Try it: Lu.ma Event Discovery — feed it a city, a category, or a single event URL and get back structured rows with dates, venues, hosts, and ticket data.
We do the dirty work so your dataset stays clean. 😈
Top comments (0)