Back to blog
FILE 0xB3·THE SEED PACKET THAT HAD NO BARCODE

The seed packet that had no barcode

August 28, 2026 · barcodes, scraping, homelab

My garden app has a seed-packet scanner. Point the camera at a packet, it fills in the variety name and brand, done. It works by decoding the UPC: the small seed houses run Shopify storefronts, their public /products.json lists every product's SKU, and the UPC is just <GS1 prefix><zero-padded SKU><check digit>. One dict entry per vendor.

Then a packet showed up that returned nothing. Fedco is a co-op, not a Shopify shop, so there was no products.json to mirror. Fine — I'd write a scraper.

Except the actual problem was worse than that. I pulled the photo of the back of the packet through zbarimg to see what the scanner was even getting:

$ convert packet.jpg -rotate -90 -colorspace gray -resize 150% w.png
$ zbarimg -q w.png
CODE-39:48011072

Code 39. Not a UPC. Not an EAN. There is no UPC anywhere on the packet — the two barcodes on the back are labelled Item# and Lot#, and they're for the co-op's own warehouse, not for retail. No UPC database on earth indexes them, because they aren't retail barcodes at all.

The front of the packet says item 1072A, lot 4801. The symbol decodes to 48011072. Lot, then item, concatenated.

The catalog is addressable by item number

Poking at their site, product URLs look like /seeds/moon-and-stars-organic-watermelon-1072. The trailing number is the item number. And it turns out the slug doesn't matter at all:

$ curl -sI https://example-seed-co.test/seeds/x-1072 | head -1
HTTP/2 302
location: /seeds/moon-and-stars-organic-watermelon-1072

Any slug works, the site 302s to the canonical page. So the whole lookup is a redirect probe. No catalog mirror, no HTML parsing beyond the <title>, no pagination.

Better: an item number they don't sell just 404s. Which solves the problem I hadn't figured out yet — given 48011072, which half is the item and which is the lot?

def _fedco_item_candidates(code):
    if re.fullmatch(r"\d{4}", code):
        return [code]
    if re.fullmatch(r"\d{8}", code):
        return [code[4:], code[:4]]   # item second, as observed — try both
    return []

Try both halves; only one resolves. 1072 is a watermelon, 4801 is a 404 in every department. The site's own 404 is the disambiguator, and I never have to trust that the byte order I saw on one packet is universal.

Two details that matter more than they look:

Use HEAD. Their 404 page is a 200KB "no results found" document. A GET per department across six departments is 1.2MB of wasted bandwidth for a miss. HEAD gives you the 302-vs-404 with a zero-byte body.

Only trust a redirect that lands on your item. A site-wide "we've moved" bounce would otherwise read as a hit for every number you probe:

if loc.startswith(BASE + "/") and loc.rstrip("/").endswith("-" + item):
    return loc

The collision I nearly shipped

Then I tested a code I knew wasn't theirs and got a confident answer back:

{"found": true, "source": "vendor", "name": "Cross Country Pickling Cucumber",
 "lot": "5678"}

An EAN-8 is eight digits. A lot+item pair is eight digits. My splitter happily chopped a real retail barcode in half and matched whichever item shared its last four digits.

The fix is that EAN-8 has a check digit and a concatenated pair of warehouse numbers almost never satisfies it:

def _ean8_valid(code: str) -> bool:
    if not re.fullmatch(r"\d{8}", code):
        return False
    body = [int(d) for d in code[:7]]
    total = sum(d * (3 if i % 2 == 0 else 1) for i, d in enumerate(body))
    return (10 - total % 10) % 10 == int(code[7])

If the digit agrees, it's a real retail barcode and it earns its UPC lookup first; only on a miss do we start splitting it. 48011072 fails the checksum — check digit should be 5, it's 2 — so it's split with confidence.

The half of the bug that wasn't on the server

Server side done, I went to check the clients and found the actual reason this packet had never scanned:

.setBarcodeFormats(
    Barcode.FORMAT_QR_CODE,
    Barcode.FORMAT_EAN_13,
    Barcode.FORMAT_EAN_8,
    Barcode.FORMAT_UPC_A,
    Barcode.FORMAT_UPC_E,
    Barcode.FORMAT_CODE_128,
    Barcode.FORMAT_DATA_MATRIX,
)

No FORMAT_CODE_39. On Android the scanner would never have fired on that packet at all — not "returned no match", just silently nothing, forever, no matter how good the server got. iOS already listed .code39 in its metadata object types, and the web client uses a decoder that supports every format unless you restrict it, so this was one platform quietly missing.

That's the part worth remembering. I spent most of the time on an elegant server-side resolver, and a third of the users couldn't have reached it because of one missing enum in a format list. When you add a symbology, grep every client for the place that enumerates them — the scanner that never fires looks exactly like a lookup that found nothing.