Export Gumroad Customers to CSV: Every Column, Field, and Gotcha (2026)

Updated 2026-07-27 · GumKit guides

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_sourceutm_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:

  1. 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.
  2. Add the BOM yourself by re-saving as "UTF-8 with BOM" in a decent editor.
  3. 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.

And the honest limits:

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:

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.

Add to Chrome — it's free