meetup.com

search-events

Installation

Adds this website's skill for your agents

 

Summary

Search Meetup for upcoming events by topic, location, group slug, or topic category. Returns structured JSON per event — id, title, group, venue + lat/lon (or online platform), start/end time, going/waitlist counts, capacity, price, photo, organizer/hosts, topic tags, canonical URL. Honors keywords/location/eventType/distance/dateRange/categoryId/sortField URL filters. Read-only.

FIG. 01
FIG. 02
FIG. 03
SKILL.md
253 lines

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

  1. 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). Append keywords=... 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 required
    

    Location 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.
  2. Fetch with proxies on.

    bb fetch "<find-url>" --proxies --allow-redirects --output page.html
    

    Meetup has Cloudflare in front. Across 30+ probes during skill construction, --proxies returned 200 OK on every find/topic/group URL — no 403s, no captchas, no rate-limit. --proxies is the safer default.

  3. 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__']
    
  4. 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", ... }.
    
  5. 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}
    
  6. 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 no endTime, the Group has stats.eventRatings but no member count, and the eventHosts array is empty. To get those fields, follow with step 7.

  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.html
    

    The 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}, and Group{stats.memberCounts.all, city, state, country, link, activeTopics, topicCategory}. The same page also carries a clean JSON-LD Event block with startDate, endDate, image[], location.address, organizer — often the easiest path when you only need the basics.

  8. Group-scoped listings. When the input is a group slug (e.g. /photography-sf/) or URL, fetch https://www.meetup.com/<slug>/events/ — that page hydrates up to 30 upcoming events as a Group.events({"filter":{"afterDateTime":"<now>","status":["ACTIVE","PAST","CANCELLED"]},"first":30,"sort":"ASC"}) connection on the Group:<id> entity. Same Event ref-resolution loop; no extra fetch needed.

  9. Topic / category landing. https://www.meetup.com/topics/<topic-urlkey>/ and https://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.

  10. 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 the eventSearchWithSeries operation 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_francisco silently falls back to the request IP's location (verified: returned "La Canada Flintridge, CA" instead of SF in our sandbox). us--ca--san-francisco works. Always verify by reading back userLocation from pageProps after 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=EVENTS is mandatory. Without it the find page renders recommendedEvents (ML-popular events near you) — a completely different ROOT_QUERY that ignores keywords=, eventType=, distance=, etc. Always include source=EVENTS.
  • categoryId= URL param maps to topicCategoryId in the GraphQL filter. Don't pass topicCategoryId= on the URL — it's silently ignored. Use categoryId=<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, use keywords=... instead of categoryId=.
  • eventType accepts only online and inPerson. eventType=physical (the literal GraphQL enum value) is dropped silently. The URL param inPerson is normalized to PHYSICAL in the filter; online to ONLINE. To get both (the default), omit the param entirely.
  • sortField is case-sensitive. sortField=DATETIME works; sortField=datetime silently falls back to RELEVANCE. Same for RELEVANCE, DISTANCE.
  • dateRange= is mostly UI-only. Only dateRange=today is honored at the SSR layer (sets filter.startDateRange to now and filter.endDateRange to end-of-day). this_week, this_weekend, next_week, tomorrow echo into filters but 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/customEndDate if it ever gets fixed, or post-filter on Event.dateTime after 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 on Event.dateTime after extraction.
  • isHappeningNow, isStartingSoon, isFree, time-of-day buckets — all UI-only. These exist as Boolean variables in the eventSearchWithSeries operation but the find-page SSR doesn't pass them through from the URL. Either (a) post-filter on Event.dateTime and Event.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.endCursor is a base64-encoded offset (e.g. MTI= decodes to "12"); it's only useful when POSTed to the GraphQL endpoint as the after variable. The endpoint POST URL is https://www.meetup.com/gql2 (GET returns 404 — endpoint is POST-only). bb fetch is 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 to group, featuredEventPhoto. It does not carry endTime, topics, eventHosts, rsvps.totalCount. Visit the canonical event URL (step 7 above) to get those.
  • maxTickets: 0 means unlimited, not zero. Map to capacity: null in your output, not capacity: 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, venue is null and there is no structured online_platform field — 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 as null when 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) under Group.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.highResUrl is the full-size photo. The same entity has baseUrl + id for templated sizes (<baseUrl>/<id>/676x676.jpg, 676x507.jpg, 676x380.jpg).
  • Group lat/lon only present on group-event pages. Group entity on find/search pages omits coordinates; group entity on /<slug>/events/ includes lat, lon, organizer, welcomeBlurb, memberships.
  • Read-only enforcement. Never click any of: Attend / RSVP, Join this group, Save, Sign In, Sign Up, or any actions[] 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",...] }.