Gumroad has no public products API. It doesn't need one — the same Inertia.js framework that renders its React frontend also hands every visitor the exact JSON that frontend was built from, HTML-escaped inside a single data-page attribute.
Quick answer
Every Gumroad page — Discover search results, a creator's storefront, a single product — carries its full server props in <div id="app" data-page="...">, where the attribute value is HTML-entity-escaped JSON (" for every "). Extraction is regex → html.unescape() → json.loads(), then a component field (Discover/Index, Users/Show, Products/Show) tells you which of three payload shapes you're holding. The shapes disagree on where seller identity lives, though: storefront pages (Users/Show) ship product.seller: null for every product — the seller only exists at the page level, in props.creator_profile, and has to be passed down as a fallback per product.
How do you pull structured data out of an Inertia.js page?
The whole extraction is three steps and no headless browser:
DATA_PAGE_RE = re.compile(r'data-page="([^"]*)"')
def extract_data_page(html_text: str) -> dict[str, Any]:
"""Regex -> ``html.unescape()`` -> ``json.loads()`` the Inertia payload."""
match = DATA_PAGE_RE.search(html_text)
if not match:
raise GumroadParseError("No data-page attribute found in response HTML")
raw_json = html.unescape(match.group(1))
try:
return json.loads(raw_json)
except json.JSONDecodeError as exc:
raise GumroadParseError(f"Malformed data-page JSON: {exc}") from exc
Any drift from the expected shape — no data-page attribute, unparseable JSON, a component value we don't recognize, a required key missing — raises GumroadParseError immediately instead of shipping a half-built row. curl-cffi fetches the HTML with a rotating browser TLS fingerprint (chrome131, chrome124, firefox147, safari180) and retries 429/5xx with exponential backoff before that parse ever runs.
Why is product.seller null on some pages but not others?
Because Gumroad's own frontend doesn't repeat the seller object on every product when you're already looking at that seller's storefront — it's implied by the page you're on. The parser recovers it explicitly:
def _build_fallback_seller(creator_profile: dict[str, Any]) -> dict[str, Any]:
"""``Users/Show`` products carry ``seller: null`` — the storefront's
own ``creator_profile`` is the only source of seller identity there."""
return {
"name": creator_profile.get("name", ""),
"profile_url": creator_profile.get("subdomain", ""),
"is_verified": creator_profile.get("is_verified", False),
}
Discover/Index and Products/Show payloads carry a full seller object per product, so the fallback only kicks in for creator-storefront mode — but skip that branch entirely and every row scraped from a creator page ships with a blank seller.
Does Discover's multi-tag filter search for products with tag A AND tag B, or A OR B?
OR — and that only got confirmed by live-probing it, not by reading the docs. The tag/filetype params use the Rails-style repeated-param encoding (tags[]=a&tags[]=b), and the combined result count matches the union of each tag's individual doc_count, not the intersection:
# Multi-tag/multi-filetype encoding (live-probed 2026-07-20): Gumroad
# expects the Rails-style repeated ``tags[]=a&tags[]=b`` param, matched
# with an OR/union semantic across values (verified: two real tags with
# doc_counts 1402 and 3030 combined to a total of ~4256, ~= their union,
# not their intersection). The spec's "spaces -> hyphens" narrative for
# tag values did NOT hold up under live probing — literal spaces
# reproduce the exact tags_data[].doc_count from the Discover payload;
# a hyphenated variant matched nothing.
Two assumptions from the original spec broke against the live site: the union-not-intersection semantic, and the belief that multi-word tag values needed hyphens. Both got corrected against real Discover responses before shipping, which is also why tag/filetype values pass through unmodified — no hyphenation applied.
Is scraping public Gumroad listings legal?
Every field this Actor emits — product name, price, ratings, seller name, sales count — is visible to any visitor on the public Discover feed, a public storefront, or a public product page, with no login and no account-gated data. Standard care applies: use it for market research and pricing intel, not for republishing seller descriptions wholesale.
FAQ
Do I need a Gumroad account or API key?
No — this reads the same server-rendered props your browser gets when you load the page, no auth required.
What happens if the same product shows up from both a search and a creator page in one run?
It's de-duplicated by product_id across all three modes — you get one row per unique product no matter how many ways the run found it.
Why would a row's url differ from the page I originally fetched?
Both product.url and seller.profile_url carry tracking query params (?recommended_by=search, ?layout=discover) in the raw payload — the parser strips query/fragment before building the row's url and seller_profile_url.
What does it cost?
$0.20 to start a run, then $0.0025 per unique product row written — about $2.70 for a 1,000-row run including the warm-up fee, and duplicate hits across modes never get charged twice.
Try it: Gumroad Product Scraper — Discover search, creator storefronts, or single product URLs, all normalized into one typed schema with pricing, ratings, and seller identity.
We do the dirty work so your dataset stays clean. 😈
Top comments (0)