A new EU packaging rule made Etsy ask some sellers for an EPR number. The sellers' question was how much of their shop that touches. The answer sits in one CSV, and that CSV is full of other people's addresses. This post covers how I read it without sending it anywhere.
What the export looks like
Etsy's Order Items file has one row per item sold. The header includes Buyer, Ship Name, Ship Address1, Ship City and Ship Zipcode. So the file holds your buyers' names and home addresses.
Column order varies between years. I match columns by header name, not by position, and a test shuffles the columns to keep that true. The lookup is a map from normalised header to the real header:
const keys = new Map(fields.map((field) => [headerKey(field), field]))
const has = (name: string) => keys.has(name.toLowerCase())
const isItems = has('Transaction ID') && has('Listing ID')
Another test pins a real 2025 export. If someone drops the Orders file instead, the page says which Shop Manager path leads to the right one.
Parsing in the browser
The page hands the File object to papaparse. Without a download option, papaparse reads it through a FileReader, so the bytes never become a request. The only fetch in the source is a counter ping. Its body is {event, source}, plus one optional typed answer on a switch event, capped at 200 characters. The Worker (worker/index.ts) rejects any other field. I probed it with an extra country field and got a 400.
You can check this in the Network panel. Load the page, then drop a file. You will see two POSTs to the counter, view and parsed, at most 74 bytes each. The page also loads its own assets and may send a Cloudflare network error report. The claim I make is narrower: the contents of the file never leave the browser.
The parcel rule
An order with three items ships as one parcel. So packaging weights need two kinds of parts. Per-item parts, such as a filler pad, multiply by quantity. Per-order parts, such as the box, count once, and for a mixed order the biggest box wins. Orders are counted by distinct Order ID, not by rows.
One currency at a time
A shop that sells in USD, GBP and EUR cannot add the totals without choosing a rate. I did not choose one. Item sales stay in their own currency per country. The sum is a record keyed by currency code:
bucket.sales[row.currency] = (bucket.sales[row.currency] ?? 0) + row.cents
A test named "counts distinct orders and keeps currencies apart" holds that line.
What did not work
My plan described the CSV from second-hand sources: a vendor blog and two GitHub repos, one of them a hand-written test fixture. That plan was wrong twice. It had money with a currency symbol ($5.00) and order IDs with a #. Real exports have neither, and Date Shipped is MM/DD/YYYY while Sale Date is MM/DD/YY. I found this by parsing four real exports from public repos, dated 2017 to 2025. The parser now tolerates a # and accepts Date Posted as an alias, and a real 2025 export is pinned as a test fixture. I still have no export in a localised header language, and a file re-saved by Excel in a German locale fails with a message that names the date it cannot read.
Quoting the registers word for word
Germany, France and Spain each publish a sentence about what a seller must report. I copy each sentence verbatim, with the link and the date I copied it. A script fetches the sources and checks every quote. Eight of eight matched today: four German exact, and the French and Spanish PDF quotes after whitespace removal, because of line breaks. My translations are marked as mine.
The page
I wrapped this in a free page called eprkilo. You drop the Order Items file and the first screen shows what share of your orders went to the EU, with Germany, France and Spain marked as the countries where Etsy asks for an EPR number. The sample button shows 18.4% (9 of 49) on invented data. It does arithmetic and gives kilograms to three decimals. It does not say whether you must register. The source is public under MIT, and I built it.
Top comments (1)
the 74-byte ping is the detail that makes me trust this. anyone can verify it in the network panel, and a worker that 400s on unexpected fields is a guardrail with teeth. a no-upload claim is nice, but a verifiable no-upload claim is the whole game.