How to Scrape Product Hunt: Launches, Upvotes, Makers
Scrape Product Hunt without guesswork: pull launches, upvotes, makers, and topics from the official GraphQL API v2, with a public-page fallback.

To scrape Product Hunt the right way, start with the door that's already open: Product Hunt ships an official GraphQL API (v2) that returns launches, upvotes, makers, and topics as structured JSON, no HTML parsing required. Most tutorials skip it and jump straight to the website, which is slower, more fragile, and harder to defend. This guide covers the API-first path in full, then the public-page fallback for the cases the API doesn't cover, and it tells you which one is the right call for a given job.
Is it legal to scrape Product Hunt?
Two separate questions decide this, and people usually collapse them into one.
The first is access. In the United States, the Ninth Circuit's ruling in hiQ Labs v. LinkedIn (2022) held that collecting data that's publicly accessible, meaning no login and no authentication wall, generally does not violate the Computer Fraud and Abuse Act. That covers unauthorized access, not a license to do anything you want with the data.
The second is contract. Product Hunt's Terms of Service restrict automated collection of the site, so scraping the public website can be a breach of contract even where it isn't a CFAA problem. The official API sits on the other side of that line: when you use it with a token, you're operating under Product Hunt's API Terms, which is authorized access by design. That alone is a strong reason to make the API your primary route.
Practical guardrails that keep a project defensible:
- Prefer the official API. It's the sanctioned path and gives cleaner data.
- Collect public data (launches, taglines, vote counts, public profiles). Never anything behind a login.
- Treat makers and voters as people, not rows. Pull the minimum, and follow GDPR or CCPA if you retain anything personal.
- Attribute Product Hunt and link back where you republish, as the API terms ask. Don't rehost copyrighted media.
- If the data feeds a commercial product, run it past a lawyer. This is engineering guidance, not legal advice.
For the general theory of collecting responsibly, our guide on ethical scraping and rate limiting covers the wider picture.
Two routes: official API vs public pages
There are exactly two ways to get Product Hunt data, and they are not equal. The GraphQL API is the front door. Scraping the website is the side window you use only when the front door doesn't have the room you need.
| Official GraphQL API v2 | Public-page scraping | |
|---|---|---|
| Data format | Structured JSON | HTML with a `__NEXT_DATA__` JSON blob |
| Auth | OAuth bearer or developer token | None (public URLs) |
| Anti-bot | Not a factor (authorized) | Bot checks, rate limits, IP bans |
| Rate limit | 6250 complexity points / 15 min, per token | Per IP; rotate residential IPs |
| Proxies needed | No | Yes, residential |
| Best for | Launches, upvotes, makers, topics at the source | Fields the API hides, or when you have no token |
The takeaway: reach for the API first. It returns exactly the entities you want, already parsed, with no CAPTCHA in the way. Fall back to page scraping for the narrow cases where you need something the schema doesn't expose, or where getting a token isn't practical. If you're weighing a managed endpoint against running your own proxy pool for that fallback, Web Scraping API vs Self-Managed Proxies lays out the trade.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
What you can pull: products, launches, upvotes, makers, topics
Product Hunt's data model is small and consistent, which makes it pleasant to work with. Five entities carry almost everything you'll want, and they nest cleanly: a launch has makers, topics, votes, and comments hanging off it.
| Entity | Useful fields | Where it lives in the API |
|---|---|---|
| Post (a launch) | `name`, `tagline`, `slug`, `url`, `votesCount`, `commentsCount`, `createdAt`, `featuredAt` | `posts` / `post` |
| Maker / user | `name`, `username`, `headline`, `profileImage`, `twitterUsername` | `makers` on a post, or `user(username:)` |
| Topic | `name`, `slug`, `description`, `followersCount`, `postsCount` | `topics` / `topic` |
| Collection | `name`, `tagline`, `followersCount` | `collections` / `collection` |
| Vote (upvote) | `createdAt`, `user` | `votes` connection on a post |
A launch is a Post. The number you probably care about most, the upvote total, is votesCount on that post, and it's a single field, so you don't have to page through individual votes just to get a count. The featuredAt timestamp tells you whether and when a post made the homepage, which is different from createdAt (when it was submitted). Makers are the people credited on the launch; the user field on a post is the hunter who posted it, which is often a different person. Keep those two straight or your "who launched this" data will be wrong.
Set up the Product Hunt GraphQL API v2
The endpoint is a single URL:
https://api.producthunt.com/v2/api/graphql
Everything goes through one POST request with a GraphQL query in the body. Authentication is a bearer token in the Authorization header. You have three ways to get one:
- Developer token. Create an application in the Product Hunt API dashboard and generate a developer token. It doesn't expire and it's tied to your account. This is the fastest way to start, and it's the right choice for scripts and internal data pulls.
- OAuth (user). The standard authorization-code flow when you're acting on behalf of a logged-in user.
- OAuth client credentials (PKCE). For public clients that need app-level access without a user session.
For a scraper, the developer token is almost always what you want. Here's the smallest possible authenticated request, in cURL, to confirm your token works:
curl -s -X POST "https://api.producthunt.com/v2/api/graphql" \
-H "Authorization: Bearer YOUR_DEVELOPER_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"query": "{ viewer { user { name username } } }"}'
If that returns your name, you're in. Now wrap it in Python so every call carries the headers and surfaces GraphQL errors, which the API returns with an HTTP 200 and an errors array rather than a non-200 status:
import requests
PH_ENDPOINT = "https://api.producthunt.com/v2/api/graphql"
PH_TOKEN = "YOUR_DEVELOPER_TOKEN"
def ph_query(query: str, variables: dict | None = None) -> dict:
resp = requests.post(
PH_ENDPOINT,
headers={
"Authorization": f"Bearer {PH_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
},
json={"query": query, "variables": variables or {}},
timeout=30,
)
resp.raise_for_status()
payload = resp.json()
if payload.get("errors"):
raise RuntimeError(payload["errors"])
return payload["data"]
That errors check matters. A malformed field or a permission you don't have comes back as 200 OK with an error body, so code that only checks resp.status_code will silently treat a failed query as success. If you want the general pattern for querying GraphQL endpoints, we go deeper in How to Scrape GraphQL APIs.
Query launches, upvotes, makers, and topics
The workhorse query is a day's launches ranked by upvotes, which is the "leaderboard" view most people picture when they think of Product Hunt. You get it from the posts field with three arguments: an order, and a postedAfter / postedBefore window.
query DailyLaunches($after: DateTime, $before: DateTime, $cursor: String) {
posts(order: VOTES, postedAfter: $after, postedBefore: $before, first: 20, after: $cursor) {
edges {
node {
id
name
tagline
slug
url
votesCount
commentsCount
createdAt
featuredAt
topics(first: 5) {
edges { node { id name slug } }
}
makers {
id
name
username
headline
}
user { # the hunter who posted it
id
name
username
}
}
cursor
}
pageInfo {
endCursor
hasNextPage
}
}
}
One detail that trips people up: Product Hunt's leaderboard day runs on Pacific Time. If you want the launches that belong to a specific calendar day on the site, set your postedAfter and postedBefore with a PT offset, not UTC, or your window will straddle two leaderboard days.
DAILY_LAUNCHES = """<the query above>"""
# A single Product Hunt "day" in Pacific Time (PDT offset shown)
variables = {
"after": "2026-08-09T00:00:00-07:00",
"before": "2026-08-09T23:59:59-07:00",
"cursor": None,
}
data = ph_query(DAILY_LAUNCHES, variables)
for edge in data["posts"]["edges"]:
post = edge["node"]
print(post["votesCount"], post["name"], "-", post["tagline"])
Topics work the same way, and a topic carries its own ranked posts, so you can pull the top launches in a category in one round trip:
query TopicLaunches($slug: String!, $cursor: String) {
topic(slug: $slug) {
id
name
followersCount
postsCount
posts(order: VOTES, first: 20, after: $cursor) {
edges { node { id name tagline votesCount featuredAt } cursor }
pageInfo { endCursor hasNextPage }
}
}
}
Call it with {"slug": "artificial-intelligence"} to get the most-upvoted AI launches. To read the individual upvotes on a post, rather than just the count, ask for the votes connection:
query PostVoters($slug: String!, $cursor: String) {
post(slug: $slug) {
id
name
votesCount
votes(first: 50, after: $cursor) {
edges {
node {
id
createdAt
user { id name username }
}
cursor
}
pageInfo { endCursor hasNextPage }
}
}
}
Before you page through thousands of voters, ask whether you need them. The votesCount field already gives you the total. Voter identities are personal data, so collect the user-level list only when you have a real reason and a lawful basis, and store the minimum. Aggregate counts answer most analytics questions on their own.
Cursor pagination and the complexity rate limit
Every connection (posts, votes, topics) paginates the same way: request a page with first: N, read pageInfo.hasNextPage and pageInfo.endCursor, then pass that cursor back as after on the next call. Loop until hasNextPage is false.
def paginate(query: str, root_field: str, variables: dict) -> list[dict]:
nodes, cursor = [], None
while True:
variables["cursor"] = cursor
data = ph_query(query, variables)
conn = data[root_field]
nodes += [edge["node"] for edge in conn["edges"]]
info = conn["pageInfo"]
if not info["hasNextPage"]:
return nodes
cursor = info["endCursor"]
Now the part most "Product Hunt scraper" articles get wrong. The API's rate limit is complexity-based, not request-based: your application gets a budget of 6250 complexity points per 15-minute window on the GraphQL endpoint. Cost scales with how many objects a query could return, so nesting matters. A posts(first: 50) query that also pulls votes(first: 50) on every post can multiply out to thousands of potential nodes and burn your budget in a handful of calls. Two lean queries usually cost less than one deep one.
Because the limit is tied to your token, rotating IP addresses does nothing to raise it. Proxies help you scrape the public site; they do not buy you more API quota. The real levers are query design and reading the rate-limit headers the API returns on every response:
import time
def ph_query_guarded(query: str, variables: dict | None = None) -> dict:
resp = requests.post(
PH_ENDPOINT,
headers={
"Authorization": f"Bearer {PH_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
},
json={"query": query, "variables": variables or {}},
timeout=30,
)
remaining = int(resp.headers.get("X-Rate-Limit-Remaining", "1"))
reset = int(resp.headers.get("X-Rate-Limit-Reset", "0"))
if remaining < 500: # running low on complexity budget
time.sleep(reset + 1) # wait out the 15-minute window
resp.raise_for_status()
payload = resp.json()
if payload.get("errors"):
raise RuntimeError(payload["errors"])
return payload["data"]
The three headers to watch are X-Rate-Limit-Limit (your ceiling for the window), X-Rate-Limit-Remaining (points left), and X-Rate-Limit-Reset (seconds until it refills). Request only the fields you use, cache anything immutable, and you'll rarely hit the wall. Launch data for a past day never changes, so store it once and never re-query it.
Scrape public pages with the SparkProxy Scraping API
Sometimes the API isn't the answer: you might not have a token, or you want a field the schema doesn't surface, or you're reconstructing exactly what a public page shows. Product Hunt's website is a Next.js application, and that's good news, because Next.js ships the page's data as a JSON blob inside the HTML. You don't scrape the rendered DOM, you read that blob.
The catch is getting the HTML at all. Hitting producthunt.com at volume from a datacenter IP invites bot checks and rate limits. This is where a scraping API earns its place: it handles the residential IP, the browser rendering, and the anti-bot layer, and hands you the finished HTML. The SparkProxy Scraping API base is https://scrape.sparkproxy.io/api/v1, and auth is one header, X-API-Key.
import requests
SCRAPE_API = "https://scrape.sparkproxy.io/api/v1"
SCRAPE_KEY = "YOUR_API_KEY"
def fetch_public(url: str) -> str:
resp = requests.get(
SCRAPE_API,
headers={"X-API-Key": SCRAPE_KEY},
params={
"url": url,
"render_js": "true", # Product Hunt is a Next.js SPA
"premium_proxy": "true", # residential IPs avoid datacenter blocks
"wait_for": "#__NEXT_DATA__",
},
timeout=90,
)
resp.raise_for_status()
return resp.text
Two parameters do the heavy lifting. render_js=true runs a real Chromium browser so client-side content is present, and premium_proxy=true routes through residential IPs that survive the site's defenses where a plain datacenter IP gets flagged. Point it at any public URL: a daily leaderboard (https://www.producthunt.com/leaderboard/daily/2026/8/9), a topic page (https://www.producthunt.com/topics/artificial-intelligence), or an individual launch. The full parameter list is in the Scraping API docs.
Pull structured data from __NEXT_DATA__
Every Next.js page embeds a element holding the data the page was built from. Parse that and you get structured JSON instead of scraping fragile CSS selectors. Product Hunt normalizes its data through an Apollo cache, so the useful records usually live under props.pageProps.apolloState, keyed like Post:123456.
import json
from selectolax.parser import HTMLParser
def next_data(html: str) -> dict:
tree = HTMLParser(html)
node = tree.css_first("script#__NEXT_DATA__")
if node is None:
raise ValueError("No __NEXT_DATA__ found; the page layout may have changed")
return json.loads(node.text())
def posts_from_apollo(nd: dict) -> list[dict]:
apollo = nd.get("props", {}).get("pageProps", {}).get("apolloState", {})
posts = []
for key, val in apollo.items():
if key.startswith("Post:"):
posts.append({
"id": val.get("id"),
"name": val.get("name"),
"tagline": val.get("tagline"),
"slug": val.get("slug"),
"votes": val.get("votesCount"),
})
return posts
html = fetch_public("https://www.producthunt.com/leaderboard/daily/2026/8/9")
launches = posts_from_apollo(next_data(html))
One honest caveat: the exact shape of that JSON is an internal implementation detail, and Product Hunt can change key names or the cache layout without notice. Print the structure once and confirm the paths before you build on them, and add a guard so a layout change fails loudly instead of silently returning an empty list. If you'd rather not walk the JSON yourself, the Scraping API can extract fields for you with the extract_rules parameter, though for a payload this nested, parsing __NEXT_DATA__ directly is usually cleaner. This "read the embedded state, not the DOM" trick generalizes well; we cover it in How to Scrape Hidden JSON API Endpoints.
Scale without breaking rules
Scaling a Product Hunt pipeline is more about discipline than horsepower. A few rules keep it healthy and defensible:
- Cache immutable data. A past day's launches and their final vote counts don't change. Write them to a database keyed on the launch
idand never re-fetch. This saves your complexity budget for the data that's actually moving. - Design lean queries. Ask for the fields you use and nothing else. Split a deep nested query into two shallow ones when the nesting multiplies node counts.
- Back off on the fallback route. For public-page scraping, retry with exponential backoff and jitter on
429(rate or concurrency limit) and530(the target failed or timed out), and keep concurrency modest. Let the scraping API rotate the exit IP so a single address doesn't get hammered. - Separate the two limits in your head. The API's 6250-point window is per token. The website's tolerance is per IP. A retry strategy that confuses the two will either waste proxies on the API or under-provision them for the site.
- Mind the people. Makers and voters are individuals. Don't assemble profiles you don't need, and honor deletion requests if you retain personal data.
Product Hunt is a friendlier target than most because the sanctioned API exists and is generous. Use it as your spine, treat page scraping as the exception, cache hard, and a scraper that pulls every launch, upvote, maker, and topic will run for months without drama. For a similar startup-directory pattern, see How to Scrape Crunchbase Data.
Frequently asked questions
FAQ
Yes. Product Hunt offers a GraphQL API (v2) at https://api.producthunt.com/v2/api/graphql that returns posts (launches), topics, collections, makers, and votes as structured JSON. It's the recommended way to get Product Hunt data, since it's authorized access and avoids the anti-bot friction of scraping the website.
No. The official GraphQL API is authenticated with a bearer token and has no bot detection, so a direct request works fine. Proxies only matter for the fallback route, scraping the public producthunt.com website, where residential IPs help you avoid rate limits and IP bans.
Create an application in the Product Hunt API dashboard and generate a developer token, which doesn't expire and is tied to your account. Send it as Authorization: Bearer YOUR_TOKEN on each request. For acting on behalf of users, use the OAuth authorization-code flow instead.
The GraphQL endpoint uses a complexity-based limit of 6250 complexity points per 15-minute window, per application token. Each response includes X-Rate-Limit-Limit, X-Rate-Limit-Remaining, and X-Rate-Limit-Reset headers. Because the limit is per token, rotating IP addresses does not increase it; lean queries and caching do.
Query the posts field with order: VOTES and a postedAfter / postedBefore window covering that day, then paginate with the endCursor from pageInfo until hasNextPage is false. Set the timestamps with a Pacific Time offset so the window lines up with Product Hunt's leaderboard day.
Makers are public and come back on each post via the makers field. Individual upvoters are available through the votes connection, but they're personal data, so pull them only with a lawful basis and store the minimum. For most analytics the votesCount total is enough and avoids handling personal information.
Get 20% off your first month
Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.
Save up to 15% more on quarterly, half-yearly and yearly plans
Related articles

Scraping API Pricing: How Credit Multipliers Set Real Cost
Scraping API pricing explained: how JS rendering, premium proxies, domain surcharges and billed failures multiply credit costs, with a worked estimate.

How to Read a Proxy Provider SLA Before You Sign
How to read a proxy SLA clause by clause: what counts as downtime, exclusions that void it, how service credits are calculated and claimed, what to negotiate.

Proxy Pool Size Claims: What Millions of IPs Really Means
Proxy pool size claims decoded: how vendors count IPs, the discounts between headline and usable pool, and a sampling method to estimate the pool you reach.
