The seed packet that wasn't a barcode
My garden app tracks seed packets: name, brand, how many are left, whether the variety has ever worked. Adding one used to mean typing the variety off the front of the packet, so a while back I put a camera scanner on it. Point it at the barcode, get a name and brand back, done.
Then I bought seeds from a small seed house and the scanner had nothing to say about them.
One line of normalisation
Here is the entire bug:
def _clean_barcode(code: str) -> str:
return re.sub(r"\D", "", code or "")[:20]
Strip everything that isn't a digit. Perfectly sensible for a UPC — the camera hands you 736210001234, you hand back 736210001234.
The packet in my hand didn't have a UPC. It had a QR code, and a QR code decodes to whatever the printer wanted, which for a seed company is a URL:
https://victoryseeds.com/qr/s/3370151/VSCGKPY/20260728/
Run that through the regex and you get 3370151202607 28 mashed into one number. Not a barcode, not anything. So the lookup missed, and — worse — the scanner "learned" that junk string against whatever I typed next, poisoning the cache for the next scan.
The big brands print UPCs. The small houses print QR. I had built for one and tested with the other exactly never.
The QR is a better source than the barcode
Once I stopped shredding the URL, it turned out to be far more useful than a UPC ever was.
A UPC gets you a number you then have to look up in a product database that mostly indexes groceries. Seed packets are not groceries; the free databases return nothing for them. I'd already worked around that by mirroring vendor catalogs and decoding SKUs out of the UPC myself.
The QR skips all of it. Follow the short link and it lands on the vendor's own product page, which names the exact variety in its Open Graph tags:
og:title Round Zucchini Summer Squash - Victory Seeds®
og:site_name Victory Seed Company
og:description A productive and interestingly shaped zucchini…
Split the title on the dash and you have the name and the brand, from the company that packed the seed. No third-party database, no SKU arithmetic.
And the URL path itself carries more than the destination:
/qr/s/3370151/VSCGKPY/20260728/
serial lot packed
That last field is the one I actually care about. Germination rate falls off with age, so when was this packed decides whether a packet is worth sowing in spring. It's printed on the back in small type, and now it lands in the notes field without anyone reading it:
Lot VSCGKPY · packed 2026-07-28
A URL that came off a camera is hostile input
The moment the fix became "fetch the scanned URL," the shape of the problem changed. That string is not a number I validate — it's an arbitrary URL supplied by whatever happened to be in front of a lens, and my server will go fetch it. Point a camera at a QR sticker somebody printed and my backend becomes their HTTP client, inside my network.
So the fetch is fenced:
def _safe_fetch(url, timeout=8, max_bytes=400_000):
# http(s) only, no private/link-local hosts, redirects re-checked
# at every hop, hard byte cap, no redirect loops
The important part is re-checked at every hop. Validating the URL you were given and then handing it to a library that follows redirects for you is the classic miss: the first URL is public, the 302 points at 10.x, and you fetched it anyway. Playing the redirects manually and running the host check each time is a few more lines and removes the whole class.
The other thing I got wrong
The camera overlay had a viewfinder box 270 wide by 170 tall. Fine for a barcode, which is a wide short thing. A QR is square — held at the distance you'd naturally hold a seed packet, it didn't fit in the box.
So even after the backend understood QR codes, the scanner still wouldn't have read one. Two independent bugs, both from assuming "code on a package" means "1D barcode," and the second one invisible until the first was fixed.
The box is now square and sized off the viewport. I also stopped pulling the decoder library from a CDN at first use — it's a garden, on a phone, where signal is a coin flip. It ships from my own static directory now, with the CDN as fallback.
What I'd do differently
Probe first. Ten minutes of pointing a decoder at the actual packets I own would have shown me two different code formats before I wrote a line of normalisation. Instead I built for the packet I imagined and found the real one on the kitchen table, months later, holding a zucchini variety my app insisted it had never heard of.