Collision-free, random-looking codes from a counter. Python implementation of the dealcode spec.
pip install dealcodeRequires Python ≥ 3.9. The only dependency is cryptography (PyCA), used for AES.
from dealcode import Dealcode
codec = Dealcode(key="0a1b...64-hex-chars-from-your-secret-manager")
codec.encode(0) # '767a5b' (6 hex chars)
codec.encode(1) # '421163' never collides with any other counter
codec.decode("421163") # 1(The outputs shown are the real ones for this exact key and default config — with your own key every counter maps to a different, but equally stable, code.)
The key can be raw bytes (16/24/32 bytes are used as-is as an AES key) or any
string/bytes, which are deterministically expanded to an AES-256 key. Generate
one with openssl rand -hex 32 and keep it in your secret manager — the
mapping is stable only while the key (and every other option) stays fixed.
Dealcode(
key, # bytes | str
alphabet="hex", # "dec" | "hex" | "base32" | "crockford" | "base36"
# | "base58" | "base62" | "base64url" | custom string
min_length=6, # codes start at this length...
max_length=None, # ...and grow one char at a time up to this (default: max for 2^63)
domain="", # namespace: same key, unrelated codes per domain
)coupon = Dealcode(key, "crockford", domain="coupons") # human-friendly, e.g. '7Q4WKZ'
order = Dealcode(key, "dec", min_length=8, domain="orders") # digits only
fixed = Dealcode(key, "hex", min_length=16, max_length=16) # constant-lengthdecode raises InvalidCodeError for malformed input — wrong length,
characters outside the alphabet, or a value outside the issuable range.
A well-formed code always decodes to some counter, whether or not that
counter was ever issued (inherent to a permutation — see SPEC §7). Treat
decode as parsing, not proof of existence: look the counter up before acting
on it, and note that a one-character typo in a valid code can resolve to a
different valid counter — add rate limiting (and, for human-typed flows, an
existence check or your own check digit). encode raises RangeError
outside [0, codec.capacity), and construction mistakes (bad key, alphabet,
lengths, domain) raise ConfigError. All errors subclass DealcodeError
(a ValueError).
Preset alphabets normalize obvious typing variants before decoding — hex
is case-insensitive, and crockford also folds O → 0 and I/L → 1 —
but normalization never strips anything: separators and whitespace are not
removed. If you display codes grouped (XXXX-XXXX), strip the hyphens or
spaces in your app before calling decode.
Dealcode does not talk to your database — it only turns a counter into a code. Any source of never-repeating integers works. With PostgreSQL:
CREATE SEQUENCE order_code_seq AS bigint MINVALUE 0 START WITH 0;
CREATE TABLE orders (
id bigint PRIMARY KEY, -- the counter
code text NOT NULL UNIQUE, -- safety net; alerts on config mistakes
...
);import os
from sqlalchemy import text
from dealcode import Dealcode, InvalidCodeError
codec = Dealcode(key=os.environ["DEALCODE_KEY"], domain="orders")
def create_order(conn) -> str:
n = conn.execute(text("SELECT nextval('order_code_seq')")).scalar_one()
code = codec.encode(n)
conn.execute(
text("INSERT INTO orders (id, code) VALUES (:id, :code)"),
{"id": n, "code": code},
)
return code
def find_order(conn, code: str):
try:
n = codec.decode(code) # malformed codes never reach the DB
except InvalidCodeError:
return None
return conn.execute(text("SELECT * FROM orders WHERE id = :id"), {"id": n}).first()Sequences never hand out the same number twice (even across concurrent transactions and rollbacks), so codes never collide. Gaps in the sequence are invisible — codes look random anyway.
If the UNIQUE constraint on code ever fires, do not retry: it means the
key/config changed for an existing namespace. Investigate.
For code shapes that must never grow — airline-PNR-style fixed-length codes —
CyclingDealcode (SPEC §11) fills the entire fixed-length space, and when it
is exhausted refills the same space through a different permutation instead
of adding a character:
from dealcode import CyclingDealcode
pnr = CyclingDealcode(key, "crockford", length=6, domain="bookings")
code = pnr.encode(n) # always exactly 6 chars; cycle = n // pnr.capacity
cycle = pnr.cycle_of(n) # which cycle a counter belongs to — store it with the code
n = pnr.decode(code, cycle=3) # the cycle is required contextConfiguration mirrors Dealcode (key, alphabet, domain) with a single fixed
length: 2 <= length <= 128 and 100 <= radix**length <= 2**63.
Codes repeat across cycles (the space is being reused — that's the
point), so keep at most one cycle's codes live per uniqueness scope: retire
or expire cycle e before issuing from e+1, index with
UNIQUE(cycle, code) rather than UNIQUE(code), and store each live code's
cycle — decode needs it. Decoding with a wrong (but in-range) cycle is
not an error — it silently returns a different counter; the existence
check on the decoded counter is what catches it.
A Dealcode instance is immutable and thread-safe; create one per namespace at
startup and reuse it. Encoding is ten AES-CBC-MAC rounds — tens of microseconds,
no allocation-heavy paths, O(1) in the counter value.
From the repository root:
pip install -e ./python pytest # or: export PYTHONPATH=python/src
python -m pytest python/testsThe suite covers the official NIST FF1 sample vectors, every shared
cross-language vector in
testvectors/,
and behavioural/edge cases.
MIT — see LICENSE.