DEV Community

Cover image for Gumroad API pagination and HTTP 200 errors: the store that looked half-empty
Christian Anderson
Christian Anderson

Posted on

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:

POST   /v2/products              success=True   (created, unpublished)
PUT    /v2/products/{id}         success=True   (edit)
PUT    /v2/products/{id}/enable  success=True   (published)
DELETE /v2/products/{id}         success=True   (account back to 0 products)
Enter fullscreen mode Exit fullscreen mode

Creation works. What had misled me is what a bare POST /v2/products returns. It is not a 404. It is an HTTP 200 with this body:

{"success": false, "message": "New products should be created with a price"}
Enter fullscreen mode Exit fullscreen mode

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."}
Enter fullscreen mode Exit fullscreen mode

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 one 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
Enter fullscreen mode Exit fullscreen mode

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.

Trap two again, four days later

On 24 September I built a separate agent to propose better titles, summaries and tags for the store and my blog posts. It has its own helper module with its own Gumroad client, written from scratch after the 20 September fix.

It read page one only. The store had 21 products at that point, and the agent could see 10. Eleven were invisible, including several of the guides I had specifically asked it to look at. My own check missed the same eleven.

The fix was the same loop, in a different file. I then ran a second batch to add summaries to those 11 products and read each one back.

This is the part I find worth writing down. The 20 September fix was correct and it was tested. It fixed one function in one script. The pattern — call the list endpoint once and treat the result as the whole store — lived in my head, and it came out again the next time I wrote a Gumroad client from scratch.

Grep for the callers, not the bug

While writing this post I grepped my scripts directory for anything that touches v2/products. Four files came up. 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:

# ⚠️ Gumroad answers EVERYTHING 200 and puts the real outcome in `success`.
# A missing product is 200. A malformed request is 200. Read `success`.
d = _get(f"https://api.gumroad.com/v2/products?access_token={tok}")
Enter fullscreen mode Exit fullscreen mode

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:

  1. Read the outcome field, not the status code. For Gumroad that means success. A 200 only means the request arrived.
  2. 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. Ten felt like a whole store to me because it was a round, plausible number.
  3. When you fix it, grep for every caller. Search for the endpoint string, not the function name, because a second client written from scratch won't share your function names. Then check each hit.
  4. Don't let your own check share the agent's blind spot. Mine missed the same eleven products the agent did, so it agreed with the agent instead of catching it.
  5. Count before you create. If a script is about to create something because it "isn't there yet", the thing it checked against needs to be complete, or the guard is decoration.

None of these failures raised an exception. Every request succeeded. The store just looked smaller than it was, and three scripts believed it.


🤖 Drafted with AI assistance from my own homelab notes, logs and repos, then reviewed and edited before publishing.

Top comments (0)