Gumroad Discount Code Not Working? Every Error String, Decoded
A buyer messages you: "Your code isn't working." You paste it into your own checkout and it works fine. Now what?
Most advice on this stops at "check the expiry date." That isn't enough, because Gumroad's checkout does not fail silently — it returns one of exactly seven specific strings, and each maps to a different condition. Two of them point you at the wrong cause: the message that says "has expired" is not about dates at all, and the message that says "is inactive" is a catch-all hiding three separate failures.
Gumroad's application is open source under the MIT license at github.com/antiwork/gumroad, so the discount validator is readable rather than guessable. Everything below is mapped from that source and from live calls against Gumroad's API v2. Every JSON response shown here is a real one captured from the API, not an illustration.
The seven strings a broken Gumroad code can produce
Six of these come from the checkout's discount validator, OfferCodesController#compute_discount. The seventh is raised later, at the moment of payment, in Purchase::CreateService.
| Exact message the buyer sees | Internal error_code |
What actually happened | What to change |
|---|---|---|---|
| "Sorry, the discount code you wish to use is invalid." | invalid_offer |
No live code with that string resolves for this product. Includes: code attached to a different product; universal code with this product in its exclusion list; fixed-amount code whose currency differs from the product's; a single-use code that already auto-deleted itself | Add the product to the code, or recreate the code as universal; match currencies for fixed-amount codes |
| "Sorry, the discount code you wish to use is inactive." | inactive |
valid_at is in the future or expires_at is in the past. On the product page only, this string is also what insufficient_times_of_use falls back to (see below) |
Clear or extend the end date; check the start date hasn't been scheduled ahead |
| "Sorry, the discount code you wish to use has expired." | sold_out |
Nothing to do with dates. The usage quota (max_purchase_count) is fully consumed |
Raise the quota, or issue a fresh code |
| "Sorry, the discount code you are using is invalid for the quantity you have selected." | insufficient_times_of_use |
Uses remain, but fewer than the quantity in the cart — e.g. 2 uses left, buyer wants 3 units | Raise the quota, or tell the buyer to reduce quantity |
| "Sorry, the discount code you wish to use has an unmet minimum quantity." | unmet_minimum_purchase_quantity |
Cart quantity is below the code's minimum_quantity |
Lower the minimum, or state it on the product page |
| "Sorry, this discount code is only for existing customers." | not_existing_customer |
The code is flagged existing_customers_only and the buyer either isn't signed in or doesn't own a qualifying product |
Tell buyers to sign in first; this code can never work for a logged-out visitor |
| "Sorry, you have not met the offer code's minimum amount." | (raised at payment) | Cart total across eligible items is below minimum_amount_cents. Not checked when the code is typed — only when Pay is pressed |
Lower the minimum, or bundle enough eligible items |
Two behaviours in that table are worth pausing on, because they send people hunting in the wrong place.
The message that says "expired" means "sold out"
In offer_codes_controller.rb, the :sold_out branch renders "Sorry, the discount code you wish to use has expired." The :inactive branch — the one that genuinely is about dates — renders "is inactive." The mapping is inverted from intuition:
- Buyer sees "has expired" → check the usage counter, not the calendar.
- Buyer sees "is inactive" → check the calendar.
inactive? is defined in OfferCode as valid_at&.future? || expires_at&.past?. Note the first half: a code scheduled to start later is "inactive" too, and produces the identical message as one that ended last week.
Single-use codes add a second twist. A code with max_purchase_count of exactly 1 is soft-deleted the instant its one sale commits — Purchase fires auto_delete_single_use_offer_code on create. Because the lookup only searches live codes, the second buyer doesn't get "has expired" at all. They get "is invalid", as though you'd never created the code. A quota of 1 and a quota of 2 produce different error messages for the same situation.
On a product page, "is inactive" can also mean "not enough uses left"
The cart shows the validator's error_message verbatim — Checkout/index.tsx passes it straight to showAlert — so all six strings can appear there.
The product page works differently, and it is worth being precise about how, because the difference is what makes buyer screenshots misleading. There is no discount box on a product page: the code can only arrive in the URL, and ProductProps resolves it server-side through BestOfferCodeService before the page is rendered. Product/index.tsx then branches on only three error codes — sold_out, invalid_offer, not_existing_customer — and falls through to "is inactive" for everything else:
Validator error_code |
Cart / checkout shows | Product page shows |
|---|---|---|
inactive (dates) |
"…is inactive." | "…is inactive." |
insufficient_times_of_use (uses remain, but fewer than the quantity) |
"…is invalid for the quantity you have selected." | "…is inactive." |
The minimum-quantity failure never reaches a product page at all: BestOfferCodeService evaluates the code with quantity: [@quantity, offer_code.minimum_quantity.to_i || 0].max, so it hands the validator a quantity that already meets the minimum. That failure only shows up once the buyer is in the cart with a real quantity.
So if a buyer screenshots "is inactive" from a product page, you cannot conclude anything about dates. Ask them to add the item to the cart and try there — the cart gives you the real reason.
One more source of a false negative, and it belongs to the cart. When the validation request itself fails, offer_code.ts returns a synthetic { error_code: "invalid_offer", error_message: "Something went wrong.", valid: false }, and because the cart prints error_message verbatim the buyer reports "Something went wrong" — which doesn't look like a discount problem at all. The endpoint is rate-limited: rack_attack.rb throttles /offer_codes/compute_discount to 60 requests per 30 seconds per IP, so buyers sharing an office or campus NAT during a launch can see a perfectly good code reported as broken.
Gumroad codes are not case-sensitive — but creating them is
This is the single most repeated piece of wrong advice about Gumroad discounts, and it's easy to check.
At purchase time, Purchase::CreateService resolves the buyer's input with product.find_offer_code(code: purchase.discount_code.downcase.strip). The typed string is lowercased and trimmed before lookup, and the offer_codes table — and therefore its code column — is declared utf8mb4_unicode_ci in db/schema.rb, a case-insensitive collation. Gumroad's own UI reinforces the point: the product page and the Discounts dashboard both render the code through .toUpperCase(), whatever case you stored it in.
SUMMER30, summer30 and Summer30 all resolve to the same code. Capitalisation is not your buyer's problem.
Creation is a different story, and this is the actual trap. For a code attached to specific products — which is what the API creates unless you pass universal=true — the only uniqueness check is code_validation in OfferCode, and it compares strings in Ruby (code == other.code) rather than in the database. Two codes differing only by case are therefore both accepted. Here is the live API accepting a case-variant duplicate on the same product after rejecting an exact one:
POST /v2/products/:id/offer_codes name=GKPROBE20 amount_off=5 offer_type=percent
→ { "success": false, "message": "Discount code must be unique." }
POST /v2/products/:id/offer_codes name=gkprobe20 amount_off=5 offer_type=percent
→ { "success": true, "offer_code": { "id": "ygt7…", "name": "gkprobe20", "percent_off": 5, … } }
You now have GKPROBE20 at 20% and gkprobe20 at 5% on one product. Every buyer types the same thing, and only one of those records will ever be found. If a discount comes out at the wrong percentage rather than failing outright, look for a case-variant twin first.
The currency floor: "$0 or at least $0.99"
Deep discounts on cheap products don't fail at checkout — they fail when you create the code, and Gumroad tells you exactly why. The rule in OfferCode#is_amount_valid? is that the price after discount must be either zero or at or above the currency's floor. Anything in between is rejected.
Here is that rejection, live, on a $5.00 product:
POST /v2/products/:id/offer_codes name=GKFAIL6 amount_off=99 offer_type=percent
→ { "success": false,
"message": "The price after discount for all of your products must be either $0 or at least $0.99." }
99% off $5.00 leaves $0.05, which is below the floor but above zero, so it's refused. 100% off the same product is accepted, because it lands exactly on zero. That non-monotonic behaviour surprises people: a bigger discount can be legal where a smaller one isn't.
The floors are per currency and vary a lot. They're published in config/currencies.json:
| Currency | min_price (raw) |
Minimum price after discount |
|---|---|---|
| USD | 99 | $0.99 |
| EUR | 79 | €0.79 |
| GBP | 59 | £0.59 |
| JPY | 99 | ¥99 |
| CAD | 99 | CAD$0.99 |
| AUD | 99 | A$0.99 |
| NZD | 99 | NZ$0.99 |
| SGD | 99 | SGD$0.99 |
| CHF | 100 | CHF 1.00 |
| PLN | 379 | zł3.79 |
| BRL | 533 | R$5.33 |
| ILS | 700 | ₪7.00 |
| HKD | 799 | HK$7.99 |
| ZAR | 1300 | ZAR 13.00 |
| CZK | 2149 | Kč21.49 |
| INR | 7300 | ₹73.00 |
| TWD | 2999 | NT$29.99 |
| PHP | 4500 | ₱45.00 |
| KRW | 111000 | ₩1,110.00 |
The spread matters if you price outside USD. A 90%-off code is trivially legal on a $20 product and illegal on a ₹500 one, because ₹50 sits under the ₹73 floor — so a deep purchasing-power discount can be impossible to express as a code in a high-floor currency. (The Gumroad PPP pricing guide covers region-based discounting.)
Fixed-amount codes also carry a currency_type; percentage codes don't. A fixed-amount code only applies where the currency matches — a €5-off code is invisible on a USD product, and the buyer gets "is invalid" with no explanation.
Creating codes through the API: the offer_type trap
If you script your codes, one default will bite you. Per the API reference, offer_type is optional and defaults to cents. Omit it while passing amount_off=100 intending "100% off", and you create a $1.00-off code instead:
POST /v2/products/:id/offer_codes name=GKPROBEDEF amount_off=100
→ { "success": true,
"offer_code": { "id": "rKDY…", "name": "GKPROBEDEF",
"max_purchase_count": null, "minimum_amount_cents": null,
"universal": false, "times_used": 0, "amount_cents": 100 } }
Note the response field: amount_cents, not percent_off. That's how you tell which one you actually made — the API returns percent_off for percentage codes and amount_cents for fixed ones, never both, and never echoes offer_type back.
The other thing to know is that failures come back as HTTP 200. Only authentication failures use a real status code, so a script checking response.status reports success on every validation error. Check success in the body instead. These are live responses:
| What you sent | HTTP | Body |
|---|---|---|
No amount_off or amount_cents |
200 | {"success": false, "message": "You are missing required offer code parameters. Please refer to https://gumroad.com/api#offer-codes for the correct parameters."} |
offer_type=percent, amount_off=150 |
200 | {"success": false, "message": "Please enter a discount amount that is 100% or less."} |
name=GK FAIL 3 (contains spaces) |
200 | {"success": false, "message": "Discount code can only contain numbers, letters, dashes, and underscores."} |
| Discount leaves a sub-floor price | 200 | {"success": false, "message": "The price after discount for all of your products must be either $0 or at least $0.99."} |
| Exact-case duplicate code | 200 | {"success": false, "message": "Discount code must be unique."} |
GET an offer code id that doesn't exist |
200 | {"success": false, "message": "The offer_code was not found."} |
DELETE a valid offer code |
200 | {"success": true, "message": "The offer_code was deleted successfully."} |
| Invalid access token | 401 | (empty body) |
That third row is the answer to "why did my code with a space in it never work" — it was never created. Codes accept letters, numbers, dashes and underscores only.
What the API can and cannot set
This is where most automation plans quietly break. The API v2 offer-code endpoints accept a much narrower set of fields than the dashboard at Checkout → Discounts — the page backed by Checkout::DiscountsController, whose permitted parameters are the full list — and PUT is narrower still: Api::V2::OfferCodesController permits only max_purchase_count and minimum_amount_cents on update. Sending a new name or amount_off to PUT returns success: true and changes nothing — verified live.
| Field | Dashboard (Checkout → Discounts) | API v2 POST |
API v2 PUT |
|---|---|---|---|
Code string (API name = dashboard code) |
Set at creation, immutable after | Yes | Ignored |
| Percentage amount | Yes | offer_type=percent + amount_off |
Ignored |
| Fixed amount | Yes | offer_type=cents + amount_off |
Ignored |
universal (all products) |
Yes | Yes | Ignored |
max_purchase_count (usage cap) |
Yes | Yes | Yes |
minimum_amount_cents (min order total) |
Yes | Yes | Yes |
valid_at / expires_at (start & end dates) |
Yes | No | No |
minimum_quantity |
Yes | No | No |
| Excluded products (universal codes) | Yes | No | No |
existing_customers_only |
Yes | No | No |
| Duration in billing cycles (memberships) | Yes | No | No |
The practical consequence: expiry dates cannot be set or read through the API at all, and they don't appear in the response object either. Any tool offering to schedule your Gumroad discounts is doing it outside the API or isn't doing it. And the code string itself is immutable everywhere — the dashboard's own update action drops :code before saving, so the UI can rename a discount's label but never the string buyers type. Fixing a typo means delete and recreate, which resets the times_used counter you may be reporting on.
One dashboard rule catches people scheduling sales: an end date with no start date is rejected outright with "The discount code's start date must be earlier than its end date."
Stacking, PPP, and the default discount that quietly wins
"Only one code per checkout" is close but not quite right. A Gumroad cart holds an array of discount codes. What can't happen is combining them: cartState.ts evaluates every code the cart carries against each line item and keeps the single one producing the lowest price. A second code that doesn't beat the first does nothing visible, which reads to the buyer as "the code didn't work."
The same "best price wins" rule governs the other two discount sources:
- Purchasing power parity. If PPP produces a lower price than the code, PPP is used and the code is discarded. At payment time this is explicit —
Purchaseapplies the PPP factor only when no offer code is attached. They never combine. - Default product discount. A product can carry a default offer code.
BestOfferCodeServicecomputes both the buyer's code and the default, then keeps whichever takes more off. If your default is 30% and you email a VIP a 20% code, the VIP silently gets 30% and their code looks ignored. Nothing is broken; the larger discount won.
Links that pre-apply a code — and the one that fails silently
Two URL forms work, both handled by links#show:
- Path form:
https://yourname.gumroad.com/l/product/LAUNCH30 - Query form:
https://yourname.gumroad.com/l/product?offer_code=LAUNCH30(the parameter?code=is accepted as an alias)
Sharing a pre-applied link removes typing errors and the "where do I enter this" problem at once, which makes it the right default for email and affiliate campaigns.
There is one exception. Adding ?wanted=true skips the product page and sends the buyer straight to checkout. The controller still evaluates the code on that path, but only forwards it if it validates. If it doesn't, the parameter is dropped and the buyer lands on checkout at full price with no error message at all. A broken ?wanted=true link doesn't look broken; it quietly stops discounting. Click one yourself and confirm the total actually dropped.
Where a tool helps, and where it does not
The causes above split into two groups. Gumroad's rules — currency floors, one-discount-per-item, the API's field limits — are fixed, and no extension changes them. The rest are consistency failures: a code added to nine products out of ten, a case-variant twin created by accident, a percentage that became a fixed amount because offer_type was omitted.
GumKit is an independent Chrome extension for Gumroad sellers that targets that second group. It is not affiliated with, endorsed by, or sponsored by Gumroad. The honest description:
- It creates one code with identical settings across every product you select in a single pass, so the nine-products-out-of-ten failure stops happening. It always sends
offer_typeexplicitly, so thecentsdefault can't silently convert your percentage. - It works through Gumroad's official API v2 with your own access token (here's how to generate one). The token is stored in your browser only — no GumKit server, no scraping, and you can revoke it at any time.
- Beyond codes it does bulk price changes (a fixed price or a ±% adjustment), purchasing-power-parity region presets, customer CSV export, and bring-your-own-key listing copy generation.
- Bulk jobs run in the background service worker with a Cancel button and keep running if you close the popup. The progress panel shows a running done/ok/failed count and names the most recent failures with Gumroad's own error message attached.
What it cannot do: change a code's amount or name after creation, set expiry dates, or push a discount below a currency floor. Those are platform limits, and any tool claiming otherwise is describing something Gumroad doesn't expose. GumKit is free.
For running one sale across a whole catalogue, see the bulk discount codes guide; for what a discounted sale actually nets you, see Gumroad fees explained.
FAQ
Why does Gumroad say my discount code "has expired" when it has no end date?
Because that string is the message for sold_out, not for dates. It means the code's usage cap (max_purchase_count) is fully consumed. The date-based failure produces a different string: "the discount code you wish to use is inactive." Check the usage counter, not the calendar.
Are Gumroad discount codes case-sensitive?
No. The buyer's input is lowercased and trimmed before lookup, and the database column uses a case-insensitive collation, so SUMMER30 and summer30 resolve identically. The catch is on the creation side: Gumroad will let you save two codes that differ only by case, and only one of them can ever be matched.
Why did my 90%-off code get rejected when I created it?
Because the price after discount landed between zero and the currency floor. Gumroad requires the discounted price to be either exactly zero or at or above the floor for that currency — $0.99 for USD, ₹73 for INR, £0.59 for GBP. A 90% discount on a $5 product leaves $0.50, which is refused. 100% off the same product is accepted, because it reaches zero.
Can a buyer combine two Gumroad discount codes?
No. The cart can hold several codes, but for each item Gumroad keeps only the one producing the lowest price. Discounts are never added together, and the same rule decides between a code, a purchasing-power-parity discount, and a product's default discount — largest wins, the others are discarded.
Can I set a discount code's expiry date through Gumroad's API?
No. valid_at and expires_at are dashboard-only fields; the API v2 offer-code endpoints neither accept nor return them. The API's PUT endpoint is limited to max_purchase_count and minimum_amount_cents — sending anything else returns success: true while changing nothing.
The takeaway
A Gumroad discount code not working is a diagnosable event, not a mystery, because the checkout names the failure. Get the buyer's exact string first, then translate it: "has expired" means the quota ran out, "is inactive" means the dates are wrong (or, on a product page, that the code ran short of uses), and "is invalid" means nothing live matches — which includes the single-use code that deleted itself after its one sale.
The recurring causes behind those strings are structural: fixed-amount codes that don't match the product's currency, discounts landing between zero and the currency floor, a case-variant duplicate created by mistake, and a bigger default discount quietly beating the code you handed out. None of those are bugs. They're rules, and every one is visible in the platform's own source and API responses if you know where to look.
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 API Rate Limit: What Actually Triggers a 429
- 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 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