Gumroad API Rate Limit: What Actually Triggers a 429
Search for Gumroad's API rate limit and you get one of two answers: a copy of Gumroad's own sentence about custom HTML landing pages, or a shrug — "the limits aren't documented, you'll find out when requests start failing." Both are technically true and neither helps you decide how fast to run a bulk job.
There is a better source. Gumroad's application is open source, and the file that enforces its rate limits — config/initializers/rack_attack.rb — is public. Better still, the deployed API tells you which commit it's running. A request to api.gumroad.com on 27 July 2026 came back with x-revision: 63ddbfb23230, and that prefix resolves to commit 63ddbfb2 in the public repository, authored the same morning. The rate-limiting code you can read is the rate-limiting code that is running.
That header moves fast — Gumroad ships many times a day, and a request a few minutes later returned x-revision: b3327fa8359f, a different (equally real) commit. What matters is that rack_attack.rb is unchanged across both and identical to main, so the numbers below aren't tied to catching one particular deploy.
This article is what that file says, pinned to that revision, plus what the API returns on the wire. It does not cover getting a token or fixing a 401 — that's the access token guide.
What Gumroad documents, and where
Gumroad's API reference contains exactly one rate-limit statement:
Rate limits per token: 30
PUTs/min, 60 previews/min.
That line is real, but its scope matters more than its number. It lives inside the CustomHtmlDocumentation component in Products.tsx, under the heading Custom HTML landing pages, in a bulleted list about the custom_html field, the 500,000-character cap, and the sanitization_report. An identical line appears in User.tsx, under Custom HTML profile page, for the profile equivalent.
So if you have read "Gumroad's docs say 30 writes per minute" and applied that to discount codes or price edits, you have taken a limit documented for landing-page HTML and generalised it. The write limits for offer codes and product edits are not published anywhere in the reference.
The gap is wider than a missing number. Gumroad's Errors.tsx lists the status codes you should expect — 200, 400, 401, 402, 404, and the 5xx family — and then states that error responses "will follow the following format," showing {"success": false, "message": "..."}. 429 is not in that list, and a 429 does not follow that format. More on the actual shape below.
| Endpoint | Documented limit | Where it's documented | Throttle rule in rack_attack.rb |
|---|---|---|---|
PUT /v2/products/:id (custom HTML) |
30 PUTs/min per token | Products.tsx, "Custom HTML landing pages" |
Yes — 30 per 60s, per IP and per token |
PUT /v2/products/:id (price, name, any field) |
Not documented | — | Yes — same rule; it matches the path, not the field |
POST /v2/products/:id/preview_custom_html |
60 previews/min per token | Products.tsx, same list |
Yes — 60 per 60s, per IP and per token |
PUT /v2/user/custom_html |
30 PUTs/min per token | User.tsx, "Custom HTML profile page" |
Yes — 30 per 60s, per IP and per token |
POST /v2/user/preview_custom_html |
60 previews/min per token | User.tsx, same list |
Yes — 60 per 60s, per IP and per token |
POST /v2/products (create) |
Not documented | — | Yes — 10 per 60s, per IP |
POST /v2/products/:id/offer_codes |
Not documented | — | No matching rule |
PUT / DELETE offer codes |
Not documented | — | No matching rule |
GET /v2/products, GET /v2/user |
Not documented | — | No matching rule |
GET /v2/sales (page_key pagination) |
Not documented | — | No matching rule |
GET /v2/sales with page > 10 |
Not documented | — | Yes — 10 per 1s, per IP |
POST /v2/media |
Not documented | — | Yes — 20 per 10 min, per IP and per token |
"No matching rule" is not a promise of unlimited throughput. Cloudflare sits in front of the API — the live response headers include Server: cloudflare and a CF-RAY id, and the Rails code reads the client IP from HTTP_CF_CONNECTING_IP — and Cloudflare's own thresholds aren't public. Gumroad can also add a rule tomorrow. It means only that on this revision, Rack::Attack is not counting those requests.
The rules themselves
Every limit below is a literal line in config/initializers/rack_attack.rb at the deployed revision. Gumroad's own inline comments supply the "max" column.
| Path pattern | Method | Base limit | Keyed by | Ceiling (Gumroad's comment) |
|---|---|---|---|---|
/(api/)?v2/products/:id |
PUT, PATCH | 30 / 60s | IP, and separately the access token | 150 requests / 9 hours |
/(api/)?v2/products |
POST | 10 / 60s | IP | 50 requests / 9 hours |
/(api/)?v2/products/:id/preview_custom_html |
POST | 60 / 60s | IP, and separately the token | 300 requests / 9 hours |
/(api/)?v2/user/custom_html |
PUT, PATCH | 30 / 60s | IP, and separately the token | 150 requests / 9 hours |
/(api/)?v2/user/preview_custom_html |
POST | 60 / 60s | IP, and separately the token | 300 requests / 9 hours |
/(api/)?v2/media |
POST | 20 / 10 min | IP, and separately the token | no backoff tiers (max_level: 1) |
/v2/sales with page > 10 |
any | 10 / 1s | IP | flat rule, no tiers |
/offer_codes/compute_discount (storefront cart, not API v2) |
any | 60 / 30s | IP | 600 requests / 9 hours |
Two of those deserve a note.
The per-token layer on product writes was added on top of the per-IP rule with an explicit comment: "Blocks the IP-rotation bypass and gives token-level attribution when an agent goes off the rails." The token is pulled from either the access_token parameter or an Authorization: Bearer header, so switching between the two authentication styles does not give you a second budget.
The /v2/sales rule only fires when params["page"].to_i > 10. That's the deprecated offset pagination; the sales controller marks page as # DEPRECATED and the documented method is the page_key cursor. If you paginate with page_key, this throttle never applies to you — the parameter it inspects isn't present. Details of that endpoint's paging behaviour are in the customer CSV export guide.
The tier ladder nobody mentions
Here is the part that changes how you plan a long job. Gumroad doesn't register one throttle per rule. It registers a ladder of them, through a helper called throttle_with_exponential_backoff:
throttle(name, limit: requests, period:, &block)
rpm = (requests / period.to_f) * 60
(2..max_level).each do |level|
throttle("#{name}/#{level}", limit: (rpm * level), period: (8**level).seconds, &block)
end
Every level is a separate counter, and all of them must pass. For the 30-per-minute product write rule, the full set is:
| Tier | Limit | Window | Window in human terms | Sustained rate it permits |
|---|---|---|---|---|
| Base | 30 | 60 s | 1 minute | 30 / min |
| Level 2 | 60 | 64 s | ~1 minute | 56 / min |
| Level 3 | 90 | 512 s | 8 min 32 s | 10.5 / min |
| Level 4 | 120 | 4,096 s | 1 h 8 min | 1.8 / min |
| Level 5 | 150 | 32,768 s | 9 h 6 min | 0.27 / min |
| Level 6 (token-keyed only) | 180 | 262,144 s | 3 days 49 min | 0.04 / min |
The per-IP rule stops at level 5 (max_level: 5 is the default); the per-token rule is built through throttle_by_params, which uses max_level: 6. That is where Gumroad's comment "Max: 150 requests/9 hours" comes from — but note that the comment describes the per-IP rule only. The per-token rule has one more rung above it, and on a long job that rung is stricter than anything Gumroad documents in a comment: 180 writes per three days.
Level 2 rarely binds — 56 a minute sustained is looser than the base rule — though it is not literally impossible to trip, because a 64-second window can straddle three 60-second base windows and admit more than 60 requests. Level 3 is the one that surprises people: stay under 30 a minute all you like — request 91 inside an eight-and-a-half minute window is still refused.
The windows are fixed, not rolling, and they are aligned to the Unix epoch rather than to your first request. cache.rb stamps @last_epoch_time = Time.now.to_i and then builds the counter key by integer-dividing it by the period:
["#{prefix}:#{(@last_epoch_time / period).to_i}:#{unprefixed_key}", expires_in]
So the level-3 counter resets at every multiple of 512 seconds since 1970, whoever you are. Two consequences: a burst that straddles a boundary can pass where the identical burst inside one window fails, and "wait a minute and try again" is sometimes wrong and sometimes far more conservative than necessary.
One more precision detail, from throttle.rb: the check is count > current_limit after incrementing. A limit of 30 means 30 requests succeed and the 31st is refused.
The counters may be narrower than they look
Read literally, these budgets are per product, not per catalog. Gumroad's helper builds the discriminator like this:
identifier = path.is_a?(Regexp) ? "#{request.path}:#{identifier}" : identifier
For rules declared with a string path, the key is just the IP. For rules declared with a regular expression — which includes every /v2/products/... rule — the request path is prepended. Elsewhere in the same file that behaviour is deliberate and documented: the comment on the post-comments rule reads "per post, per IP." Applied to PUT /v2/products/:id, the same mechanism yields a counter per product permalink per IP. It also means gumroad.com/api/v2/products/x and api.gumroad.com/v2/products/x carry separate counters, since the paths differ.
If that reading holds in production, repricing 200 different products touches 200 separate counters and none of the tiers above bind. Repeatedly editing one product is what trips the 30-per-minute rule.
I have not verified this against the live API, because doing so means deliberately hammering someone else's production service until it refuses. Treat it as what the source says, not as a budget to spend: pace as if the ceiling were global.
What a 429 actually looks like
Gumroad does not override Rack::Attack's default response, and it never enables the optional Retry-After header — a search across the whole repository for throttled_response_retry_after_header returns zero results, and the Gemfile pins rack-attack ~> 6.6 (6.7.0 in the lockfile). In that version's configuration.rb, the default is @throttled_response_retry_after_header = false, and the responder is:
[429, { 'content-type' => 'text/plain' }, ["Retry later\n"]]
So the response you should code against is:
HTTP/1.1 429 Too Many Requests
content-type: text/plain
Retry later
| What you might expect | What Gumroad sends | Evidence |
|---|---|---|
JSON body {"success": false, ...} |
Plain text Retry later\n |
rack-attack 6.7.0 default responder, not overridden |
Retry-After header |
Absent | throttled_response_retry_after_header defaults to false; zero repo hits |
X-RateLimit-Limit / -Remaining / -Reset |
Absent | Live response headers, api.gumroad.com, 27 Jul 2026 |
| 429 listed in the API error reference | Absent | Errors.tsx lists 200/400/401/402/404/5xx only |
| Readable headers from browser JavaScript | access-control-expose-headers is empty |
Live GET sent with an Origin header |
That last row is its own trap. Gumroad returns access-control-allow-origin: * but sends an empty access-control-expose-headers. Per the Fetch Standard, page JavaScript can only read the seven safelisted response headers (Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, Pragma) unless the server exposes more. Retry-After is not on that list. Even if Gumroad started sending it tomorrow, a script running on a normal web page could not read it. A browser extension with api.gumroad.com in host_permissions is not subject to that filter — which is the only reason a browser-side tool can read the header at all.
Two practical consequences:
- Don't call
response.json()before checking the status. A 429 body is not JSON, so a client that parses first throws a syntax error and reports something meaningless instead of "you're rate limited." - Capture the identifiers. Every live response carries
x-request-idandCF-RAY. Gumroad's own throttle logger, at the bottom ofrack_attack.rb, recordsCF-RAYandX-Amzn-Trace-Idon each blocked event. Quoting yourCF-RAYin a support ticket is the one string that maps to their log line.
Note also that RFC 6585 §4, which defines 429, only says a response "MAY include a Retry-After header." Gumroad omitting it is unusual, not non-compliant. And when a server does send it, RFC 9110 §10.2.3 allows either a delay in seconds or an HTTP-date — so parseInt() on that header is not a complete implementation.
Backoff strategies, compared against these rules
| Strategy | What it assumes | Against Gumroad's actual rules | Failure mode |
|---|---|---|---|
| Fire everything at once | No limit exists | Fine for offer codes today; refused after 30 writes to one product | A wall of 429s, and no record of which items landed |
| Fixed interval (e.g. one write / 2.4s) | A steady per-minute ceiling | Clears the base and level-2 tiers; still trips level 3 past ~90 writes in one window | Predictable, but degrades on long jobs |
| Exponential backoff on failure | Server will forgive you if you slow down | Correct shape, and it mirrors Gumroad's own tier ladder | Unbounded doubling can wait far longer than the 512s window actually requires |
Obey Retry-After |
Server tells you when to return | Gumroad never sends the header — you always fall through to your default | A tool built only on this path retries instantly and loops |
| Paced queue + bounded retry | Nothing; assumes the worst | Survives all tiers, reports what it couldn't finish | Long jobs take real time; some items may be reported failed |
The honest ranking for this API is the last row. Retry-After compliance is worth implementing — it costs nothing and protects you if Gumroad enables the header — but it cannot be your only mechanism, because today it never fires.
How long does a bulk run actually take
Take a pacing interval of 2.4 seconds per write, which is 25 writes a minute. The arithmetic is (N − 1) × 2.4 seconds of waiting, plus request time:
| Products | Wall-clock at 2.4 s/write | Tightest tier reached (if counters were global) |
|---|---|---|
| 25 | ~1 min | Base only, 25 of 30 used |
| 50 | ~2 min | Base only |
| 90 | ~3 min 34 s | Level 3 exactly at its 90-request limit |
| 120 | ~4 min 46 s | Level 3 exceeded — expect 429s and backoff |
| 200 | ~8 min | Level 4 exceeded — expect a long stall |
| 400 | ~16 min | Level 5 (150 / 9 h) and level 6 (180 / 3 days) both exceeded |
Those last rows are the real planning constraint under the conservative reading, and the tighter of the two is the one nobody quotes. No pacing interval makes a 400-item single-IP job fit inside one nine-hour window, because the constraint is a total, not a rate — but "run it again tomorrow" doesn't rescue it either. The token-keyed level 6 allows 180 writes per ~3 days, so 400 writes on one token is not a two-day job; it is closer to a week, unless the per-path reading in the previous section holds and each product carries its own counter.
The honest planning advice, then. Under ~150 products, one run finishes inside a nine-hour window — but "finishes" is not "runs smoothly": at 2.4 s a write you reach the level-3 limit around item 90 and stall until that 512-second window rolls over, so budget roughly one extra 512-second window per 90 items rather than the flat wall-clock above. Past ~180, spread the work over multiple three-day windows, or lean on the fact that these counters are very likely per product permalink — in which case editing 400 different products never approaches any of these numbers. Do not design a job that needs both readings to be wrong.
What this looks like in a tool
GumKit is an independent Chrome extension for Gumroad sellers — not affiliated with or endorsed by Gumroad — and its queue mechanics (2.4-second pacing, retry-the-same-item, bounded retries, resumable jobs) are already written up in catalog-wide price changes and bulk discount codes. Rather than repeat them, here are the two things this teardown actually changes about that description.
The Retry-After branch is dead code, and that matters. Its API client reads Retry-After on a 429 and falls back to 30 seconds when the header is missing. Because Gumroad never sends the header, the fallback is the only path that ever executes — every 429 wait is exactly 30 seconds. Earlier articles on this site describe that behaviour as "waits out the server's Retry-After," which is what the code intends but not what happens on this API. A fixed 30-second wait is a reasonable default against a 512-second window, but it is a default, not a negotiated delay.
The pacing number was aimed at the wrong limit. One write every 2,400 ms — roughly 25 a minute against an assumed ceiling of 30 — clears the per-minute rule with room to spare, and it predates this teardown. What it does not clear is level 3: a 512-second window at one write every 2.4 seconds holds more than 200 writes, against a 90-request limit. If the counters are per product permalink, that never comes up. If they are not, a job past ~90 items will collect 429s no matter how carefully the per-minute figure was chosen, and the bounded retries are what carry it through. That is the honest state of it, and it is an argument for the last row of the strategy table rather than a claim to have solved the problem.
It uses Gumroad's official API v2 with your own access token, stored only in your browser. No server, no scraping, no rate-limit bypass.
FAQ
What is Gumroad's API rate limit?
There isn't one number. Gumroad's reference publishes 30 PUTs/min and 60 previews/min, but only in its custom HTML landing page section. The enforcement code adds rules the docs don't mention: 10 product creations a minute, 20 media uploads per 10 minutes, and a ladder of longer-window ceilings on product writes — 150 per 9 hours on the per-IP rule, and 180 per 3 days on the per-token rule stacked above it.
Does Gumroad send a Retry-After header on a 429?
No. Gumroad uses rack-attack 6.7.0, whose Retry-After support is opt-in and defaults to off, and nothing in the repository enables it. You get a bare 429 with the plain-text body Retry later. Implement Retry-After handling anyway, but always have a default delay.
Are discount code writes rate limited?
No Rack::Attack rule in the deployed configuration matches /v2/products/:id/offer_codes for any method — the product rules use [^/]+ anchored at the end of the path, which can't match a sub-resource. That is not permission to flood the endpoint: Cloudflare fronts the API, and a rule could appear in any deploy.
Why did I get a 429 when I was clearly under 30 requests a minute?
Almost certainly a longer-window tier. Level 3 allows 90 requests per 512 seconds, which works out to about 10.5 a minute sustained. A job running at 25 a minute passes the per-minute rule for three and a half minutes and then hits the eight-minute ceiling.
Can I check how much of my limit is left?
No. The API returns no X-RateLimit-* headers on normal responses, and a browser page can't read non-safelisted headers anyway because access-control-expose-headers comes back empty. The only signal is the 429 itself.
Is any of this different in production from the open-source repo?
The live API's x-revision header resolved to a real commit in the public repository, and rack_attack.rb at that commit is byte-identical to main. That's strong evidence the deployed configuration is the public one. It is still a snapshot: check the file yourself before designing around a specific number.
The takeaway
Gumroad's rate limiting isn't undocumented so much as under-documented in one specific way: the published number describes a corner of the API most sellers never touch, while the rules that govern bulk work live only in source. Read them and the per-minute figure turns out to be the least important limit — the multi-hour and multi-day ceilings are what stop a large catalog job, and the 429 that announces one is plain text with no header telling you when to return.
Pace deliberately, retry the same item rather than the next one, bound your retries, and log the CF-RAY when something is refused. That is the whole discipline, and it's the difference between a bulk job that finishes and one that leaves half your catalog in an unknown state.
Get GumKit for Chrome
Bulk discount codes, PPP regional pricing, bulk price changes and customer CSV export for Gumroad — all using your own API token. Free, runs in your browser, no server.
More Gumroad guides
- Best Gumroad Chrome Extensions (2026): Every One I Could Actually Verify
- Export Gumroad Customers to CSV: Every Column, Field, and Gotcha (2026)
- Gumroad API Access Token: How to Create, Use, and Revoke It (2026)
- Gumroad Bulk Discount Codes: One Code, Every Product
- How to Change Prices on All Gumroad Products at Once (2026)
- Gumroad ConvertKit Integration: What Exists, and How to Move Buyers to Kit
- Gumroad Sales CSV Export Wrong Data? A Symptom-by-Symptom Diagnosis
- Gumroad Discount Code Not Working? Every Error String, Decoded
- Gumroad Fees Explained (2026): What You Actually Keep
- Gumroad PPP Pricing: How the Native Toggle Works vs Region Codes
- Gumroad Sales Tax & VAT: What Sellers Actually Owe (2026)
- Gumroad SEO: What Your Product Page Actually Sends to Google
- Gumroad vs Lemon Squeezy (2026): Which Should Digital Sellers Use?
- ParityDeals Alternative for Gumroad: 4 Options Compared