Export Gumroad Customers to CSV: Every Column, Field, and Gotcha (2026)
There are two ways to export your Gumroad customers to CSV, and almost every guide treats them as the same thing. They aren't. The dashboard button and the API produce files with different columns, different date boundaries, different row counts on the same account, and different behavior when your buyers have non-English names.
This guide is a reference for both: the exact columns the dashboard export writes, the exact fields GET /v2/sales returns, where the two disagree, how refunds and renewals appear, and the two file-level details — the missing byte-order mark and formula escaping — that decide whether your CSV opens cleanly or turns into mojibake.
First: "customers" on Gumroad means sales
Gumroad has no separate customer table. Your customer list is your purchase list, so every export gives you one row per purchase, keyed on an email that can repeat. A buyer who bought three products is three rows; a subscriber paying monthly for eight months is eight rows. Keep the model in your head: customers = sales, grouped by email.
Method 1: the dashboard export
The native path, per Gumroad's own help center: go to the Sales tab, click the Export icon, select a Date Range, and click Download. For a single product, click the Filter icon first, pick the product, then export (the sales analytics dashboard).
Three things about this export are documented but easy to miss.
Most of your on-page filters are ignored. Gumroad's help page for the sales dashboard states it plainly: "any filters you applied to this page will not apply to the export" (the sales dashboard). The export dialog itself contains nothing but a date range picker, and the download link it builds carries exactly four parameters — start_time, end_time, product_ids, variant_ids. So the one page-level filter that does survive is your product and variant selection, which is why the help center tells you to set it with the Filter icon before exporting. Country, price range, active-customers-only and license-seat filters are all dropped: filter the page to German buyers, hit Export, and you get every sale in the date range (CustomersPage.tsx, purchases_controller.rb).
The dates are UTC, your dashboard isn't. Gumroad's guidance: "Your Gumroad Analytics and Customers dashboards use your local time zone to display and filter orders. However, your CSV uses UTC time... add a one-day buffer to the beginning and end of your date range before exporting to ensure that you capture all orders" (the sales analytics dashboard). That advice exists because the two views genuinely disagree at the edges of a month.
Past a certain size the file is emailed, not downloaded. Gumroad's help text says "If the CSV is not immediately available for download, it will be emailed to you" (the sales dashboard). The threshold is in the open-source codebase: the export service defines SYNCHRONOUS_EXPORT_THRESHOLD = 2_000, and only exports at or under that count are built inline and sent to the browser. Anything larger creates a queued export job, and the dashboard responds with "You will receive an email in your inbox with the data you've requested shortly" (purchase_export_service.rb, purchases_controller.rb).
The dashboard CSV columns worth knowing
The export writes a fixed header row of 72 columns, plus one extra column per custom field you've defined on your products. Most are self-explanatory. These are the ones people misread:
| Column header | What it actually contains | Source |
|---|---|---|
Purchase ID |
Gumroad's obfuscated purchase ID — the same value the API returns as id |
purchase.external_id |
Order Number |
A numeric obfuscated ID, different from Purchase ID — the API calls it order_id |
purchase.external_id_numeric |
Product ID |
The permalink (e.g. drIB), not the API's product ID |
purchase.link.unique_permalink |
Purchase Email |
The email used at checkout | purchase.email |
Buyer Email |
The buyer's Gumroad account email — blank if they checked out without an account | purchase.purchaser.try(:email) |
Do not contact? |
1 means the buyer opted out. Gumroad suppresses them internally; if you import the list elsewhere, you must remove them yourself |
can_contact? inverted |
Purchase Date / Purchase Time (UTC timezone) |
UTC, always — not your dashboard's timezone | created_at |
Refunded? vs Fully Refunded? |
Refunded? is 1 for full or partial refunds; Fully Refunded? is 1 only for full ones |
stripe_refunded, stripe_partially_refunded |
Recurring Charge? |
1 marks a subscription renewal rather than the first payment |
is_recurring_subscription_charge |
Buyer Currency / Buyer Total |
Populated only when the card was charged in the buyer's own currency; blank for USD sales. Every other money column stays in USD | presentment fields |
Totals |
A trailing row, not a customer. The last row of the file carries the string Totals in the first cell and column sums in the money columns |
TOTALS_COLUMN_NAME |
That last one bites people who pipe the file straight into an email tool or a script: the final row has no email address and a literal Totals where the Purchase ID should be. Drop it before importing.
Method 2: the API — GET /v2/sales
Gumroad's public API exposes the same data as JSON, and it is richer than the dashboard file in some places and thinner in others. Authentication is a personal access token you generate yourself under Settings → Advanced → Applications (creating an application); we walk through that in detail in the Gumroad API access token guide.
A minimal call:
curl "https://api.gumroad.com/v2/sales?access_token=YOUR_TOKEN&after=2026-01-01"
The response is { "success": true, "sales": [ ... ], "next_page_key": "...", "next_page_url": "..." }.
Field reference for a sale object
Every sale is serialized by the same code path the whole platform uses, with version: 2. One structural rule governs the whole payload: null fields are deleted before the JSON is built (delete_if { |_, v| v.nil? } in purchase.rb). A sale object therefore does not have a fixed shape — a subscription renewal carries keys a one-off download never will.
| Field | Type | Meaning |
|---|---|---|
id |
string | Obfuscated purchase ID; equals the dashboard's Purchase ID |
order_id |
number | Numeric obfuscated ID; equals the dashboard's Order Number |
created_at |
ISO 8601 string | UTC, no fractional seconds (JSON time precision is set to 0) |
timestamp |
string | Human text such as "3 days ago" — not parseable as a date |
daystamp |
string | Formatted in the seller's timezone, e.g. "4 Jun 2026 11:15 AM" |
email |
string | Buyer's account email if they have one, otherwise the checkout email |
purchase_email |
string | The checkout email, always |
full_name |
string | Name given at checkout, falling back to the account name |
product_id |
string | Product's external ID — not the permalink |
product_permalink |
string | The permalink; this is what the dashboard CSV calls Product ID |
product_name |
string | Product title at time of export |
price |
integer | Cents. 2900 is $29.00 |
gumroad_fee |
integer | Fee in cents |
formatted_display_price / formatted_total_price |
string | Pre-formatted, currency symbol included |
refunded |
boolean | Fully refunded |
partially_refunded |
boolean | Partially refunded |
chargedback / disputed / dispute_won |
boolean | Chargeback state |
subscription_id |
string | Present only on membership sales |
recurring_charge |
boolean | Present with a subscription; true on renewals, false on the first charge |
is_recurring_billing |
boolean | Whether the product is a membership |
variants |
object | Category → variant name map |
custom_fields |
object | Your custom field name → value map |
offer_code |
object | { code, displayed_amount_off, name } — present only if a discount was used |
affiliate |
object | { email, amount } — present only on affiliate sales |
card |
object | { visual, type, bin, expiry_month, expiry_year }; the last three are legacy and always null |
ppp |
object | { country, discount } — present only on parity-discounted sales |
discover_fee_charged |
boolean | Whether this sale was billed at the marketplace rate |
can_contact |
boolean | Inverse of the dashboard's Do not contact? |
utm_source … utm_content |
string | UTM attribution, when a tracked link was used |
tax_cents, shipping_cents, tip_cents |
integer | Cents |
license_key, license_uses, license_disabled |
mixed | Present only for licensed products |
quantity |
integer | Always present |
That is not the complete list, but it is the part that matters for a customer export. The full serializer is public if you want to audit it line by line.
Pagination: page_key, ten rows at a time
This is the single biggest practical constraint on the API, and it is not obvious from the docs. In sales_controller.rb, RESULTS_PER_PAGE = 10. Ten sales per request. A 3,000-sale account is 300 round trips.
Pagination is cursor-based. When more results exist, the response includes next_page_key, and you pass it back as page_key. The cursor is a timestamp plus an obfuscated row ID joined by a hyphen, so it looks like 20260604111503123456-8412345678. Results are ordered created_at DESC, id DESC, and the next page is fetched with created_at <= ? and id < ?, which is why the cursor carries both halves.
The older page parameter still exists but is deprecated: past page 10 it is separately rate-limited, and the controller will hand back an explicit error pointing you at page_key if the query times out.
Where the two exports actually differ
| Dashboard export | GET /v2/sales |
|
|---|---|---|
| Delivery | Direct download up to 2,000 rows, emailed above that | JSON, 10 rows per request |
| Start of range | Selected start date at 00:00 UTC — inclusive | after date, created_at >= — inclusive |
| End of range | Selected end date at end_of_day UTC — includes that day |
before date, created_at < — excludes that day |
| Purchase states included | successful, not_charged (free trials), and pre-order authorizations |
successful and free-trial not_charged only |
| Bundle child rows | Excluded (exclude_bundle_product_purchases: true) |
Included, flagged is_bundle_product_purchase |
| Column set | Fixed 72 headers + your custom fields | Varies per row; null keys are dropped |
| Money format | Dollars (Sale Price ($)) |
Cents (price) |
| Product identifier | Permalink | External ID, with the permalink in a separate field |
| Extra filters | Product and variant IDs | Product ID, email, name, order ID, license key |
Trailing Totals row |
Yes | No |
| Byte-order mark | No | n/a (JSON) |
The date boundary row is the one that quietly corrupts monthly reports. Ask the API for before=2026-07-01 and you get everything up to 30 June inclusive, because the filter is a strict <. Ask the dashboard for a range ending 2026-07-01 and 1 July is included. Run both for "June" and the numbers won't match, and neither export is wrong.
Refunds, chargebacks, and subscription renewals
None of these are removed from your export. The API's sale scope filters on purchase state only — there is no refund exclusion in it — so a refunded sale stays in the list with its flags flipped.
| Event | In the API | In the dashboard CSV |
|---|---|---|
| Full refund | refunded: true |
Refunded? = 1, Fully Refunded? = 1 |
| Partial refund | partially_refunded: true; amount_refundable_in_currency holds what is still refundable, not what was refunded |
Refunded? = 1, Fully Refunded? = 0, and the refunded amount in Partial Refund ($) |
| Chargeback | chargedback: true, disputed: true |
Disputed? = 1 |
| Chargeback reversed in your favor | dispute_won: true |
Dispute Won? = 1 |
| Subscription renewal | A separate row, same subscription_id, recurring_charge: true |
A separate row, Recurring Charge? = 1 |
| Membership cancelled | cancellation_date, subscription_end_date |
Cancellation Date, Subscription End Date |
| Free trial, not yet charged | Included (state not_charged) |
Included, Free trial purchase? = 1 |
So "how much did I actually make in June" is never a sum of the price column. You need to subtract refunds, and the platform cut sits in gumroad_fee / Fees ($) — see what Gumroad's fees really cost you for the arithmetic, and what sellers owe on sales tax and VAT for why the tax columns are not your money.
Why your CSV opens as mojibake — the BOM
If you export a customer list containing Korean, Japanese, Chinese, Cyrillic, or accented Latin names and open it by double-clicking in Excel on Windows, the names often come out as garbage. This is not a Gumroad bug and it is not corrupted data. It's a missing three-byte prefix.
A UTF-8 file can start with EF BB BF, the encoded form of U+FEFF. Per the Unicode Consortium, "an initial BOM is only used as a signature — an indication that an otherwise unmarked text file is in UTF-8" (UTF-8 FAQ). The WHATWG Encoding Standard formalizes the decoder side: sniff the first bytes, and if they are EF BB BF, decode as UTF-8 and strip them; with no BOM, fall back to whatever default the application was configured with. On Windows that fallback is typically the system code page, which is exactly how 김민준 becomes 김민준.
Gumroad's export writes a plain UTF-8 temp file with the header row first — no BOM anywhere in the export path. The file is correct UTF-8 that Excel has no signal to recognise. Three fixes, in order of effort:
- Don't double-click it. In Excel use Data → From Text/CSV and set the file origin to UTF-8. Google Sheets and Numbers detect UTF-8 without a BOM and are unaffected.
- Add the BOM yourself by re-saving as "UTF-8 with BOM" in a decent editor.
- Use an export that writes the BOM in the first place.
One related quirk: the dashboard export passes four address columns (Street Address, City, State, Country) through a transliterator before writing, so accented Latin is flattened to ASCII — São Paulo becomes Sao Paulo — while scripts with no ASCII equivalent are left alone. Names and emails are untouched.
The other file-level detail: formula injection
A CSV cell beginning with =, +, -, or @ is treated as a formula by every major spreadsheet. Since buyer names and custom field values are attacker-controlled free text, an export that writes them raw is a way to get code running in the recipient's spreadsheet.
Both Gumroad and GumKit defend against this, slightly differently.
| Gumroad dashboard export | GumKit CSV export | |
|---|---|---|
| Neutralizing technique | Prefix the cell with ' |
Prefix the cell with ' |
| Trigger characters | = @ | % \r \t + - |
= + - @ |
| Leading whitespace | Checks the first character only | Checks the first non-whitespace character (/^\s*[=+\-@]/) |
| Numeric exception | -12.5 and +3 are left alone |
No exception |
| Source | csv_safe.rb | popup.js toCSV() |
Gumroad covers a wider prefix set; GumKit catches the leading-space variant (" =HYPERLINK(...)") that spreadsheets trim before evaluating. Neither is a substitute for not opening exports from untrusted sources, but both mean a hostile buyer name won't quietly execute when you open your own customer list.
Method 3: GumKit's one-click export
GumKit is an independent Chrome extension for Gumroad sellers — not affiliated with or endorsed by Gumroad. Its CSV tab does one thing: walk GET /v2/sales to the end and hand you the file. Because it sits on the same API described above, its behavior is predictable, limits included.
- Your own token, no server. The token lives in
chrome.storage.localand is sent only toapi.gumroad.com. No GumKit backend, no scraping, no DOM automation. - Read-only. The export path calls one endpoint,
GET /v2/sales. Nothing on your account is written. - Optional From/To dates map straight to
afterandbefore, so the exclusive end boundary applies here too: to include 30 June, set To to 1 July. - Paced pagination — 350 ms between pages rather than hammering the endpoint.
- The BOM is written. The download is assembled by prepending U+FEFF to the CSV text and wrapping the result in a
text/csv;charset=utf-8Blob, so Excel identifies it as UTF-8 and Korean or accented names survive a double-click. - Columns are the union of every key across your rows. Because the API drops null fields, the header reflects your actual catalogue: no
subscription_idcolumn if you sell no memberships. - Nested objects are JSON-stringified into one cell.
card,offer_code,affiliate,variants, andcustom_fieldsland as{"code":"LAUNCH20",...}rather than being flattened into extra columns. - The file is named
gumroad-customers-YYYYMMDD.csv.
And the honest limits:
- A hard ceiling of 500 pages, which at ten sales per page is 5,000 sales. Beyond that the loop stops and you get the first 5,000 rows, newest first. Larger histories need date slices — or the dashboard export, which has no such cap.
- 500 pages of pacing is roughly three minutes of deliberate sleeping before network time. The popup shows "Loading sales data… (large accounts can take a while)" throughout, and it has to stay open. The paging runs in the background service worker, but the CSV is assembled and saved by the popup itself, so closing the popup mid-run throws the export away and you start over. (GumKit's bulk price and discount jobs are the ones that survive a popup close; the CSV export is not one of them.)
- A rate limit ends the run. An HTTP 429 mid-export surfaces as
Failed: Gumroad rate limit (429)and produces no partial file. Retry with a narrower range. - Status strings are literal:
0 sales (no sales in this range/account).on an empty window,Done: exported N sales (P pages).on success.
If you're already in the extension for bulk price changes, the export is one more tab. If you export twice a year, the dashboard button is fine and needs no token.
Turning sales into a unique customer list
Whichever export you used, you now have one row per purchase. To get one row per person:
- Google Sheets:
=UNIQUE(B2:B)on the email column, or Data → Data cleanup → Remove duplicates. - Excel: select the email column, then Data → Remove Duplicates.
Two cautions. Deduplicate on the checkout email (purchase_email in the API, Purchase Email in the dashboard file) — the other email column is the buyer's account email and is blank for guest checkouts, so deduplicating on it silently merges unrelated people into one blank-keyed row. And Remove Duplicates keeps the first occurrence, so row order decides which purchase you keep — and the two exports don't agree on order. The API returns created_at DESC, id DESC, newest first, so a GumKit or script export keeps each customer's most recent purchase. The dashboard export is written with Rails' find_each, which walks the matched rows in ascending purchase-ID order, so that file is roughly oldest-first and dedupe keeps the earliest purchase instead. Sort by date yourself before deduplicating rather than trusting the order you were handed.
A word on privacy
Your export is a file of other people's personal data: names, emails, often street addresses. Don't mail it around or park it in a shared drive, and delete old copies. Honor the Do not contact? column too — Gumroad suppresses those buyers on its own side, but as its help center notes, if you import the list into your own email provider you have to remove them yourself.
FAQ
How do I export my Gumroad sales to a CSV file?
Open the Sales tab, click the Export icon, choose a Date Range, and click Download. Under about 2,000 rows the file downloads immediately; above that Gumroad queues it and emails it to you. Of the filters on the page itself, only your product and variant selection is carried into the export — country, price range and the rest are dropped.
Why don't my CSV totals match my dashboard?
Almost always timezones. Your dashboard displays orders in your local timezone; the CSV is UTC. Gumroad's own recommendation is to add a one-day buffer at each end of the range. If you're comparing the dashboard export against the API, there's a second cause: the API's before parameter is exclusive and the dashboard's end date is inclusive.
Does the export include refunded sales?
Yes. Refunds are never removed from either export — the row stays and its flags change. The API sets refunded or partially_refunded; the dashboard file sets Refunded? (full or partial) and Fully Refunded? (full only). Any revenue figure you compute has to subtract them explicitly.
How do subscription renewals appear?
As separate rows, one per charge, sharing a subscription_id. In the API the renewal rows carry recurring_charge: true; in the dashboard CSV they carry Recurring Charge? = 1. Deduplicate by email if you want subscribers rather than charges.
Why are Korean or accented names garbled in Excel?
The dashboard export is valid UTF-8 with no byte-order mark, and Excel on Windows falls back to a legacy code page when it finds no BOM. Import via Data → From Text/CSV with the origin set to UTF-8, or re-save as "UTF-8 with BOM". Google Sheets and Numbers are unaffected. GumKit's export writes the BOM, so it opens correctly on a double-click.
Can I get more than 10 sales per API request?
No. RESULTS_PER_PAGE is fixed at 10 in Gumroad's sales controller and there is no page-size parameter. Large exports mean many paged requests using page_key, which is why the dashboard's queued email export remains the better tool for very large histories.
Is it safe to use a Chrome extension for this?
It depends on the architecture. Ask three things: does it use the official API or scrape your dashboard, does it hold your token on someone else's server, and does your customer list ever leave your machine? GumKit uses the official API with a token you generate, stored in chrome.storage.local, and its export path is read-only with no backend. Check any extension's permissions before installing it.
Bottom line
Exporting your Gumroad customers to CSV is easy. Exporting them correctly means knowing three things: the dashboard export is UTC and ignores your on-page filters, the API is ten rows per request with an exclusive end date, and the file has no byte-order mark until something adds one.
For an occasional pull, the dashboard button is the right answer and needs no setup. For a repeatable, machine-readable pull that opens cleanly in any spreadsheet, the API path — through your own script or through GumKit — gives you the fuller field set and a file that doesn't need repair. Either way, the data is yours, which was always the point.
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
- 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 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