Gumroad API pagination and HTTP 200 errors: the store that looked half-empty
Gumroad API Pagination and HTTP 200 Errors: The Store That Looked Half-Empty
I run a small Gumroad store from a set of scripts on my homelab. The scripts create products, set prices and tags, and check what is already listed before they list anything new. Twice this month they gave me a completely wrong picture of that store, and neither time did anything error. Both mistakes came from the same place: the Gumroad API tells you what happened in a way that is easy to misread. There are two traps. The HTTP status code tells you almost nothing, and the product list comes back ten at a time.
Trap One: Everything Is HTTP 200
In late August I wrote down, with some confidence, that Gumroad's API could not create products. The note said POST /v2/products returned 404 and that creation was dashboard-only. I built the rest of the tooling around that: a script that packaged the files, then a manual step where I would click through the dashboard.
On 5 September I ran the full lifecycle against the live account to check, and the note was wrong:
{
"success": false,
"message": "New products should be created with a price"
}
Gumroad answers nearly everything with 200 and puts the real outcome in the success field. A product that does not exist is also a 200:
{
"success": false,
"message": "The product was not found."
}
So there are two wrong readings available, and I had managed one of them. Treat the 200 as "the endpoint exists and it worked" and you carry on with a failure. Or half-remember a failure as "it 404'd" and you conclude the feature is missing. The status code in this API tells you that you reached Gumroad. It does not tell you whether Gumroad did what you asked. Once I read success instead, the manual step disappeared. The scripts now create, edit, publish and tag products end to end.
Tags, for the record, work through PUT /v2/products/{id} with repeated tags[] parameters. I applied them and read them back to confirm rather than trusting the response. A related issue caught me later when I switched a product to pay-what-you-want. customizable_price and suggested_price_cents have to be sent together. Send only one and the product renders as plain free. Again, no error.
Trap Two: Ten Products, Then Next Page URL
On 20 September I added a sync command. It lists and publishes every item in my local catalogue that is not live on Gumroad yet. It already had a guard: before creating anything, check whether a product with that name is already on Gumroad.
GET /v2/products returns only the first ten products. The rest sit behind a next_page_url field in the response. My status() function and the duplicate check in create() both read page one and stopped. The consequences were all quiet:
- The store appeared to have 10 products. It had 22.
- A diff of catalogue against store said 10 catalogue items had never been listed. In fact 14 of the 15 were already live.
- The duplicate guard could not see page two, so the first sync run created two duplicate products.
I deleted both the same night and kept the older originals, which were the ones already linked from elsewhere. Nothing errored. Nothing was wrong with any single request. The bug was in what I assumed a single request meant. The fix was a function that walks the pages:
import json
import urllib.parse
import urllib.request
API = "https://api.gumroad.com/v2"
def gumroad_get(url, token):
sep = "&" if "?" in url else "?"
url += sep + urllib.parse.urlencode({"access_token": token})
with urllib.request.urlopen(url, timeout=60) as r:
body = json.load(r)
# HTTP 200 means nothing here. The outcome is in `success`.
if not body.get("success"):
raise RuntimeError(f"gumroad: {body.get('message')}")
return body
def all_products(token, max_pages=25):
url, out, pages = f"{API}/products", [], 0
while url and pages < max_pages:
body = gumroad_get(url, token)
out += body.get("products") or []
url = body.get("next_page_url")
if url and not url.startswith("http"):
url = "https://api.gumroad.com" + url
pages += 1
return out
A few details in there matter. The loop accepts next_page_url as either a full URL or a bare path, so it does not break on whichever form comes back. The token is added with & when the URL already carries a query string, which the next-page URL does. The page cap of 25 is there so a bad cursor cannot loop forever. And the success check runs on every page, not just the first. After the fix, status() and the duplicate check both use all_products(). The note I left in the code says it plainly: anything asking "is this already on Gumroad?" must use this function, never a single call to the products endpoint.
The Pattern: Treat List Endpoints as Paginated Searches
Four files came up when I grepped my scripts directory for anything that touches v2/products. Two now paginate. One is the batch script that fixed the missing summaries. The fourth is the script that records distribution numbers per product. It still reads page one and makes the same mistake:
d = _get(
f"https://api.gumroad.com/v2/products?access_token={tok}"
)
It had the first lesson written into a comment, and it still made the second mistake. I fixed it the same afternoon with the same loop, and it now sees all 21 products instead of 10. So I now treat an API quirk like this as a search, not a patch: read the outcome field, not the status code. For Gumroad that means success. A 200 only means the request arrived. Assume every list endpoint is paginated until you've proved otherwise. Check for a next_page_url, cursor or page field on the first response, even when you think the list is small.
Lessons Learned
- Never rely on HTTP status codes alone. Gumroad answers nearly everything with 200 and puts the real outcome in the
successfield. A 200 can mean "request received" or "nothing to report," depending on the endpoint. - Pagination is universal. Even endpoints that seem small (like listing products) can hide dozens of items behind
next_page_urlfields. Always iterate through pages unless you have proof otherwise. - Use the dedicated pagination helper. The
all_products()function demonstrates the correct pattern: walk through pages, collect all results, and verify each item individually. - Check the outcome, not the status. When debugging, inspect
response["success"]rather than assuming a 200 means success. This prevents silent failures where requests succeed but the operation actually failed (e.g., missing required fields).
Comments
No comments yet. Start the discussion.