Gumroad Sales CSV Export Wrong Data? A Symptom-by-Symptom Diagnosis
You exported your sales, opened the file, and the numbers are wrong. The month is off, or the row count doesn't match the dashboard you were staring at ten seconds ago, or the filters you carefully set were ignored, or the file never arrived at all.
Searching for gumroad sales csv export wrong data is unusually frustrating because the top results contradict each other: Gumroad's help center describes what the export is supposed to do, while public bug reports on Gumroad's own issue tracker say it didn't. Both are real. One of those bugs was genuine, carried a bounty, and was fixed in January 2026 — and the symptom still appears today for a completely different reason that nobody documents.
This is a diagnostic guide, not a tutorial. For the mechanics of running an export, or the full 72-column field reference, see the Gumroad customer CSV export guide. Here we start from what you're seeing and work backwards. Gumroad's application is open source under the MIT license at github.com/antiwork/gumroad, so nearly everything below is checked against the code that produces the file rather than inferred from behavior.
Start here: symptom to cause
| What you're seeing | Actual cause | Fix | Verify it yourself |
|---|---|---|---|
| CSV covers roughly the last month, not the range you filtered the page to | The Export popover has its own date picker, defaulting to one month back. It does not inherit the page's date filter | Set the dates inside the Export popover before clicking Download | CustomersPage.tsx, useState(subMonths(new Date(), 1)) |
| Row count off by a few at the start or end of a month | Picker sends a bare calendar date; server reads it as UTC midnight. Your window is shifted by your UTC offset | Widen by one day at each end, then filter precisely in your spreadsheet | Gumroad help article 74; purchase_export_service.rb |
| Country / price / "active customers only" filters ignored | Only product and variant selection is forwarded to the export. Everything else is dropped | Filter after the fact in your spreadsheet | Export URL carries only 4 params |
| Products you excluded on the page are still in the file | The export forwards included product IDs only. Exclusions aren't sent at all | Invert the filter: include what you want | CustomersPage.tsx export href |
| Clicked Download, got a notice, no email | Over 2,000 rows the export is queued to a low-priority job chain and mailed later | Wait; check the inbox of the account that clicked, not the account owner | SYNCHRONOUS_EXPORT_THRESHOLD = 2_000 |
| Email arrived with a link that's now dead | Files over 10 MB become a presigned S3 link that expires after 7 days | Re-run the export | MailerAttachmentOrLinkService, ExpiringS3FileService |
| API export has more rows than the dashboard CSV | Bundle child purchases are excluded from the dashboard file, included by the API | Filter on is_bundle_product_purchase |
PurchaseSearchService args |
| Dashboard CSV has rows the API doesn't | Dashboard includes pre-order authorizations; the API scope excludes them | Check Pre-order authorization? |
NON_GIFT_SUCCESS_STATES vs for_sales_api |
| Same buyer appears many times | Subscription renewals are one row per charge, by design | Deduplicate on Purchase Email |
Gumroad help article 74 |
| Last row has no email and says "Totals" | It's a literal summary row appended to every dashboard export | Drop the final row before importing | TOTALS_COLUMN_NAME |
| Columns differ between two API exports | The API deletes null fields before serializing, so the column set follows your data | Build headers from the union of all rows | delete_if in purchase.rb |
| Non-English names are garbled in Excel | No byte-order mark in the dashboard file — covered in detail here | Import via Data → From Text/CSV as UTF-8 | — |
The rest of this article works through the ones that need more than a sentence.
Symptom: the CSV covers a different period than you selected
This is the single most common complaint, and in early 2026 there were two independent causes for it. Only one of them was a bug.
The bug: issue #2991, "Sales CSV export ignores selected date range", filed 20 January 2026. The report is blunt — "Sales CSV download on the /customers page currently exports all-time sales data instead of respecting the selected time range." It carried a $100 bounty label. PR #2992 explains the mechanism: the Export button issued a POST through Inertia with the filters in the request body, but file downloads fell back to a plain GET, and the body — and therefore every filter — was silently dropped. The server received nothing and returned everything. It was merged on 26 January 2026 and the export button now builds a GET with the parameters encoded in the query string. This one is fixed.
The other cause is not a bug and is still there. Open the Export popover in the Sales page and look carefully: it contains a date picker of its own, separate from the date filter on the page behind it. In CustomersPage.tsx, that picker is initialized like this:
const [from, setFrom] = React.useState(subMonths(new Date(), 1));
const [to, setTo] = React.useState(new Date());
Two consequences follow. First, the export defaults to the last one month — not all time, not your current view. Second, because these are useState initializers, they are set once when the page mounts and never re-sync with the page's own createdAfter / createdBefore filters. You can filter the page to January, open Export, click Download without touching the picker, and get June and July.
If your file covers the wrong period today, this is almost certainly why. The fix is mechanical: set the dates inside the popover, every time.
Symptom: the row count is off by a handful at month boundaries
This one is documented by Gumroad, but only as advice, without the arithmetic that would let you predict it. From the sales analytics dashboard help article:
Your Gumroad Analytics and Customers dashboards use your local time zone to display and filter orders. However, your CSV uses UTC time (Coordinated Universal Time). We recommend that you add a one-day buffer to the beginning and end of your date range before exporting to ensure that you capture all orders.
The same article repeats it against the Purchase Date column: "Your Customer and Analytics tab use your local time zone. This can sometimes make it seem like there are discrepancies between your dashboard and the CSV."
Here is why, and by exactly how much. The browser formats your picked dates with date-fns lightFormat(from, "yyyy-MM-dd") — a bare local calendar date with no timezone attached. The server then does this, in purchase_export_service.rb:
start_time = Date.parse(filters[:start_time]).in_time_zone("UTC").beginning_of_day
end_time = Date.parse(filters[:end_time]).in_time_zone("UTC").end_of_day
Your local calendar date is reinterpreted as a UTC calendar date. Nothing is converted; the label is simply re-read against a different clock. The result is that the exported window is your selected range shifted by your UTC offset — the same amount, in the same direction, every time. Offsets below are for July 2026, when northern-hemisphere daylight saving is in effect; the IANA Time Zone Database is the authority on what your offset is on any given date.
| Your zone (July 2026) | UTC offset | "1–31 July" becomes, on your clock | Sales you lose | Sales you gain |
|---|---|---|---|---|
| Los Angeles (PDT) | −07:00 | 30 Jun 17:00 → 31 Jul 16:59 | last 7h of 31 Jul | last 7h of 30 Jun |
| New York (EDT) | −04:00 | 30 Jun 20:00 → 31 Jul 19:59 | last 4h of 31 Jul | last 4h of 30 Jun |
| Reykjavík / UTC | ±00:00 | 1 Jul 00:00 → 31 Jul 23:59 | none | none |
| London (BST) | +01:00 | 1 Jul 01:00 → 1 Aug 00:59 | first 1h of 1 Jul | first 1h of 1 Aug |
| Berlin (CEST) | +02:00 | 1 Jul 02:00 → 1 Aug 01:59 | first 2h of 1 Jul | first 2h of 1 Aug |
| Delhi (IST) | +05:30 | 1 Jul 05:30 → 1 Aug 05:29 | first 5h30 of 1 Jul | first 5h30 of 1 Aug |
| Seoul / Tokyo | +09:00 | 1 Jul 09:00 → 1 Aug 08:59 | first 9h of 1 Jul | first 9h of 1 Aug |
| Sydney (AEST) | +10:00 | 1 Jul 10:00 → 1 Aug 09:59 | first 10h of 1 Jul | first 10h of 1 Aug |
Gumroad's own help text confirms the shape of this from the other direction: it notes that Eastern Standard Time is UTC−05:00, and therefore "the day ends at 7pm EST."
Two worked examples
A seller in Seoul (UTC+9). A buyer pays at 1 July 2026, 07:30 KST. Stored created_at is 30 June 2026, 22:30 UTC. The dashboard, rendering in your local zone, files it under 1 July. You export 1–31 July, and the server's window opens at 1 July 00:00 UTC — an hour and a half after the sale. The row is missing. Widen the range to 30 June and it appears, but its Purchase Date cell reads 2026-06-30, so it still looks like a June sale.
A seller in New York (UTC−4 in July). A buyer pays at 31 July 2026, 21:15 EDT = 1 August 2026, 01:15 UTC. The dashboard says 31 July. Your July export ends at 31 July 23:59:59 UTC, which is 19:59:59 EDT. The sale falls after the cutoff and reappears in your August export stamped 2026-08-01.
Negative offsets bleed off the end of the range; positive offsets bleed off the start. That asymmetry is why the advice is to buffer both ends, then filter precisely using the Purchase Time (UTC timezone) column converted to your own zone.
If you work through the API instead, one detail saves time: the JSON sale object carries created_at in UTC and daystamp, which purchase.rb builds with created_at.in_time_zone(seller.timezone). daystamp is the one field that already agrees with your dashboard.
Symptom: the filters you set on the page didn't apply
Gumroad's sales dashboard help article states this plainly:
Please note that any filters you applied to this page will not apply to the export.
That is close, but not accurate any more, and the inaccuracy runs in the direction that costs you time. Since PR #2992 the export link is built as a GET with parameters in the URL, and the component passes product_ids: includedProductIds and variant_ids: includedVariantIds — the page's product and variant selection. The controller then slices exactly four parameters, start_time, end_time, product_ids, variant_ids, and hands them to the export service. So some filters survive, and the help center says none do.
| Filter on the Sales page | Reaches the export? | Notes |
|---|---|---|
| Product / variant included | Yes | Forwarded as product_ids / variant_ids |
| Product / variant excluded | No | Exclusions are never sent; use inclusion instead |
Page date filter (createdAfter/createdBefore) |
No | The popover's own picker is what counts |
| Country | No | Dropped |
| Minimum / maximum price | No | Dropped |
| "Show active customers only" | No | Dropped |
| Search box (name / email) | No | Dropped |
The practical rule: product and variant filtering is the only thing worth doing before you export. Everything else is faster in the spreadsheet afterwards, and doing it in the UI quietly produces a file that doesn't match what's on screen.
Symptom: you clicked Download and no email ever arrived
At 2,000 rows or fewer the file is built inline and streamed straight to your browser — the branch condition is count <= SYNCHRONOUS_EXPORT_THRESHOLD, so exactly 2,000 still downloads directly. Above that, Exports::PurchaseExportService.export takes a different branch entirely, and the help center's one-liner — "If the CSV is not immediately available for download, it will be emailed to you" — hides a long pipeline. Knowing its shape tells you whether to keep waiting or just re-run.
The threshold is a constant: SYNCHRONOUS_EXPORT_THRESHOLD = 2_000, measured with an Elasticsearch count before anything is built. Over the line, a SalesExport record is created and CreateAndEnqueueChunksWorker takes over. From there:
- Purchases are scrolled out of Elasticsearch in batches of 1,000 (
MAX_PURCHASES_PER_CHUNK), each stored as its own chunk record and processed by a separate background job. All three workers run on the:lowqueue withretry: 5, so a busy queue means a slow export, not a lost one. - Chunks are stamped with the app's deploy revision. If a deploy lands mid-export,
chunks_left_to_process?re-enqueues the chunks built by the older revision. A big export that coincides with a Gumroad deploy genuinely does take longer. - Once the last chunk lands,
CompileChunksWorkerassembles the CSV, mails it, then deletes the chunks and destroys the export record. There is no export history and no retry surface — if the mail doesn't reach you, re-run from the dashboard.
Two things about the email itself appear nowhere in Gumroad's documentation:
Attachment or link, decided at 10 MB. MailerAttachmentOrLinkService sets MAX_FILE_SIZE = 10.megabytes. At or under that, the CSV is attached as sales_data.csv. Over it, the file is uploaded to S3 and the email carries a presigned link instead. ExpiringS3FileService sets DEFAULT_FILE_EXPIRY = 7.days. So a large export you find in your inbox three weeks later is a dead link by design — re-run it rather than hunting for the file.
It goes to whoever clicked, not to the account owner. The controller passes recipient: impersonating_user || logged_in_user. On a team account, the CSV lands in the inbox of the team member who pressed Download. The API export behaves differently: it sets recipient: current_resource_owner and returns recipient_email in the response, so that one goes to the account owner. If an export "never arrived," check the other inbox before re-running.
A nearby export path failed for a more prosaic reason. Issue #2092 reported that follower and subscriber CSVs from the /followers page simply never sent for large accounts. The diagnosis is unusually precise: no index supported both WHERE seller_id = ? and ORDER BY id, so MySQL fell back to a primary-key scan reading roughly 68 million of the table's 135 million rows — past the five-minute query timeout. Forcing a seller-leading index on production dropped it from a timeout to 4.7 seconds, and PR #3469 shipped that index on 9 February 2026. This is the audience export, not the sales export, and no API-based tool substitutes for it — Gumroad's public API documents no followers endpoint. Its Subscribers resource covers membership subscribers per product, which is a different list.
Symptom: the dashboard CSV and the API disagree on row count
These are two different queries against the same data, and they include different things. Neither is wrong. The field-by-field comparison — column sets, money format, page size, gift handling — is already tabulated in the customer CSV export guide. Only three of those differences actually move the row count, and those are worth spelling out.
Bundles. The dashboard export passes exclude_bundle_product_purchases: true to its search service; the API applies no such filter. Sell a bundle of four products and the dashboard gives you one row while the API gives you five — one parent plus four children. Summing the API's price without first filtering on is_bundle_product_purchase double-counts that sale.
Pre-order authorizations. The dashboard's state list is Purchase::NON_GIFT_SUCCESS_STATES, which is preorder_authorization_successful, successful and not_charged. The API's for_sales_api scope drops the pre-order state. On an account that takes pre-orders the gap therefore runs the other way — the dashboard file carries rows the API will never return, and the Pre-order authorization? column tells you which.
The exclusive end boundary. Ask the API for before=2026-08-01 and you get everything through 31 July, because the filter is a strict created_at < ?. Ask the dashboard for a range ending 1 August and 1 August is included, because the server takes end_of_day. Run both for "July" and the totals differ by a day's worth of sales.
The API rejects malformed dates rather than guessing. From sales_controller.rb, a bad value returns Invalid date format provided in field 'before'. Dates must be in the format YYYY-MM-DD. On a large account with a broad range, the paginated query is wrapped in a timeout defaulting to 15 seconds that surfaces as Query timed out. Try narrowing your date range with 'after'/'before' or filtering by product_id. Slice the range rather than retrying.
One endpoint worth knowing about: POST /v2/sales/exports exists in Gumroad's routes and controller, accepts from, to and product_id, always queues asynchronously, and responds with a status of queued plus the recipient email. It is not listed in the public API documentation alongside the other sales endpoints, so treat it as undocumented and subject to change rather than something to build a process on. Its date handling matches the dashboard export — inclusive at both ends — not GET /v2/sales.
Symptom: refunds are still in the file and the totals look wrong
Refunds are never removed from either export — the row stays and its flags flip. Refunded? is 1 for full or partial refunds, Fully Refunded? is 1 only for full ones, and the amount sits in Partial Refund ($). No revenue figure is a straight sum of Sale Price ($). The complete flag mapping across both exports, chargebacks and cancellations included, is tabulated in the customer CSV export guide; what Gumroad's fees actually cost you covers the Fees ($) column and the sales tax and VAT guide explains why the tax columns aren't your money to count.
Three more things that look like corruption and aren't:
- The last row is not a customer. Every dashboard export appends a summary row with the literal string
Totalsin the first cell and sums in the money columns. Pipe the file into an email tool and you get one contact with no address. Drop it. - A negative
Item Price ($)is a discount. Gumroad's help center: "if you see a negative number here, that's the amount that a customer received through a discount code." - Repeated buyers are usually renewals. Per the same article, on subscriptions "each subsequent charge will appear on its own line," marked
Recurring Charge? = 1. Deduplicate onPurchase Email— specifically that column, notBuyer Email, which is blank for guest checkouts.
Symptom: columns are missing, or different every time
The two exports fail in opposite directions.
The dashboard file has a fixed header of 72 columns, plus one extra per custom field defined across your products. It is stable between runs. If a column looks empty, the field genuinely wasn't populated — Buyer Currency and Buyer Total, for instance, are blank on every USD sale by design.
The API has no fixed shape at all. Before serializing, purchase.rb deletes every key whose value is nil, individually per sale. A subscription renewal carries subscription_id and recurring_charge; a one-off download carries neither. If your script builds its header from the first row, it will silently drop columns for every row after it. Build the header from the union of keys across all rows, or you'll lose data with no error to warn you.
What was actually broken, and what isn't
Because search results for this topic mix current docs with old bug reports, here is the status of the three issues people keep landing on. All three are closed.
| Issue | Title | Status | Resolution |
|---|---|---|---|
| #2991 | Sales CSV export ignores selected date range | Closed 26 Jan 2026 | Fixed by PR #2992 — export switched from POST-with-body to GET-with-query-params |
| #2092 | Followers / Subscribers CSV not sending due to timeout issue | Closed 9 Feb 2026 | Fixed by PR #3469 — added audience_members(seller_id) index |
| #100 | Export combined followers, customers and affiliates list from /followers page |
Closed 15 Apr 2025 | Shipped as the Export popover on /followers, deduplicated by email, emailed as an expiring link |
If your recollection of "the date range does nothing" comes from before late January 2026, that memory is accurate and the behavior is gone. If you are seeing it today, look at the Export popover's own date picker first.
A reconciliation routine that works
- Pick the range inside the Export popover, not on the page, and add a day at each end.
- Filter by product or variant before exporting. Do everything else in the spreadsheet.
- Delete the trailing
Totalsrow. - Convert
Purchase Time (UTC timezone)to your zone, then trim to the real boundary. This is the step that makes the CSV agree with the dashboard. - Subtract refunds using
Refunded?andPartial Refund ($)before totalling anything. - Deduplicate on
Purchase Email, after sorting by date so you control which purchase survives. - Comparing against the API? Remember the exclusive
before, and filter outis_bundle_product_purchaserows.
Where a tool actually helps
GumKit is an independent Chrome extension for Gumroad sellers — not affiliated with or endorsed by Gumroad. It doesn't fix any of the causes above, because most of them are boundary semantics rather than defects. Its full feature and limit inventory lives in the customer CSV export guide; the only thing worth adding in a diagnostic context is which symptoms it removes and which it inherits.
Because its export walks GET /v2/sales with a token you generate yourself, four rows of the table at the top stop applying. There is no export popover with its own date picker to forget. There is no page filter state that can silently fail to travel. The byte-order mark is written, so non-English names survive a double-click. And the header is built from the union of keys across every row, which is the correct answer to the API deleting null fields. Generating the token is covered in the Gumroad API access token guide.
It inherits the API's boundaries in exchange. From and To map straight to after and before, so the exclusive end still bites — to include 31 July, set To to 1 August. Pre-order authorizations sit outside the for_sales_api scope, so they will not appear in the file at all. Paging is capped at 500 requests, which at ten sales each is 5,000 sales; larger histories need date slices, or the dashboard export, which has no such cap. A 429 ends the run with no partial file, and the popup has to stay open while the CSV is assembled — both covered in that guide. It cannot export followers, because the public API has no followers endpoint.
If you export twice a year, the dashboard button is the right tool and needs no token at all.
FAQ
Why doesn't my Gumroad CSV match my dashboard?
Almost always the UTC shift. Your dashboard renders in your local time zone; the CSV window is your selected calendar dates read as UTC. The file is shifted by your exact UTC offset, so you lose sales from one edge of the range and gain them from the other. Add a one-day buffer at each end, then trim precisely using the Purchase Time (UTC timezone) column.
Does the Gumroad CSV export still ignore the date range?
No. That was issue #2991, caused by the export's filters being sent in a POST body that got dropped when the browser fell back to GET. PR #2992 fixed it on 26 January 2026. If you're still getting the wrong period, the cause is different: the Export popover has its own date picker, defaulting to the last month, that does not inherit the page's date filter.
Why did my filters not apply to the export?
Only product and variant selection is forwarded. The export URL carries exactly four parameters — start_time, end_time, product_ids, variant_ids. Country, price range, "active customers only", the search box, and any excluded products are dropped. Gumroad's help center says no filters carry over, which understates it: product and variant do.
My export email never arrived. What now?
Check three things. Whether the export exceeded 2,000 rows — at or under that it downloads directly and no email is sent at all. Which inbox — dashboard exports go to the user who clicked, so on a team account it may be a colleague's. And whether the file exceeded 10 MB, in which case the email holds a presigned link that expires after seven days rather than an attachment. There's no export history, so if none of those fit, re-run it.
Why does the API return more rows than the dashboard CSV?
Bundles. The dashboard export sets exclude_bundle_product_purchases: true; the API includes each bundled product as its own row flagged is_bundle_product_purchase. The dashboard file also includes pre-order authorizations, which the API's scope excludes — so the difference can run both ways on one account.
Can I export my followers through the API?
No. Gumroad's public API documents no followers endpoint, so the /followers page export is the only route to a follower list. Membership subscribers are a separate thing and are covered — the API's Subscribers resource exposes GET /products/:product_id/subscribers and GET /subscribers/:id — so don't read "no followers endpoint" as "no subscriber data." The timeout that stopped the /followers emails sending on large accounts was issue #2092, fixed with a database index in February 2026.
Bottom line
Most "wrong data" in a Gumroad sales CSV is not corruption — it's three boundaries that don't match your intuition. The export popover keeps its own date range and defaults to the last month. Your calendar dates are read as UTC, shifting the window by your offset. And only product and variant filters survive the trip from page to file.
The one genuine bug people still find in search results — the export ignoring the date range entirely — was real, was fixed in January 2026, and is no longer the explanation. Diagnose against the table above, buffer your range by a day at each end, and do the precise filtering in your spreadsheet where you can see exactly what you're cutting.
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 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