I've been selling small things on Gumroad, and I plan to sell more software down the line. Somewhere along the way I heard enough stories about how easy Gumroad's built-in license keys are to work around that I stopped trusting them as the only thing standing between "customer" and "person who downloaded it once and shared it in a Discord." I looked at the hosted alternatives (Keygen, Lemon Squeezy's license API, etc.) and they're fine, but paying a recurring fee to validate a string felt silly for what's fundamentally a lookup table with an expiration date.
So I built my own: SnaKey — a small, self-hosted license key server in FastAPI + SQLAlchemy.
What it does
Three endpoints, on purpose:
- POST /licenses/generate — mints a random key (XXXXX-XXXXX-XXXXX-XXXXX-XXXXX), stores only its SHA-256 hash plus whatever metadata you want attached (customer id, product id, whatever your app needs later)
- POST /licenses/verify — checks a key, walks it through a generated → active → expired lifecycle
- POST /licenses/revoke — kills a key immediately, refund/chargeback style The raw key is returned exactly once, at generation time. After that the server only ever compares hashes — even if the database leaks, there's nothing usable in it.
It runs on SQLite by default (zero setup, good enough for most indie-scale traffic) or Postgres if you need it, picked by one env var. And since you get the source, you're not locked into either — swap in anything SQLAlchemy supports.
The part I almost shipped broken
I added a pytest suite mostly out of habit, and it immediately caught something I would not have noticed by eye: the key generator used os.urandom(15) encoded as base32, split into groups of 5. 15 bytes happens to encode to exactly 24 base32 characters — not 25 — so the last group was always 4 characters instead of 5. Not a rare edge case, not a race condition — every single key generated had a short last group, deterministically, forever.
# before — silently wrong, every time
random_bytes = os.urandom(15)
base32_encoded = base64.b32encode(random_bytes).decode("utf-8").rstrip("=")
# 15 bytes -> 24 chars -> groups of [5,5,5,5,4]
Fixed it by generating the 25 characters directly instead of going through a byte count that didn't divide evenly:
import secrets
BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
def generate_key_body(length=25):
return "".join(secrets.choice(BASE32_ALPHABET) for _ in range(length))
Small bug, but it's exactly the kind of thing that's invisible in manual testing (the keys look fine, you just don't count the characters) and obvious the second you write assert len(group) == 5 and run it fifty times.
Where it's at
It's live on Gumroad for $15 — API only, no dashboard (didn't want to sink a bunch of time into a UI before I know it's worth building at all). If there's interest I'll probably add one, plus multi-product support, later.
Mostly I built this for myself, but if you're in the same spot — selling or about to sell something that needs a key, not wanting a subscription for it — it might save you a weekend. You can check it out here.
Top comments (0)