Gumroad API Rate Limit: What Actually Triggers a 429

Updated 2026-07-27 · GumKit guides

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:

  1. 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."
  2. Capture the identifiers. Every live response carries x-request-id and CF-RAY. Gumroad's own throttle logger, at the bottom of rack_attack.rb, records CF-RAY and X-Amzn-Trace-Id on each blocked event. Quoting your CF-RAY in 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.

Add to Chrome — it's free