DEV Community

Cover image for Buy in Poland, sell in France: a 110% margin that nets €4.61
Ahmed Jasarevic for Apify

Posted on

Buy in Poland, sell in France: a 110% margin that nets €4.61

The 110% Vinted arbitrage margin is really 9%

A four-market Vinted scan reported a 110.1% spread on one sneaker query. After five costs it was 9.2% — below the threshold I would act on, and one return eats two winners. Here is the arithmetic, the dataset, and the traps.

Disclosure: I built the Actor used in this post. The numbers are from a real run on 25 October 2026: 480 listings across PL, DE, IT and FR, ECB reference rates from 23 October. Everything below is recomputable from the dataset.


The number everyone screenshots

A Vinted scan returns a report that looks like this:

================ CROSS-COUNTRY ARBITRAGE REPORT ================
1. PL  162.99 PLN  (avg 37.09 EUR / median 37.89 EUR)  range 12-300  n=120
2. DE  71.27 EUR   (avg 71.27 EUR / median 50 EUR)     range 5-300   n=120
3. IT  76.92 EUR   (avg 76.92 EUR / median 50 EUR)     range 5-300   n=120
4. FR  77.92 EUR   (avg 77.92 EUR / median 55 EUR)     range 5-300   n=120
===============================================================
BUY in  PL  37.09 EUR
SELL in FR  77.92 EUR
Gross margin: 110.1% (before fees, shipping and Vinted's buyer protection)
Enter fullscreen mode Exit fullscreen mode

110.1%. Buy for €37, sell for €78, more than double your money.

The last line is the honest one, and it is the line everyone skips: before fees, shipping and buyer protection.

This post is the rest of the calculation. Not to kill the idea, but because I would rather have ten trades that make 25% than a spreadsheet full of trades that make minus six.

First problem: the report compares averages, and the average is not the market

Here is the same run at the median instead of the average.

Market Currency Average (EUR) Median (EUR) Range (EUR) n
PL PLN 37.09 37.89 12 - 300 120
DE EUR 71.27 50.00 5 - 300 120
IT EUR 76.92 50.00 5 - 300 120
FR EUR 77.92 55.00 5 - 300 120

The 110.1% comes from comparing the Polish average (37.09) to the French average (77.92). The medians tell a different story: 37.89 versus 55.00. That is a 45.2% spread, not 110.1%.

The gap between average and median is the shape of the distribution, not free money:

  • In PL the average sits below the median (37.09 vs 37.89), meaning a tail of very cheap listings drags the mean down.
  • In FR the average sits above the median (77.92 vs 55.00), meaning a tail of expensive listings pulls the mean up.

That €22.92 gap between the French mean and the French median represents €2,750 of value spread across 120 listings. It is a distribution, not a data error, and you cannot buy a distribution. You buy one item at one price, and you sell it for one price.

Notice something else in the table: DE and IT have identical medians (€50.00). The "third cheapest market" in the report is an artefact of the average, not a real price level. The report sorts markets by mean, which is exactly the statistic most distorted by outliers at both ends. Use it to pick candidates, never to size a trade.

Rule one: compare medians first, then pull P25 and P75 from the dataset. If the spread only exists between the means, it does not exist.

The five costs that eat the spread

Here is the same trade with the costs that Vinted, shipping and reality add. Everything below is in EUR, using the medians above and assumptions you can edit.

# Cost Who pays Size in this example
1 Vinted fee on the buy leg Buyer (you) ~5% + fixed, so €2.59 on €37.89
2 Inbound shipping to you Buyer (you) €4.50 (EU parcel, tracked)
3 Currency conversion You ~1.2% per converted leg, €1.11 total
4 Packaging + handling You €0.50
5 Risk reserve You 10% of buy price (returns, deadstock, price drift), €3.79

A few notes on each, because this is where most spreadsheets quietly cheat.

1. The Vinted fee. There are no selling fees on Vinted, which is why the fee is easy to forget: the buyer pays it. When you source, you are the buyer. Vinted describes it as typically a percentage plus a fixed amount, roughly 5% + a small fixed fee, varying by market. On a €38 item it is not the main cost. On a €380 item it is.

2. Inbound shipping. You pay to get the item to you. The buyer of your resale pays the label on the way out, so outbound shipping is not your cost, but packaging is. This asymmetry is why cheap, light items can work and bulky ones rarely do.

3. FX. The ECB reference rate is a mid-market rate. Your payment provider's rate is not. Vinted documents a 1.2% conversion fee on the USD/GBP pairs it handles directly; for other pairs the fee depends on your provider. Your bank may add its own on top. Converting in and out means you pay it twice.

4. Packaging. Small, real, and it compounds. A €0.50 mailer on a €38 trade is 1.3% of your capital, every cycle.

5. Risk reserve. This is the one people leave out because it does not appear on any invoice. Some items arrive not as described. Some do not sell for eight weeks while your money sits in them. Some sell after the market has moved down. A 10% reserve is not pessimism; it is the cost of doing this more than once.

The arithmetic

fee        = 37.89 * 0.05 + 0.70        =  2.59
fx         = (37.89 + 55.00) * 0.012    =  1.11
risk       = 37.89 * 0.10               =  3.79
packaging  =                               0.50
shipping   =                               4.50

all-in cost = 37.89 + 2.59 + 1.11 + 3.79 + 0.50 + 4.50 = 50.39
sell price  =                                            55.00
net         =                                             4.61
net margin  =                                             9.2%
Enter fullscreen mode Exit fullscreen mode

The trade that showed a 110.1% gross margin nets €4.61, or 9.2%.

That is not a catastrophe. It is also not a business. Two numbers fall out of it:

  • Break-even sell price: €50.39. You already need to clear the French median (€55.00) just to make €4.61. If the item sells at the median price of the cheapest French listing band, you lose money.
  • A 25% net margin needs about €62.99. In this run, that is above the median and somewhere in the upper quartile of the French market. If your "arbitrage" only exists at the median, you are buying a lottery ticket with extra steps.

And then the return. One SNAD return — item not as described, refund, return shipping, packaging, your time — costs roughly €9 on a trade this size. That is two winners to pay for one loser. At a 10% return rate, the expected net per trade drops from €4.61 to about €3.25. At 20%, it is under €2. The margin is not just thin; it is one bad batch away from zero.

Here is the same model as code, so you can drop your own numbers in:

const FEE_PCT = 0.05;
const FEE_FIXED = 0.70;
const FX_LEG = 0.012;
const PACKAGING = 0.50;
const RISK = 0.10;

export function netMargin({ buy, sell, inboundShipping }) {
  const fee = buy * FEE_PCT + FEE_FIXED;
  const fx = (buy + sell) * FX_LEG;
  const risk = buy * RISK;
  const cost = buy + fee + inboundShipping + fx + PACKAGING + risk;

  return {
    cost,
    net: sell - cost,
    netPct: (sell - cost) / cost,
    breakEvenSell: cost,
    sellFor25Pct: cost * 1.25,
  };
}

console.table(netMargin({ buy: 37.89, sell: 55.00, inboundShipping: 4.50 }));
// cost 50.39 | net 4.61 | netPct 0.092 | breakEven 50.39 | 25% target 62.99
Enter fullscreen mode Exit fullscreen mode

The real killer is not the fee. It is the border.

The model above assumes you can buy in Poland and sell in France. That assumption is doing more work than any of the five costs.

Your Vinted account is registered in one market. What you can buy and sell across borders depends on Vinted's international connections, the shipping options available to that pair of countries, item restrictions, weight limits and customs. Some connections are rolled out gradually and limited. On the connections Vinted documents publicly, electronics, cosmetics, jewellery and watches are commonly excluded or restricted, and those are exactly the categories with the widest spreads.

So in practice, "sell in FR" often means: have a French account, a French delivery address, French shipping options, and the ability to handle returns in French. That is an operations problem, not a data problem. It is also why the gross-margin number should be treated as a screening signal, not a business plan.

There is a second border that is not geographic: DAC7. If you complete 30 sales or earn more than €2,000 on Vinted in a calendar year, the platform reports your seller data to tax authorities. That does not automatically mean tax is due, but it does mean the hobby has a paper trail. Once you are buying to resell, you are probably a business, and the EU margin scheme for second-hand goods is the thing to read before you scale.

What actually survives the model

Running this across a few categories, four patterns hold up:

  1. Same-market mispricing beats cross-border arbitrage. A mispriced listing in your own market has no customs, no FX, no cross-border shipping and no language problem. The scanner finds those too; you just have to stop filtering for a country flag.
  2. High value-to-weight wins. Sneakers, jackets, small designer accessories. A €200 item that weighs 800g absorbs the same €4.50 shipping that destroys a €38 item.
  3. P25 buy to P75 sell. If the spread only exists median-to-median, the trade dies at the first cost. Require the sell-side target to sit above P75 before you even look at the item.
  4. Bundles amortise everything. One shipment, one fee, one packaging cost, several items. This is the only structural way to make low-priced categories work.

And the conclusion I did not expect: the data is worth more than the trades. If you already sell on Vinted, the same scan tells you which market your item is priced highest in, and what the top quartile looks like. You do not have to ship anything across a border to profit from that. Price at P75 in the market with the highest median, and you have captured most of the spread without the operations.

Five data traps to check before you trust a scan

  1. Asking prices are not sold prices. Vinted does not publish a public sold-price feed. Everything you scan is a listing, including the ones that will never sell. This is the single biggest limitation of any Vinted price dataset, mine included. Treat medians as an upper bound on clearing prices, not a forecast.
  2. Averages hide the tails. This run's headline 110.1% was mean-to-mean. The medians said 45.2%. Always recompute before you believe the report.
  3. Duplicates across markets. The same listing can surface in several markets. itemId is stable; dedupe on it before you compute anything.
  4. Condition mix. A market with more "satisfactory" items will look cheaper. In this run the French range starts at €5, which is either a different product, a damaged item, or a placeholder price. Filter by condition (status_ids) before comparing, or you are comparing two different products.
  5. Sample size. n=120 is enough to see a shape and not enough to trust a percentile. If the difference between two markets is smaller than the spread of either, you have measured noise.

How to run the scan

The Actor takes query, countries (up to five per run), maxItems per market, and optional filters. CROSS_COUNTRY mode returns the listings plus the per-market report.

{
  "mode": "CROSS_COUNTRY",
  "query": "Nike Jordan 4",
  "countries": ["pl", "de", "fr", "it"],
  "maxItems": 120,
  "minPrice": 5,
  "maxPrice": 300,
  "status_ids": ["new_with_tags", "very_good", "good"],
  "priceBandPartitioning": true
}
Enter fullscreen mode Exit fullscreen mode

Two things matter for data quality:

  • Price bands. Vinted caps how many results a single search pass returns. Setting maxPrice enables priceBandPartitioning, which splits the range into bands and sweeps each one, so a large query does not silently truncate at the cap.
  • Five markets per run. Run a second pass for the remaining countries and merge the datasets; itemId is your join key.

From code:

import { ApifyClient } from 'apify-client';

const client = new ApifyClient({ token: process.env.APIFY_TOKEN });

const run = await client
  .actor('ahmed_jasarevic/vinted-cross-country-arbitrage-scraper')
  .call({
    mode: 'CROSS_COUNTRY',
    query: 'Nike Jordan 4',
    countries: ['pl', 'de', 'fr', 'it'],
    maxItems: 120,
    minPrice: 5,
    maxPrice: 300,
  });

const { items } = await client.dataset(run.defaultDatasetId).listItems();
console.log(items.length, 'rows');

// quartiles per market. Replace RATES with the ECB reference rates for your run date.
const RATES = { EUR: 1, PLN: 0.2275, GBP: 1.17, CZK: 0.0405, SEK: 0.0895, DKK: 0.134 };

const pct = (arr, p) => {
  const sorted = [...arr].sort((a, b) => a - b);
  const i = (sorted.length - 1) * p;
  const lo = Math.floor(i), hi = Math.ceil(i);
  return sorted[lo] + (sorted[hi] - sorted[lo]) * (i - lo);
};

const byMarket = items.reduce((acc, row) => {
  (acc[row.country] ??= []).push(row.price * (RATES[row.currency] ?? NaN));
  return acc;
}, {});

for (const [country, prices] of Object.entries(byMarket)) {
  const clean = prices.filter(Number.isFinite);
  console.log(country, {
    n: clean.length,
    p25: pct(clean, 0.25).toFixed(2),
    median: pct(clean, 0.5).toFixed(2),
    p75: pct(clean, 0.75).toFixed(2),
  });
}
Enter fullscreen mode Exit fullscreen mode

At $1.50 per 1,000 results, 480 rows costs about $0.72. That is cheap enough to run weekly, which is the point.

A seven-day protocol

Do not act on a single scan. Do this instead:

  • Day 1. Baseline scan, fixed query, fixed filters, five markets. Save the raw dataset.
  • Days 2-7. Same input, once a day. Track the median per market, not the average.
  • Day 7. Keep only pairs where the P75 sell side clears your all-in cost by 25%, and where the gap has held for at least three days. Gaps that appear and vanish in 24 hours are usually one seller, not a market.
  • Then verify by hand. Open the top five candidates, check condition, photos, size, seller feedback and whether the item is still live. The scanner gives you candidates; it does not give you judgement.

What I would not do

I would not run this as a dropshipping business. The cross-border operational layer, the return risk and the sold-price blindness are all working against you, and no amount of scraping fixes any of them.

I would run it as a pricing tool. It answers a question every reseller actually has: what does this thing clear for, in which market, at the top of the range? That answer is worth more than a 110% screenshot.


The Actor used here is Vinted Cross-Country Arbitrage — 26 European markets, ECB-normalised prices, per-market medians and pairwise gross margins. The fee and FX assumptions above are documented in the post; change them before you make a decision.

If you want the version of this analysis with your own category, run one scan and drop the numbers into netMargin(). The result is usually more useful than the headline.

Top comments (1)

Collapse
 
lucas_him_f7e05624dd9cc96 profile image
Lucas Him •

The fixed-input seven-day protocol is the useful part here, especially deduping by itemId before comparing markets. BrowserAct Cloud is another option for scheduled web-data collection, but the condition and currency fields still need explicit checks. I would keep listing disappearance separate from a confirmed sale; otherwise the time series could imply a clearing price it never observed.