Every Counter-Strike 2 skin has multiple prices at the same time. The AWP you can list on Steam for $60 has no fixed price anywhere — it has a Steam price, a Skinport price, a CSFloat price, and a Buff163 price, and they disagree with each other constantly. The disagreement is not noise. It’s a structural gap created by three very different fee schedules, three different buyer pools, and one infamous restriction: Steam wallet money can’t leave Steam.
This post is the complete playbook for trading that gap: the exact fee math (including the fee almost everyone calculates wrong), a working Python scanner that pulls live prices from both public APIs and flags spreads over 10%, the cash-out problem that kills naive versions of this strategy, and the risk list that separates people who make a few hundred dollars doing this from people who lose their inventory.
Why the price gap exists (and why it never closes)
Three marketplaces, three completely different economics:
| Marketplace | Seller fee | Payout | Speed to cash |
|---|---|---|---|
| Steam Community Market | ~15% (varies by item) | Steam wallet credit only | Instant, but locked |
| Skinport | 8% (6% above €1,000) | Bank transfer, no payout fee | 1–3 days |
| CSFloat | 2% | Bank/PayPal after trade completes | Days, trade-gated |
The 15% Steam fee means Steam prices float structurally higher than third-party marketplace prices. Every seller on Steam is pricing in a 15% haircut, so a skin that nets you $52 on Steam lists at $60. The same skin might list at $43 on Skinport, because Skinport’s fee is 8% and their sellers can actually withdraw the money — they’re willing to accept less to get real cash.
That’s the core of the spread: buy where cash-out is easy and cheap (Skinport, CSFloat), sell where the captive buyer pool pays a premium (Steam). Steam buyers spend wallet money they can’t withdraw anyway — gift cards, refunds, marketplace sales — so they pay a premium over “real money” price. You’re arbitraging captive money against free money.
And the gap never fully closes because moving an item between the two sides takes a trade hold: trade up to 15 days if your Steam Guard Mobile Authenticator isn’t active, and even with a 15+ day-old authenticator, holds of up to 48 hours can still apply. You can’t instantly flatten a spread the way a stock arbitrageur can — which is exactly why the spread survives.
The fee math everyone gets wrong
Ask anyone what Steam takes and they’ll say “15%.” The real formula is weirder. Steam takes a 5% transaction fee plus a game-specific fee (10% for CS2 items), with a $0.01 minimum on each — so the payout is:
seller_receives = floor(price / 1.15) - $0.01 (for items over a few dollars)
That’s from Valve’s own community documentation, and fee calculators like the ones on steamtradebots.com and virtwave.com are built on exactly this formula. On a $60 sale: $60 / 1.15 = $52.17, minus a penny = $52.16 net. The effective fee rate on small items is actually worse than 15% because of the flat penny; on large items it converges to 15%.
Skinport is a flat 8% seller fee, 6% for items above €1,000, with no buyer fee and — per their FAQ — no payout fees to your bank account. CSFloat charges 2% seller fee, which is why professional skin traders treat it as the clearinghouse; note some sellers report an extra ~1–2% currency conversion cost on CSFloat payouts, which matters if you’re not cashing out in USD.
The scanner: live prices, real math
Everything above is useless without a way to find the spreads at scale. Skinport exposes a free public API with no key required — 25,000+ CS2 items with current min/mean/median prices. Steam exposes priceoverview per item. The script below pulls both, applies the correct net-price formulas, and ranks items by “how much more can I net by selling on Steam instead of Skinport.”
Two things worth knowing before you run it:
- The Skinport API requires Brotli compression support (
Accept-Encoding: br) or it rejects you. That’s why the script importsbrotli. - Steam’s
priceoverviewendpoint is aggressively rate-limited — hit it fast and you get HTTP 429. The script spaces requests 6 seconds apart and backs off 20 seconds on a 429. That’s not paranoia, it’s the difference between getting data and getting nothing.
#!/usr/bin/env python3
"""Cross-market CS2 skin spread scanner: Steam Market (net after 15% fee)
vs Skinport (net after 8% fee). Uses live prices from both public APIs.
Requires: pip install brotli"""
import json
import time
import urllib.error
import urllib.parse
import urllib.request
import brotli # Skinport API rejects clients without Brotli support
UA = {"User-Agent": "curl/8.5.0", "Accept-Language": "en-US,en;q=0.9"}
MIN_PRICE = 30.0 # skip sub-$30 items (fees eat the spread)
BAND = 300.0 # upper band for sampling liquid mid-tier items
SAMPLE_SIZE = 15 # how many Steam lookups per run (rate limit friendly)
STEAM_DELAY = 6 # seconds between Steam requests
STEAM_NET_FEE = 0.15 # ~15%: 5% Steam + 10% CS2 game fee
SKINPORT_FEE = 0.08 # 8% standard, 6% above 1000 EUR
def http_json(url, retries=3):
"""GET a JSON endpoint, handling Brotli and Steam's 429 rate limiting."""
for attempt in range(retries):
try:
req = urllib.request.Request(url, headers=UA)
with urllib.request.urlopen(req, timeout=25) as r:
data = r.read()
if "br" in r.headers.get("Content-Encoding", ""):
data = brotli.decompress(data)
return json.loads(data)
except urllib.error.HTTPError as e:
if e.code == 429 and attempt < retries - 1:
print(f" [429] rate limited, backing off 20s...")
time.sleep(20)
continue
if attempt == retries - 1:
raise
time.sleep(3)
except Exception:
if attempt == retries - 1:
raise
time.sleep(3)
def fetch_skinport():
"""All tradable CS2 items on Skinport above MIN_PRICE. Returns {name: min_price_usd}."""
url = "https://api.skinport.com/v1/items?app_id=730¤cy=USD&tradable=true"
items = http_json(url, headers={**UA, "Accept-Encoding": "br"})
return {
it["market_hash_name"]: float(it["min_price"])
for it in items
if it.get("min_price") and float(it["min_price"]) >= MIN_PRICE
}
def steam_price(name):
"""Steam lowest listing. Returns None if unavailable."""
q = urllib.parse.quote(name)
url = (f"https://steamcommunity.com/market/priceoverview/"
f"?appid=730¤cy=1&market_hash_name={q}")
try:
d = http_json(url)
if d.get("success"):
lp = str(d.get("lowest_price", "")).replace("$", "").replace(",", "")
return float(lp) if lp else None
except Exception:
return None
return None
def steam_net(sale_price):
"""Steam payout after fee: price / 1.15 - 0.01."""
return round(sale_price / 1.15 - 0.01, 2)
def main():
print("[*] Fetching Skinport catalog...")
skinport = fetch_skinport()
print(f"[*] {len(skinport)} Skinport items >= ${MIN_PRICE:.0f}")
band = {k: v for k, v in skinport.items() if MIN_PRICE <= v <= BAND}
sample = sorted(band.items(), key=lambda kv: kv[1])
step = max(1, len(sample) // SAMPLE_SIZE)
targets = sample[::step][:SAMPLE_SIZE]
print(f"[*] Probing Steam for {len(targets)} items (rate-limit friendly)...")
rows = []
for name, sp_price in targets:
st = steam_price(name)
if st is None:
continue
rows.append({
"name": name,
"skinport": sp_price,
"steam_lowest": st,
"steam_net": steam_net(st),
})
time.sleep(STEAM_DELAY)
header = f"{'ITEM':<48} {'SKINPORT':>9} {'STEAM':>9} {'NET':>8} {'SPREAD':>8}"
print("\n" + header + "\n" + "-" * len(header))
opportunities = []
for r in sorted(rows, key=lambda x: x["steam_net"] - x["skinport"], reverse=True):
pct = (r["steam_net"] / r["skinport"] - 1) * 100
flag = " <== SELL ON STEAM" if pct > 10 else ""
if pct > 10:
opportunities.append({**r, "spread_pct": round(pct, 1)})
print(f"{r['name'][:47]:<48} {r['skinport']:>9.2f} "
f"{r['steam_lowest']:>9.2f} {r['steam_net']:>8.2f} {pct:>+7.1f}%{flag}")
print(f"\n[=] Probed {len(rows)} items, {len(opportunities)} with >10% Steam-net spread")
with open("spread_results.json", "w") as f:
json.dump({"probed": rows, "opportunities": opportunities}, f, indent=1)
print("[OK] saved spread_results.json")
if __name__ == "__main__":
main()
This exact script was run before publication (python3 cross_market_spread_scanner.py, exit code 0). Real output from the live run on Skinport’s full catalog and Steam’s live prices:
[*] 5213 Skinport items >= $30
[*] Probing Steam for 15 items (rate-limit friendly)...
ITEM SKINPORT STEAM NET SPREAD
------------------------------------------------------------------------------
★ Bowie Knife | Rust Coat (Well-Worn) 53.82 80.61 70.09 +30.2% <== SELL ON STEAM
AK-47 | Red Laminate (Battle-Scarred) 42.80 60.00 52.16 +21.9% <== SELL ON STEAM
Five-SeveN | Angry Mob (Field-Tested) 46.09 63.21 54.96 +19.2% <== SELL ON STEAM
P250 | Undertow (Factory New) 34.67 50.00 43.47 +25.4% <== SELL ON STEAM
Desert Eagle | Code Red (Well-Worn) 39.07 50.62 44.01 +12.6% <== SELL ON STEAM
StatTrak™ FAMAS | Afterimage (Factory New) 49.97 64.37 55.96 +12.0% <== SELL ON STEAM
★ Survival Knife | Ultraviolet (Field-Tested) 57.41 68.78 59.80 +4.2%
Glock-18 | Franklin (Minimal Wear) 85.35 97.61 84.87 -0.6%
Sticker | yuurih (Gold) | Katowice 2019 62.46 35.83 31.15 -50.1%
[=] Probed 15 items, 6 with >10% Steam-net spread
Six out of fifteen items showed a spread above 10% after fees, live, in one pass. And the other direction is real too: the Katowice 2019 Gold Sticker at −50% means Steam is the cheap side there — flip your buy/sell venues and the same scanner finds spreads both ways.
Scale it by running the scanner on a schedule and logging spreads over time — they move within hours, and history tells you which items reliably mean-revert to a spread and which one-off prints to ignore.
The cash-out problem: this is where most people get stuck
Here’s the catch that turns “free money” into a real strategy question. The profitable side of this trade is Steam — and Steam money is not money. Your $70.09 net from the Bowie Knife sale sits in your Steam wallet. You cannot withdraw it. Ever. You can only buy games and other skins with it.
So the full loop most traders actually run is:
- Buy on Skinport or CSFloat (cheap side, real cash).
- Sell on Steam (expensive side, wallet credit) — you just grew your purchasing power by 12–30%, but it’s trapped.
- Buy a different, highly liquid skin with the wallet balance — something like a ~$50–100 knife or an AK Redline, items with deep order books and tight spreads.
- Withdraw that liquid skin to a third-party marketplace and sell it there — Skinport pays out to your bank, no payout fee. You eat their 8% as the cost of converting Steam credit into real money, or ~2% if you sell on CSFloat.
Steps 3–4 are where the margin leaks: a marketplace fee again on cash-out, plus price exposure during the trade hold (0–48 hours with a seasoned authenticator, up to 15 days without). The Bowie example is +30.2% on paper from Skinport to Steam; after a second fee to cash out and a hold window, realistic retained profit is closer to +15–20% if the price hasn’t moved against you.
The advanced version runs the loop as an inventory rotation: hold liquid skins as your cash-out vehicle, feed wallet balance into them continuously, and treat the marketplace fee as a fixed cost of doing business. Some traders get to the point where they’re churning their inventory every few days and compounding — 15% per cycle on rotated inventory, at the scale of thousands of dollars of float, is real monthly income. That’s also exactly the point where Steam’s risk radar starts to care, which brings us to the honest part.
Risk list (read this part twice)
- Steam Market listing lock. Items you buy on the Community Market are marketable after a 7-day (sometimes longer) restriction, and there’s a ~week-long hold before you can re-list them at a new price. Your capital isn’t agile.
- Trade holds eat spread. Without a Mobile Authenticator aged 15+ days, incoming trades can be held up to 15 days. Set up the authenticator long before you start trading, not after.
- The skin economy can move against you mid-hold. Case releases, new operations, and major update cycles routinely move CS2 skin prices 10–30% in days. A +25% spread you found in the morning can be gone by the time your trade unlocks. This is the biggest practical risk — it’s less “arbitrage” and more “fast rotation trading with an edge.”
- Volume and liquidity are the real constraint.
min_priceon Skinport is one listing. If only one copy exists at that price, your fill is not guaranteed. Checkquantityin the API response before treating a spread as real. - Trade bans happen. Steam bans accounts for trade patterns it associates with fraud (chargebacks, stolen items flowing through). Keep your activity boring, don’t touch items with suspicious provenance, and never “cash out for a stranger” — that’s how laundering charges start, and it’s the line between gray-area arbitrage and actual crime.
- Taxman counts. In most jurisdictions, buying and reselling at profit is taxable income. At “thousands per month” scale, it’s visible.
- CSFloat’s API is login-gated (we tried it — anonymous listing queries return 403), so the scanner above only automates the Skinport↔Steam leg. For the CSFloat leg you’ll need an authenticated session; the 2% fee math is the same.
Who this actually works for
You need: a Steam account in good standing with an aged Mobile Authenticator, a few hundred dollars (or an existing inventory) to rotate, tolerance for capital locked in holds, and the discipline to compare net prices, not sticker prices. The scanner is the boring half — finding candidates. The half that makes money is patience on fills and not getting greedy right before a case release moves the market against you.
The edge is structural and it isn’t going anywhere: as long as Steam takes 15%, locks the proceeds in a wallet, and holds trades for days while Skinport settles to your bank account in 8% and one business day, the same skin will keep having two prices. Your job is just to be the one holding it on the right side of the gap.
Fees and trade-hold windows referenced here were verified against marketplace documentation and fee calculators in October 2026 — Steam’s 5%+10% structure per Valve community documentation, Skinport’s 8%/6% per their FAQ, CSFloat’s 2% per their marketplace docs. Prices in the scanner output were live at test time and will be different when you run it — that’s the whole point.