If you run a developer community, sponsor events, or just want to know which Python, AI, or data meetups in your city are actually growing, Meetup is the obvious source. The catch is that Meetup closed its free public API to most people years ago. Today the practical options are a paid Pro account with GraphQL access, or reading the same data the website already sends to every logged-out visitor.

This guide takes the second route. We build a small Python Meetup scraper that:

  1. Searches events by keyword and city
  2. Reads a group's member count, rating, and past events
  3. Saves RSVP counts to SQLite on every run
  4. Shows which upcoming events are filling up fastest

Everything here works without logging in, and that's where it stops. Member lists, attendee names, and private groups need an account, and they're off limits for this kind of tool anyway.

A quick note on manners: stick to public pages, keep the request rate low, cache what you fetch, and don't collect personal data about attendees. Meetup's terms apply to you, not to me.

Where the data actually lives

Meetup's website is a Next.js app. Every server-rendered page ships a <script id="__NEXT_DATA__"> tag, and inside it sits __APOLLO_STATE__: the Apollo GraphQL client cache the page was rendered from. That means you don't parse HTML at all. You get the same normalized objects the frontend uses:

  • ROOT_QUERY holds the query results, keyed by the query and its variables, for example eventSearch:{...} or groupByUrlname:{...}
  • Event:317054890, Group:14064142, Venue:..., and Member:... hold the actual records
  • Records point to each other with {"__ref": "Group:14064142"} instead of nesting

Event pages also carry schema.org Event JSON-LD, which is handy for name, start and end time, and status. But the JSON-LD has no RSVP counts and a truncated description, so the Apollo cache is the better source.

Three URLs cover most needs:

  • Search: https://www.meetup.com/find/?keywords=python&location=gb--london&source=EVENTS
  • Group upcoming events: https://www.meetup.com/<urlname>/events/
  • Group past events: https://www.meetup.com/<urlname>/events/?type=past

The location value is a slug like gb--london or us--ny--new-york. Open the search page in a browser, pick a city, and copy it from the address bar.

Setup

You only need requests:

pip install requests

Fetching a page and unpacking the Apollo cache

import json
import random
import re
import sqlite3
import sys
import time
from datetime import datetime, timezone

import requests

HEADERS = {
    "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
                  "(KHTML, like Gecko) Chrome/130.0 Safari/537.36",
    "Accept-Language": "en-US,en;q=0.9",
}
NEXT_DATA = re.compile(
    r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>', re.S)

session = requests.Session()
session.headers.update(HEADERS)


def get(url, params=None, tries=4):
    for attempt in range(tries):
        res = session.get(url, params=params, timeout=30)
        if res.status_code == 200:
            return res.text
        if res.status_code in (429, 500, 502, 503, 504):
            time.sleep(2 ** attempt + random.random())
            continue
        res.raise_for_status()
    raise RuntimeError(f"gave up on {url} after {tries} tries")


def apollo_state(html):
    m = NEXT_DATA.search(html)
    if not m:
        raise ValueError("no __NEXT_DATA__ on page (blocked, or layout changed)")
    data = json.loads(m.group(1))
    state = data["props"]["pageProps"].get("__APOLLO_STATE__")
    if not state:
        raise ValueError("page has no Apollo cache")
    return state


def deref(state, value):
    """Follow {"__ref": "Group:123"} pointers into the cache."""
    if isinstance(value, dict) and "__ref" in value:
        return state.get(value["__ref"], {})
    return value or {}


def root_field(state, prefix):
    for key, value in state["ROOT_QUERY"].items():
        if key.startswith(prefix):
            return value
    return None

The deref() helper is the important part. Without it you end up with half your fields as {"__ref": ...} dicts and you'll wonder where the group name went.

root_field() matches keys by prefix because the full key includes the variables Meetup used, including a timestamp and a guessed lat/lon. That key changes on every request, so never hard-code it.

Searching events

def search_events(keywords, location):
    html = get("https://www.meetup.com/find/",
               {"keywords": keywords, "location": location, "source": "EVENTS"})
    state = apollo_state(html)
    conn = root_field(state, "eventSearch")
    if conn is None:
        return [], None
    rows = []
    for edge in conn["edges"]:
        ev = deref(state, edge["node"])
        group = deref(state, ev.get("group"))
        rows.append({
            "event_id": ev["id"],
            "title": ev.get("title"),
            "starts_at": ev.get("dateTime"),
            "type": ev.get("eventType"),
            "url": ev.get("eventUrl"),
            "going": (ev.get("rsvps") or {}).get("totalCount"),
            "max_tickets": ev.get("maxTickets"),
            "group": group.get("urlname"),
            "group_rating": ((group.get("stats") or {}).get("eventRatings") or {}).get("average"),
        })
    return rows, conn.get("totalCount")

When I ran this for python in London it returned 14 events, led by a PyData London meetup with 201 people going. It also returned a badminton session and a social media event. Search is ranked by relevance across titles, descriptions, and group topics, and it's loose. If you're building a list of tech events, filter on title or group name afterward instead of trusting the keyword.

Two other things to know:

  • Only the first page is server-rendered. The cache includes pageInfo.hasNextPage and an endCursor, but loading page two happens in the browser through Meetup's internal GraphQL endpoint. You can replay those calls, but it's undocumented and changes without warning. For a weekly “what's on in my city” check, page one, sorted by date, is often enough.
  • dateTime formats vary. Search results come back in UTC with a Z, while group pages use the group's local offset, like +01:00. Parse with datetime.fromisoformat() and compare as aware datetimes.

Group stats and past events

def group_summary(urlname, past=False):
    url = f"https://www.meetup.com/{urlname}/events/"
    html = get(url, {"type": "past"} if past else None)
    state = apollo_state(html)
    group = next(v for k, v in state.items()
                 if k.startswith("Group:") and v.get("urlname") == urlname)
    stats = group.get("stats") or {}
    events = []
    for key, ev in state.items():
        if not key.startswith("Event:"):
            continue
        going = (ev.get("going") or {}).get("totalCount")
        events.append({
            "event_id": ev["id"],
            "title": ev.get("title"),
            "starts_at": ev.get("dateTime"),
            "status": ev.get("status"),
            "going": going,
            "max_tickets": ev.get("maxTickets"),
            "url": ev.get("eventUrl"),
        })
    return {
        "urlname": urlname,
        "name": group.get("name"),
        "city": group.get("city"),
        "members": (stats.get("memberCounts") or {}).get("all"),
        "rating": (stats.get("eventRatings") or {}).get("average"),
        "ratings": (stats.get("eventRatings") or {}).get("total"),
    }, events

For PyData London this returns 16,599 members, a 4.74 average rating from 2,750 ratings, and 10 past events on the first page, each with a going count. Note the field name changes: on the search page the RSVP total is under rsvps.totalCount, on group pages it's going.totalCount. Same number, different query, different cache shape. Expect this kind of thing anywhere you read a frontend cache.

Past events are the most useful part for analysis. Turnout compared with maxTickets across the last year tells you more about a community's health than the member count, which only ever goes up.

Tracking RSVPs over time

A single snapshot says little. Running the search daily and storing counts shows which events are filling up:

SCHEMA = """
CREATE TABLE IF NOT EXISTS rsvp_snapshots (
    event_id   TEXT NOT NULL,
    seen_at    TEXT NOT NULL,
    title      TEXT,
    starts_at  TEXT,
    going      INTEGER,
    max_tickets INTEGER,
    PRIMARY KEY (event_id, seen_at)
);
"""


def save(db, rows):
    now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M")
    db.executemany(
        "INSERT OR IGNORE INTO rsvp_snapshots VALUES (?,?,?,?,?,?)",
        [(r["event_id"], now, r["title"], r["starts_at"], r["going"], r["max_tickets"])
         for r in rows])
    db.commit()


def movers(db):
    return db.execute("""
        SELECT title, MIN(going), MAX(going), MAX(going) - MIN(going) AS delta,
               MAX(max_tickets)
        FROM rsvp_snapshots
        GROUP BY event_id
        HAVING COUNT(*) > 1
        ORDER BY delta DESC
        LIMIT 10
    """).fetchall()

The primary key on (event_id, seen_at) with minute precision makes reruns harmless. Run it from cron once or twice a day. Meetup RSVP counts don't change fast enough to justify more, and fewer requests keep you well clear of rate limits.

Putting it together

if __name__ == "__main__":
    keywords = sys.argv[1] if len(sys.argv) > 1 else "python"
    location = sys.argv[2] if len(sys.argv) > 2 else "gb--london"
    rows, total = search_events(keywords, location)
    print(f"{len(rows)} of {total} events on page 1")
    for r in rows[:5]:
        print(f'{r["starts_at"]}  {r["going"]:>4} going  {r["title"]}')
    db = sqlite3.connect("meetup.db")
    db.executescript(SCHEMA)
    save(db, rows)
    for title, first, last, delta, cap in movers(db):
        print(f"+{delta:<4} {first} -> {last} (cap {cap})  {title}")
    if rows:
        info, past = group_summary(rows[0]["group"], past=True)
        print(info)
        print(len(past), "past events on the first page")
python meetup_scraper.py python gb--london
python meetup_scraper.py "machine learning" us--ny--new-york

Pitfalls I hit

  • No __NEXT_DATA__ means you were blocked, not that the layout changed. Meetup sits behind a bot filter. A plain browser User-Agent and a slow request rate work for small jobs. Hammer it and you'll get challenge pages, so back off instead of retrying in a loop.
  • Query keys contain timestamps. Match by prefix, as above.
  • Field names differ between pages for the same concept (rsvps against going).
  • Group status: PAID means the organizer pays for the group. It doesn't mean the events cost money. Check feeSettings on the event for that.
  • Online events have eventType: ONLINE and a placeholder venue. Filter them out before plotting anything on a map.
  • Cancelled events stay in past listings with status: CANCELLED. Exclude them, or your turnout averages will dip for no real reason.

When the DIY version stops being enough

For one city and a handful of groups, this script is fine. It gets tedious when you need many cities, deeper pagination, or member profiles, because then you're maintaining a GraphQL replay client against an API that was never meant for you.

At that point I'd use a hosted API. I use ScrapeAtlas* for this. GET /v1/meetup/search takes query and location plus optional category_id, distance, start_date/end_date, event_type (online or inPerson), sort, and a cursor for the next page:

import os
import requests

def api_search(query, location, cursor=None):
    params = {"query": query, "location": location, "sort": "DATETIME"}
    if cursor:
        params["cursor"] = cursor
    res = requests.get(
        "https://api.scrapeatlas.com/v1/meetup/search",
        params=params,
        headers={"x-api-key": os.environ["SCRAPEATLAS_API_KEY"]},
        timeout=60,
    )
    res.raise_for_status()
    data = res.json()
    # data["status"] is "success" or "partial"; data["coverage"]["warnings"] explains gaps
    return data.get("events", []), data.get("nextCursor")

There are also /v1/meetup/locations to turn a city name into a location slug, /v1/meetup/categories, /v1/meetup/search/groups, /v1/meetup/group, /v1/meetup/group/events (with status=upcoming or past and a cursor), and /v1/meetup/event.

Wrapping up

  • Skip the HTML. Meetup pages carry their full Apollo cache in __NEXT_DATA__.
  • Follow __ref pointers and match ROOT_QUERY keys by prefix.
  • Search is loose. Filter results yourself.
  • Only page one is server-rendered. Deeper pages mean replaying internal GraphQL calls.
  • Snapshot RSVPs over time. Change over time is where the useful signal is.

If you find a field I missed, or Meetup moves something, leave a note and I'll update the script.


*I build it.