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:
- Searches events by keyword and city
- Reads a group's member count, rating, and past events
- Saves RSVP counts to SQLite on every run
- 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_QUERYholds the query results, keyed by the query and its variables, for exampleeventSearch:{...}orgroupByUrlname:{...}Event:317054890,Group:14064142,Venue:..., andMember:...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.hasNextPageand anendCursor, 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. dateTimeformats vary. Search results come back in UTC with aZ, while group pages use the group's local offset, like+01:00. Parse withdatetime.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 (
rsvpsagainstgoing). - Group
status: PAIDmeans the organizer pays for the group. It doesn't mean the events cost money. CheckfeeSettingson the event for that. - Online events have
eventType: ONLINEand 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
__refpointers and matchROOT_QUERYkeys 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.