Meetup Search Events
Purpose
Search Meetup for upcoming events matching a topic, location, group, or topic-category and return them as structured JSON — event id, title, group, description, format (in-person/online), venue + lat/lon (or online platform), start/end time, going/waitlist counts, capacity, price + is-free flag, photo URL, organizer/hosts, topic tags, and the canonical event URL. Same shape for both ad-hoc text searches and group-scoped event listings. Read-only: never RSVP, join, save, or sign in.
When to Use
- "AI events in San Francisco this week", "book club Brooklyn", "climbing meetups near 94110".
- Pulling a single Meetup group's upcoming events from its slug or URL.
- Aggregating events by topic category (Tech, Social Activities, Sports & Fitness, etc.) in a region.
- Watch-lists / digests for a fixed query + location pair.
- Anywhere a public Meetup browse URL would work — including online-only events.
Workflow
The Meetup find page is a Next.js SSR app whose page HTML embeds the entire first-page GraphQL response (operation: eventSearchWithSeries, server-resolved as eventSearch) inside window.__NEXT_DATA__.props.pageProps.__APOLLO_STATE__. The same response is also mirrored as schema.org <script type="application/ld+json"> blocks (one per result). Fetch the HTML, parse the embedded state, and resolve the Apollo refs — no live browser session, no auth, no GraphQL POST required for page-1 reads. Pagination beyond page 1 does require a GraphQL POST (no URL surface for it); that is the only situation where a Browserbase remote session is needed.
Recommended path — SSR hydration via Browserbase Fetch
-
Build the find URL. Required:
source=EVENTS(without it the page falls into a different code path —recommendedEvents/ ML-popular — that won't honor your filters). Appendkeywords=...for text search,location=...for the geo lock, and any optional filter params from the table below.https://www.meetup.com/find/ ?keywords=<urlenc text> &location=<location-slug> &source=EVENTS &eventType=online|inPerson // optional &distance=fiveMiles|tenMiles|twentyFiveMiles|fiftyMiles|hundredMiles &dateRange=today // ONLY `today` is honored server-side &categoryId=<numeric> // see enum below; maps to topicCategoryId &sortField=RELEVANCE|DATETIME|DISTANCE // UPPERCASE requiredLocation slug grammar (critical, two distinct forms):
- US/Canada:
us--<state-lowercase>--<city-with-dashes>— e.g.us--ny--new-york,us--ca--san-francisco. Dashes between city words, never underscores. Underscore form silently falls back to the request IP's location. - International: REVERSED order:
<city>--<country-code>— e.g.berlin--de,london--gb,paris--fr. The US<country>--<region>--<city>form does not work for non-US cities.
- US/Canada:
-
Fetch with proxies on.
bb fetch "<find-url>" --proxies --allow-redirects --output page.htmlMeetup has Cloudflare in front. Across 30+ probes during skill construction,
--proxiesreturned 200 OK on every find/topic/group URL — no 403s, no captchas, no rate-limit.--proxiesis the safer default. -
Parse
__NEXT_DATA__.import re, json nd = json.loads(re.search( r'<script id="__NEXT_DATA__"[^>]*>(.*?)</script>', html, re.DOTALL).group(1)) pp = nd['props']['pageProps'] apollo = pp['__APOLLO_STATE__'] -
VERIFY the location lock before trusting results. Meetup silently re-resolves the location to the request IP when the
location=slug is malformed. Always confirm before emitting events:ul = pp['userLocation'] # what Meetup actually used # Compare ul.city / ul.state / ul.lat / ul.lon against the user's intent. # If they don't match, the slug was wrong — re-issue with corrected slug, # or emit { success: false, reason: "location_not_resolved", ... }. -
Read the eventSearch result. Locate the single ROOT_QUERY entry whose key starts with
eventSearch:(the rest of the key is a JSON-stringified{filter, sort}block — useful for debugging but not needed for extraction):rq = apollo['ROOT_QUERY'] es_key = next(k for k in rq if k.startswith('eventSearch:')) conn = rq[es_key] total = conn['totalCount'] # region-wide totals has_next = conn['pageInfo']['hasNextPage'] end_cursor = conn['pageInfo']['endCursor'] # base64 offset, e.g. "MTI=" = 12 edges = conn['edges'] # list of {node: {__ref}, metadata} -
Resolve each event by ref. The edges hold
Event:<id>pointers; the full entity is in the flat Apollo store at the same key:for edge in edges: ev = apollo[edge['node']['__ref']] # Event:<id> entity grp = apollo[ev['group']['__ref']] # Group:<id> venue = apollo[ev['venue']['__ref']] if ev.get('venue') else None photo = apollo[ev['featuredEventPhoto']['__ref']] if ev.get('featuredEventPhoto') else None topics = [apollo[t['node']['__ref']] for t in (ev.get('topics') or {}).get('edges', [])] hosts = [apollo[h['__ref']] for h in (ev.get('eventHosts') or [])] # ... assemble flat output (see Expected Output section)Note: on the find-page hydration, the Event entity has
dateTime(start) but noendTime, the Group hasstats.eventRatingsbut no member count, and theeventHostsarray is empty. To get those fields, follow with step 7. -
(Optional, for richer fields) For each event you want hydrated with end time + lat/lon + capacity + topics + hosts + group member count, fetch the canonical event URL:
bb fetch "<ev.eventUrl>" --proxies --output event.htmlThe detail page's
__APOLLO_STATE__has the full Event schema:endTime,topics{edges[]},eventHosts[],rsvps({"filter":{"rsvpStatus":["YES"]}}).totalCount(going count),rsvps({"filter":{"rsvpStatus":["WAITLIST"]}}).totalCount,maxTickets(capacity;0= unlimited),feeSettings(price when paid),venue{lat, lon}, andGroup{stats.memberCounts.all, city, state, country, link, activeTopics, topicCategory}. The same page also carries a clean JSON-LDEventblock withstartDate,endDate,image[],location.address,organizer— often the easiest path when you only need the basics. -
Group-scoped listings. When the input is a group slug (e.g.
/photography-sf/) or URL, fetchhttps://www.meetup.com/<slug>/events/— that page hydrates up to 30 upcoming events as aGroup.events({"filter":{"afterDateTime":"<now>","status":["ACTIVE","PAST","CANCELLED"]},"first":30,"sort":"ASC"})connection on theGroup:<id>entity. Same Event ref-resolution loop; no extra fetch needed. -
Topic / category landing.
https://www.meetup.com/topics/<topic-urlkey>/andhttps://www.meetup.com/topics/<topic-urlkey>/<country>--<region>/both hydrate ~30 events scoped to the topic. Useful when the user's intent is "all AI events near me" with no other text — set<topic-urlkey>=artificial-intelligence. Location for/topics/defaults to request IP unless the country/region subpath is provided. -
Pagination beyond page 1. None of
page=,offset=,after=,cursor=,endCursor=are honored by the SSR — every call returns the same first 12–30 events. To paginate, you must POST theeventSearchWithSeriesoperation to Meetup's GraphQL endpoint with{ after: <endCursor>, first: 30 }(see Site-Specific Gotchas for the endpoint situation), or narrow the query/location until the first page covers your need.
Browser fallback — Browserbase remote session
When (a) you need to paginate beyond page 1 and want to use the in-page Apollo client (which holds the GraphQL endpoint URL + persisted-query hash + auth cookies), or (b) the Fetch API is blocked/rate-limited:
SID=$(bb sessions create --keep-alive --advanced-stealth --proxies | jq -r .id)
browse --connect "$SID" open "https://www.meetup.com/find/?keywords=AI&location=us--ny--new-york&source=EVENTS"
browse --connect "$SID" wait load
browse --connect "$SID" wait timeout 2000
# Pull hydrated state directly
browse --connect "$SID" eval "JSON.stringify(window.__NEXT_DATA__.props.pageProps.__APOLLO_STATE__)" > apollo.json
# To paginate: click the "Show more" / "Load more" button at the bottom and re-eval
browse --connect "$SID" snapshot
# ... find @ref of the load-more button, click it, wait, re-eval __APOLLO_STATE__
bb sessions update "$SID" --status REQUEST_RELEASE
Same Apollo extraction logic as the Fetch path — the in-page state is the same shape, just kept fresh by the Apollo client as the user scrolls.
Site-Specific Gotchas
- Location slug uses dashes, not underscores.
us--ca--san_franciscosilently falls back to the request IP's location (verified: returned "La Canada Flintridge, CA" instead of SF in our sandbox).us--ca--san-franciscoworks. Always verify by reading backuserLocationfrompagePropsafter the fetch — if it doesn't match the user's intent, the slug was wrong; re-issue or fail loud. - International locations use reversed slug grammar.
de--be--berlin(US-style) fails and falls back to IP.berlin--de(city--country) works and resolves to Berlin (52.52, 13.38). Other examples:london--gb,paris--fr,tokyo--jp. The state/region middle segment doesn't exist for non-US cities. source=EVENTSis mandatory. Without it the find page rendersrecommendedEvents(ML-popular events near you) — a completely different ROOT_QUERY that ignoreskeywords=,eventType=,distance=, etc. Always includesource=EVENTS.categoryId=URL param maps totopicCategoryIdin the GraphQL filter. Don't passtopicCategoryId=on the URL — it's silently ignored. UsecategoryId=<numeric>. Verified enum (extracted from_next/static/chunks/73326...js):521=Art & Culture,593=Beliefs & Religion,405=Career & Business,604=Community & Environment,612=Dancing,436=Science & Education,535=Games,522=Health & Wellbeing,622=Identity & Language,395=Music,684=Travel & Outdoor,673=Parents & Family,701=Pets & Animals,652=Social Activities,482=Sports & Fitness,546=Technology,467=Writing.- "Synthetic" categories don't have a numeric ID. Movements & Politics, Photography, LGBTQ, Singles, Fashion & Beauty, Film, Fitness, Food & Drink, Hobbies & Passions, Book Clubs, Lifestyle, Women — these are surfaced by Meetup's category UI but resolve to keyword rewrites under the hood (e.g. Photography →
keywords=photography, LGBTQ →keywords=queer%20lgbtq). For these intents, usekeywords=...instead ofcategoryId=. eventTypeaccepts onlyonlineandinPerson.eventType=physical(the literal GraphQL enum value) is dropped silently. The URL paraminPersonis normalized toPHYSICALin the filter;onlinetoONLINE. To get both (the default), omit the param entirely.sortFieldis case-sensitive.sortField=DATETIMEworks;sortField=datetimesilently falls back toRELEVANCE. Same forRELEVANCE,DISTANCE.dateRange=is mostly UI-only. OnlydateRange=todayis honored at the SSR layer (setsfilter.startDateRangeto now andfilter.endDateRangeto end-of-day).this_week,this_weekend,next_week,tomorrowecho intofiltersbut don't reach the eventSearch filter — the find UI applies them client-side after hydration, so an SSR-only consumer sees the unfiltered page-1 results. Workaround: use the (broken)customStartDate/customEndDateif it ever gets fixed, or post-filter onEvent.dateTimeafter extraction.customStartDate=YYYY-MM-DD&customEndDate=...returns 500. The SSR resolver throws on this combination. Don't construct URLs that include these — the page won't render at all. Post-filter onEvent.dateTimeafter extraction.isHappeningNow,isStartingSoon,isFree, time-of-day buckets — all UI-only. These exist asBooleanvariables in theeventSearchWithSeriesoperation but the find-page SSR doesn't pass them through from the URL. Either (a) post-filter onEvent.dateTimeandEvent.feeSettings, or (b) use the Browser-fallback path and POST the GraphQL operation with these vars set.- No URL pagination.
page=2,after=<cursor>,offset=20,cursor=...,endCursor=...are all silently ignored.pageInfo.endCursoris a base64-encoded offset (e.g.MTI=decodes to "12"); it's only useful when POSTed to the GraphQL endpoint as theaftervariable. The endpoint POST URL ishttps://www.meetup.com/gql2(GETreturns 404 — endpoint is POST-only).bb fetchis GET-only, so to actually paginate you need a--connect-attached browser session. - Find-page Event entity is partial. It carries
id,title,dateTime,description,eventType,eventUrl,rsvpState,maxTickets,feeSettings,venue(name/address/city/state/country but no lat/lon), and refs togroup,featuredEventPhoto. It does not carryendTime,topics,eventHosts,rsvps.totalCount. Visit the canonical event URL (step 7 above) to get those. maxTickets: 0means unlimited, not zero. Map tocapacity: nullin your output, notcapacity: 0.- Two event ID formats. Numeric (
314579537) for standalone events; alphanumeric (rmcjwtyjckbtb,qltprtyjcjbwb) for individual instances of recurring event series. Both work in the canonical URL (/<group-slug>/events/<id>/). - Online platform is unstructured. When
eventType=ONLINE,venueis null and there is no structuredonline_platformfield — Meetup leaves the host to mention "Zoom / Google Meet / Hopin / Discord" in the free-text description. Best-effort: regex-scan the description for known platform names; treat asnullwhen none match. - Group page (
/<slug>/) ≠ group events page (/<slug>/events/). The bare slug hydrates 1–8 "featured/preview" events; the/events/subpath hydrates the full upcoming list (up to 30) underGroup.events({...filter})— different ROOT_QUERY path, richer event schema. Always use/<slug>/events/when listing a group's upcoming events. - Group-event page hydration is the richest available without a detail-page hop. Each event includes
endTime,going,feeSettings,eventHosts,actions,isOnline,socialLabels,topics— i.e. nearly everything an event-detail page has. Prefer this surface when the input is a group. featuredEventPhoto.highResUrlis the full-size photo. The same entity hasbaseUrl+idfor templated sizes (<baseUrl>/<id>/676x676.jpg,676x507.jpg,676x380.jpg).- Group
lat/lononly present on group-event pages. Group entity on find/search pages omits coordinates; group entity on/<slug>/events/includeslat,lon,organizer,welcomeBlurb,memberships. - Read-only enforcement. Never click any of:
Attend / RSVP,Join this group,Save,Sign In,Sign Up, or anyactions[]entries returned on the Event (those are RSVP-state mutations). All extraction is page-load only.
Expected Output
Three distinct shapes:
// 1. Events found
{
"success": true,
"query": { "keywords": "AI", "location": "us--ca--san-francisco", "eventType": null, "distance": null, "categoryId": null, "sortField": "DATETIME" },
"resolved_location": { "city": "San Francisco", "state": "CA", "country": "us", "lat": 37.78, "lon": -122.42, "zip": "94102", "timezone": "US/Pacific" },
"total_count": 30,
"page_info": { "has_next_page": true, "end_cursor": "MTI=" },
"events": [
{
"event_id": "314579537",
"title": "Vibe Code LA Meetup Event",
"event_url": "https://www.meetup.com/vibe-code-la/events/314579537/",
"description": "We're back, LA! ...",
"format": "in_person",
"start_at": "2026-05-27T18:00:00-07:00",
"end_at": "2026-05-27T20:00:00-07:00",
"duration_minutes": 120,
"rsvp_state": "JOIN_OPEN",
"going_count": 57,
"waitlist_count": 0,
"capacity": null,
"is_free": true,
"price": null,
"photo_url": "https://secure-content.meetupstatic.com/images/classic-events/534004625/highres_534004625.jpeg",
"topics": [
{ "id": "488822", "name": "Artificial Intelligence", "urlkey": "artificial-intelligence" }
],
"hosts": [
{ "member_id": "930091", "name": "Matt", "photo_url": "https://..." }
],
"venue": {
"id": "28001128", "name": "Total Wine",
"address": "11441 Jefferson Blvd",
"city": "Culver City", "state": "CA", "country": "us",
"lat": 33.99, "lon": -118.39706
},
"online_platform": null,
"is_featured": false,
"is_network_event": false,
"group": {
"id": "38287434", "name": "Vibe Code LA", "slug": "vibe-code-la",
"url": "https://www.meetup.com/vibe-code-la",
"city": "Santa Monica", "state": "CA", "country": "us",
"member_count": 342,
"photo_url": "https://...",
"category": { "id": "546", "name": "Technology", "urlkey": "technology" },
"active_topics": [ { "id": "488822", "name": "Artificial Intelligence" } ],
"rating": { "average": 4.83, "total": 18 }
}
}
]
}
// 2. Search executed, zero results
{
"success": true,
"query": { "keywords": "underwater-basket-weaving", "location": "us--mt--billings", "eventType": null, "distance": "tenMiles" },
"resolved_location": { "city": "Billings", "state": "MT", "country": "us", "lat": 45.78, "lon": -108.50 },
"total_count": 0,
"page_info": { "has_next_page": false, "end_cursor": null },
"events": []
}
// 3. Location slug malformed — fell back to request IP
{
"success": false,
"reason": "location_not_resolved",
"input_location": "us--ca--san_francisco",
"resolved_to": { "city": "La Canada Flintridge", "state": "CA", "country": "us", "lat": 34.21, "lon": -118.20 },
"hint": "Location slug uses dashes between words, not underscores. Try `us--ca--san-francisco`."
}
For online-only events (format: "online"): venue is null, online_platform is "zoom" | "google_meet" | "hopin" | "discord" | null (best-effort regex match against description). For paid events: is_free: false, price: { amount: <decimal>, currency: <ISO-4217>, accepts: ["CARD","PAYPAL",...] }.