🎉 Premium Proxies · 24-Hour Free TrialClaim Now
Guides

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.

S SparkProxy 0 17 min read
Share
How to Scrape Product Hunt: Launches, Upvotes, Makers

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.

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 v2Public-page scraping
Data formatStructured JSONHTML with a `__NEXT_DATA__` JSON blob
AuthOAuth bearer or developer tokenNone (public URLs)
Anti-botNot a factor (authorized)Bot checks, rate limits, IP bans
Rate limit6250 complexity points / 15 min, per tokenPer IP; rotate residential IPs
Proxies neededNoYes, residential
Best forLaunches, upvotes, makers, topics at the sourceFields 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.

Free trial

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.

EntityUseful fieldsWhere 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