US retailers took back roughly $890 billion worth of merchandise in 2024 — about 16.9% of everything they sold, per the National Retail Federation and Happy Returns. A big slice of that is opened-but-fine product that the retailer can never resell as new. Their cleanup channel is B2B liquidation auctions, and the largest network is B-Stock (bstock.com), where brands and marketplaces like Walmart, Target, Amazon returns and Best Buy dump everything from single parcels to full truckloads.

Here is the blunt version of the opportunity: a pallet of customer-return electronics with a retail manifest of $3,000 routinely hammers for $300–$700. If 60–80% of it actually works and resells, that is a four-figure retail recovery for a three-figure bid. People run this as a real business out of a garage. The grey-zone flavor is mild but real: liquidators are explicitly buying what retailers wrote off, grading it themselves, and reselling it on eBay/Facebook/local markets with zero brand authorization — fully legal, and the retailers’ loss is your inventory pipeline.

The catch, and the reason most first-time pallet buyers lose money: the auction does the math against you. By the time an auction gets attention, casual bidders have pushed the price past anything sensible — B-Stock pallets of Walmart returns regularly get bid past 1x retail MSRP by people who never opened a spreadsheet. The winning move is boring: decide your absolute maximum bid before you bid, from the manifest, with every fee priced in, and never cross it. This article builds that calculator in Python.

The fee stack that kills beginners

Every cost below is verified at time of writing — re-check each listing, because terms vary per marketplace inside the B-Stock network:

  1. Hammer price — your winning bid.
  2. Buyer’s premium — B-Stock Supply charges a standard 10% buyer’s premium on top of the hammer (per B-Stock’s own how-to-buy docs).
  3. Transaction fee — B-Stock Supply listings carry a $50 transaction fee, which the buyer’s premium is applied toward unless the lot sells for under $500 — meaning cheap lots effectively pay premium plus a partial extra fee. Read the fee block on every listing.
  4. Sales tax — most marketplaces charge sales tax unless you upload a resale certificate. Get one; it’s a flat ~7–10% saving on every pallet.
  5. Shipping/freight — pallets ship freight. Budget $80–$250 per pallet depending on weight and distance; truckloads are quoted per load.
  6. Resale fees — eBay takes a 13.6% final value fee plus $0.40 per order in most US categories (on item price + shipping + any tax you collect — confirmed across eBay’s current fee schedule and multiple fee trackers). Facebook Marketplace and cash sales dodge this but move slower.

And the silent killer nobody prices in: dead stock. Grade A lots run maybe 90% usable, B lots ~70%, C lots can be under 50%, and the manifest lies more often than not. The calculator below applies a grade factor and a usable-percentage haircut to every line item, because “$500 of retail value” means roughly $250 of real recovery in a B-grade lot.

The max-bid formula

Work backwards from what the stuff actually resells for:

net_resale = Σ(comps × grade_factor × usable) 
             − eBay fee − outbound shipping − labor
max_hammer = (net_resale − min_profit) / (1 + buyer_premium)
             [− $50 if hammer < $500]

You bid max_hammer or walk. No emotions, no “but the manifest says $4k”.

The scanner, tested and working

The script below was written and executed end-to-end before being published (mocked market data — see the disclosure inside the code). It takes a lot’s manifest, prices each line against secondary-market sold prices, applies grade and usability haircuts, computes the full fee stack both inbound and outbound, and outputs a max hammer price per lot plus a BID/SKIP signal against the current bid.

#!/usr/bin/env python3
"""
B-Stock Liquidation Lot Scanner.
Scores upcoming B-Stock-style liquidation lots against secondary-market
sold-price comps, and computes a maximum bid where the math still clears
your minimum profit after ALL fees.

Tested with MOCKED market data (no paid API key needed for the demo run) --
wire the get_market_comps() function to eBay's Browse API or a sold-prices
provider and run once with your real key before trusting any live output.
"""

import csv
import json
import os
from datetime import datetime, timezone
from pathlib import Path

# ---------------------------------------------------------------- constants
# Verified fees (see article for sources):
BSTOCK_BUYERS_PREMIUM = 0.10   # B-Stock Supply standard 10% buyer's premium
BSTOCK_TXN_FEE = 50.0          # $50 listing transaction fee, covered by the
                               # premium unless the lot sells under $500
EBAY_FVF = 0.136               # 13.6% final value fee, most categories
EBAY_PER_ORDER = 0.40          # $0.40 per order ($0.30 if order <= $10)
SALES_TAX_RESALE = 0.08        # avg tax you collect on the resale side
SHIPPING_PER_UNIT = 12.0       # avg outbound shipping per unit
MIN_PROFIT_PER_LOT = 150.0     # your floor per pallet
LABOR_PER_UNIT = 3.0           # unbox, test, photograph, list

SCRATCH = Path(__file__).resolve().parent


def get_market_comps(keyword: str) -> dict:
    """
    MOCKED secondary-market comps (median sold prices, USD).
    Replace the body with a real call (eBay Browse API:
    https://api.ebay.com/buy/browse/v1/item_summary_search -- needs OAuth)
    and run once with your real key. Shape: {keyword: median_sold_price}
    """
    mock_db = {
        "dyson v8": 165.0,
        "bose qc35": 95.0,
        "thermo thermometer": 28.0,
        "kindle 10th": 42.0,
        "anker powercore": 24.0,
        "instant pot duo": 55.0,
        "shark vacuum": 88.0,
        "sonos play:1": 110.0,
        "fitbit charge": 38.0,
        "jbl flip": 55.0,
    }
    k = keyword.lower()
    for name, price in mock_db.items():
        if name in k:
            return {"comp_price": price, "source": "mock"}
    return {"comp_price": 0.0, "source": "mock"}


def lot_score(lot: dict) -> dict:
    """
    Score one lot: recoverable value vs max bid that still clears MIN_PROFIT.
    Grading assumptions (industry rule of thumb, see article):
    grade A ~90% of comp, B ~70%, C ~45% -- applied per-unit on recoverable units.
    """
    grade_factor = {"A": 0.90, "B": 0.70, "C": 0.45}.get(lot.get("grade", "B"), 0.70)
    usable_pct = lot.get("usable_pct", 0.75)  # fraction of units actually resellable

    total_recoverable = 0.0
    unit_details = []
    for item in lot["manifest"]:
        comps = get_market_comps(item["keyword"])
        unit_resale = comps["comp_price"] * grade_factor
        units = int(round(item["qty"] * usable_pct))
        total_recoverable += unit_resale * units
        unit_details.append({
            "keyword": item["keyword"], "qty": item["qty"],
            "usable": units, "unit_resale": round(unit_resale, 2),
        })

    # Outbound costs
    ship_total = SHIPPING_PER_UNIT * sum(u["usable"] for u in unit_details)
    labor_total = LABOR_PER_UNIT * sum(u["usable"] for u in unit_details)
    tax_on_resale = total_recoverable * SALES_TAX_RESALE
    # eBay fee is charged on item price + shipping + tax we collect
    ebay_fee = (total_recoverable + ship_total + tax_on_resale) * EBAY_FVF + EBAY_PER_ORDER

    net_resale = total_recoverable - ebay_fee - ship_total - tax_on_resale - labor_total

    # B-Stock inbound cost model: premium always; $50 txn fee only if hammer < $500
    def all_in(hammer):
        cost = hammer * (1 + BSTOCK_BUYERS_PREMIUM)
        if hammer < 500:
            cost += BSTOCK_TXN_FEE
        return cost

    # Max hammer that still clears MIN_PROFIT_PER_LOT
    budget = net_resale - MIN_PROFIT_PER_LOT
    if budget >= all_in(500.0):
        max_hammer = round(budget - BSTOCK_TXN_FEE, 2) / (1 + BSTOCK_BUYERS_PREMIUM)
    elif budget >= 0:
        max_hammer = round(budget, 2) / (1 + BSTOCK_BUYERS_PREMIUM)
    else:
        max_hammer = 0.0

    return {
        "lot_id": lot["lot_id"], "marketplace": lot["marketplace"],
        "units": sum(i["qty"] for i in lot["manifest"]),
        "total_recoverable": round(total_recoverable, 2),
        "net_resale": round(net_resale, 2),
        "max_hammer_bid": round(max_hammer, 2),
        "current_bid": lot["current_bid"],
        "signal": "BID" if max_hammer >= lot["current_bid"] > 0 else "SKIP",
        "unit_details": unit_details,
    }


def main():
    lots = load_lots()
    results = [lot_score(l) for l in lots]
    results.sort(key=lambda r: (r["signal"] != "BID", -(r["max_hammer_bid"] - r["current_bid"])))

    out_csv = SCRATCH / "bstock_scan_results.csv"
    with open(out_csv, "w", newline="") as f:
        w = csv.writer(f)
        w.writerow(["lot_id", "marketplace", "units", "total_recoverable", "net_resale",
                    "max_hammer_bid", "current_bid", "signal"])
        for r in results:
            w.writerow([r["lot_id"], r["marketplace"], r["units"], r["total_recoverable"],
                        r["net_resale"], r["max_hammer_bid"], r["current_bid"], r["signal"]])

    print(f"[{datetime.now(timezone.utc).isoformat()}] scanned {len(results)} lots")
    print(f"{'lot':<10} {'mkt':<12} {'units':>5} {'recover$':>9} {'net$':>8} "
          f"{'maxbid$':>8} {'cur$':>7}  signal")
    for r in results:
        print(f"{r['lot_id']:<10} {r['marketplace']:<12} {r['units']:>5} "
              f"{r['total_recoverable']:>9.2f} {r['net_resale']:>8.2f} "
              f"{r['max_hammer_bid']:>8.2f} {r['current_bid']:>7.2f}  {r['signal']}")

    n_bid = sum(1 for r in results if r["signal"] == "BID")
    print(f"\n{n_bid}/{len(results)} lots clear the {MIN_PROFIT_PER_LOT:.0f}$ profit floor")
    print(f"results written to {out_csv}")


def load_lots() -> list:
    """
    MOCKED live-auction snapshot (shape matches what you'd parse from a
    B-Stock marketplace auction-listing page you're registered on).
    Sign up free at bstock.com per-marketplace, then swap this for your own
    scraped/parsed listing data.
    """
    path = SCRATCH / "bstock_lots_mock.json"
    if path.exists():
        return json.loads(path.read_text())
    lots = [
        {
            "lot_id": "PAL-1042", "marketplace": "Walmart US",
            "grade": "B", "usable_pct": 0.75, "current_bid": 60.0,
            "manifest": [
                {"keyword": "dyson v8", "qty": 4},
                {"keyword": "shark vacuum", "qty": 3},
                {"keyword": "anker powercore", "qty": 10},
            ],
        },
        {
            "lot_id": "PAL-2210", "marketplace": "Target US",
            "grade": "A", "usable_pct": 0.90, "current_bid": 190.0,
            "manifest": [
                {"keyword": "sonos play:1", "qty": 3},
                {"keyword": "jbl flip", "qty": 6},
                {"keyword": "bose qc35", "qty": 2},
            ],
        },
        {
            "lot_id": "PAL-3388", "marketplace": "Amazon US",
            "grade": "C", "usable_pct": 0.45, "current_bid": 95.0,
            "manifest": [
                {"keyword": "kindle 10th", "qty": 8},
                {"keyword": "fitbit charge", "qty": 5},
                {"keyword": "thermo thermometer", "qty": 12},
            ],
        },
        {
            "lot_id": "PAL-4455", "marketplace": "Best Buy US",
            "grade": "B", "usable_pct": 0.70, "current_bid": 640.0,
            "manifest": [
                {"keyword": "sonos play:1", "qty": 2},
                {"keyword": "jbl flip", "qty": 4},
                {"keyword": "anker powercore", "qty": 15},
                {"keyword": "instant pot duo", "qty": 3},
            ],
        },
    ]
    path.write_text(json.dumps(lots, indent=2))
    return lots


if __name__ == "__main__":
    main()

Actual run output (this exact script, executed before publication):

[2026-10-26T03:07:22+00:00] scanned 4 lots
lot        mkt          units  recover$     net$  maxbid$    cur$  signal
PAL-1042   Walmart US      17    604.10   250.43    91.30   60.00  BID
PAL-2210   Target US       11    715.50   386.45   214.95  190.00  BID
PAL-3388   Amazon US       25    172.80   -49.76     0.00   95.00  SKIP
PAL-4455   Best Buy US     24    437.50    71.73     0.00  640.00  SKIP

2/4 lots clear the 150$ profit floor

Read what it caught: PAL-3388 has 25 units on the manifest, but at grade C with 45% usable and low-value comps, the net recovery is negative ($-49.76) — so the scanner forces max bid to $0 and screams SKIP even though the current bid “looks cheap” at $95. That is exactly the pallet that bankrupts first-timers. PAL-2210 is the opposite: grade A, premium comps, net $386 against a floor of $150 — it can absorb a hammer up to $214.95 and still pay you. If the auction ends below that, you bid. Above it, you close the tab.

Disclosure: the run above uses mocked comp prices and a mocked auction snapshot — no paid API was touched. The scoring math, fee stack and max-bid logic are the real deliverable and ran clean (exit 0). Before trusting live numbers, wire get_market_comps() to the eBay Browse API (or any sold-prices provider) with your own key, and feed real manifests you copy off the listing pages.

Getting real data in

B-Stock has no public API for buyers. The practical workflow:

  1. Register free at bstock.com and join the marketplaces you care about (each retail brand runs its own — Walmart US, Target US, etc. approvals are separate).
  2. Copy manifests — most listings have a downloadable manifest CSV/Excel. Parse it with openpyxl or plain csv into the manifest shape the scanner expects.
  3. Comps — for a no-API start, filter eBay to Sold Items for each manifest keyword and take the median of the last 20 solds. The Browse API automates this once you have a key.
  4. Automate the watch — run the scanner on a schedule (cron every 30 min), and push BID signals to a Telegram bot or Discord webhook so you catch lots in their first hours, before the herd bids them past your max. Everything stays local; there is nothing to bypass and no ToS gymnastics — you are a registered buyer doing math on data you already have.

The money, honestly

  • Scale per pallet: net $100–$400 per good pallet is a realistic band after every fee above. A focused operator moving 10–20 pallets a month sits in the low four figures net — this scales with warehouse space and grind, not with the script.
  • The script’s job is not to make you rich; it’s to keep you from the one losing pallet that erases five winners. Kill rate is the whole edge.
  • Cash conversion is slow: pallet → items listed → sold → payout can run 3–6 weeks. Keep a float for 3–4 pallets, don’t recycle rent money.
  • Truckloads (26 pallets, $3k–$8k a load) are where six figures/yr operations live — same math, bigger freight bill, more dead stock risk. Do not start there.

How people actually lose

  1. Manifest worship. Manifests say “retail $4,200”. Reality at grade B is about 40–55% of that in recovery. The scanner’s haircut is not pessimism, it’s the average outcome.
  2. Ignoring the $50 sub-$500 fee trap. On a $300 lot, the $50 transaction fee is 16.7% on top of the 10% premium. Small lots need big margins to survive.
  3. Forgetting freight. A $150 pallet profit minus $120 freight is a hobby, not income. Local pickup marketplaces or clustered regional sellers change this math.
  4. Dead categories. Phone cases, cables, cheap earbuds — returns-heavy items with no secondary value. Let the comps dict return $0 and the math skips them for you.
  5. Bidding wars. The entire article is this one sentence: your max bid is computed before the auction heats up, and the number does not move because someone else has less spreadsheet than you.

Start with one B-grade Walmart pallet under your computed max, track every unit’s actual sold price in a sheet, and feed the real numbers back into get_market_comps(). After 3–4 pallets you’ll have your own grade factors instead of the rule-of-thumb ones — and at that point the scanner stops being a tutorial and becomes your pricing desk.