Skip to content

Bet UID Format

A bet UID identifies a bet globally. It has two parts:

{service_uid}-{ulid}
16 1 26
  • service_uid (16 chars) — which service minted it
  • ulid (26 chars) — unique within that service, time-ordered
  • a single - separator

Total: 43 characters. The design gives four properties:

  1. Self-identifying — the prefix names the minting service.
  2. Idempotency key — the (engineType, serviceBetUid) pair is unique in the ledger.
  3. Time-sortable — the ULID’s first 48 bits are a timestamp, so sort order = time order.
  4. Collision-free — the ULID’s 80 bits of randomness make collision risk effectively zero.

ULIDs use Crockford Base32:

0123456789ABCDEFGHJKMNPQRSTVWXYZ
  • 32 symbols: 0–9 plus A–Z, excluding I, L, O, U.
  • Case-insensitive (but lowercase is used here — see below).
  • I/L are dropped so they cannot be confused with 1, and O with 0.

Full bet UID validation:

^[0-9a-hjkmnp-tv-z]{16}-[0-9a-hjkmnp-tv-z]{26}$
  • first 16: service_uid (lowercase Crockford)
  • separator -
  • last 26: ulid (lowercase Crockford)

Service UID alone:

^[0-9a-hjkmnp-tv-z]{16}$

The platform mints the service UID and provides it when the service is registered. The service only stores it — not in env, but learned at runtime from the first desired-state push.

16 Crockford Base32 characters = 16 × 5 = 80 bits. The platform checks for collisions against existing services when minting; the service does nothing here.

01JQ8Z9K7E 3M4N5P6Q7R8S9T0V
|timestamp | | randomness |
48 bit 80 bit
10 chars 16 chars
  • first 10 characters: 48-bit timestamp (ms, Unix epoch)
  • last 16 characters: 80-bit randomness
  • 26 characters total

Why ULID, not UUID?

PropertyULIDUUID v4
Length26 chars36 chars
Sortable✅ (timestamp prefix)❌
URL-safe✅⚠️ (hyphens)
Readable✅❌

Lowercase. The ULID spec uses uppercase, but bet UIDs are lowercased — to avoid case-insensitive comparison issues in the ledger, for URL consistency, and for DB index consistency. Always lowercase: call .toLowerCase() after minting.

Reference implementation (JavaScript, vanilla)

Section titled “Reference implementation (JavaScript, vanilla)”

A minimal, zero-dependency implementation, ready to copy into a service:

// betUid.js — CGSP reference implementation
import { randomBytes } from 'node:crypto';
const ALPHABET = '0123456789abcdefghjkmnpqrstvwxyz';
// Crockford Base32 (lowercase) — i, l, o, u excluded
export const SERVICE_UID_LENGTH = 16;
export const ULID_LENGTH = 26;
export const BET_UID_LENGTH = SERVICE_UID_LENGTH + 1 + ULID_LENGTH; // 43
const SERVICE_UID_RE = /^[0-9a-hjkmnp-tv-z]{16}$/;
const ULID_RE = /^[0-9a-hjkmnp-tv-z]{26}$/;
const BET_UID_RE = /^[0-9a-hjkmnp-tv-z]{16}-[0-9a-hjkmnp-tv-z]{26}$/;
function encodeCrockford(value, length) {
let v = value;
let out = '';
for (let i = 0; i < length; i++) {
out = ALPHABET[Number(v & 0x1fn)] + out;
v >>= 5n;
}
return out;
}
function randomBigInt(byteLen) {
const bytes = randomBytes(byteLen);
let acc = 0n;
for (const b of bytes) acc = (acc << 8n) | BigInt(b);
return acc;
}
/** 26-char lowercase ULID: 48-bit timestamp + 80-bit randomness. */
export function ulid() {
const ts = BigInt(Date.now());
return encodeCrockford(ts, 10) + encodeCrockford(randomBigInt(10), 16);
}
export const isValidServiceUid = (v) => typeof v === 'string' && SERVICE_UID_RE.test(v);
export const isValidUlid = (v) => typeof v === 'string' && ULID_RE.test(v);
export const isValidBetUid = (v) => typeof v === 'string' && BET_UID_RE.test(v);
/**
* Build a {serviceUid}-{ulid} bet reference. Throws on an invalid serviceUid so
* misconfiguration (empty lookup, legacy string, etc.) surfaces immediately
* instead of writing a malformed bet id.
*/
export function makeBetUid(serviceUid) {
if (!isValidServiceUid(serviceUid)) {
throw new Error(
`makeBetUid: invalid serviceUid (expected ${SERVICE_UID_LENGTH} lowercase Crockford Base32 chars)`,
);
}
return `${serviceUid}-${ulid()}`;
}

The service_uid arrives on the desired-state push; store it, then mint per bet. If an engine has no service_uid yet (a bet arrived before reconcile), the bet must be rejected — otherwise it produces a malformed bet id the platform rejects.

import { makeBetUid } from './betUid.js';
const serviceUidByEngineId = new Map();
function onDesiredState(payload) {
for (const e of payload.engines) {
serviceUidByEngineId.set(e.engineId, e.serviceUid);
}
}
function acceptBet(engineId /* ... */) {
const serviceUid = serviceUidByEngineId.get(engineId);
if (!serviceUid) {
throw new Error(`engine ${engineId} has no serviceUid yet — reject bet`);
}
return makeBetUid(serviceUid); // "wp9dvdgp4wfqmxq4-01krrh7xn3qq5e69gn58wkjt38"
}

The algorithm is the same everywhere; it needs only three primitives: a CSPRNG (10 random bytes), big-integer arithmetic (to split into 5-bit groups), and the alphabet lookup table.

Python:

import secrets, time
ALPHABET = '0123456789abcdefghjkmnpqrstvwxyz'
def encode(value: int, length: int) -> str:
out = []
for _ in range(length):
out.append(ALPHABET[value & 0x1f])
value >>= 5
return ''.join(reversed(out))
def ulid() -> str:
ts = int(time.time() * 1000)
rand_bigint = int.from_bytes(secrets.token_bytes(10), 'big')
return encode(ts, 10) + encode(rand_bigint, 16)
def make_bet_uid(service_uid: str) -> str:
if not (len(service_uid) == 16 and all(c in ALPHABET for c in service_uid)):
raise ValueError("invalid service_uid")
return f"{service_uid}-{ulid()}"

Go:

package betuid
import (
"crypto/rand"
"errors"
"math/big"
"time"
)
const alphabet = "0123456789abcdefghjkmnpqrstvwxyz"
func encode(v *big.Int, length int) string {
out := make([]byte, length)
mask := big.NewInt(0x1f)
tmp := new(big.Int).Set(v)
for i := length - 1; i >= 0; i-- {
out[i] = alphabet[new(big.Int).And(tmp, mask).Int64()]
tmp.Rsh(tmp, 5)
}
return string(out)
}
func ULID() string {
ts := big.NewInt(time.Now().UnixMilli())
b := make([]byte, 10)
rand.Read(b)
rnd := new(big.Int).SetBytes(b)
return encode(ts, 10) + encode(rnd, 16)
}
func MakeBetUID(serviceUID string) (string, error) {
if len(serviceUID) != 16 {
return "", errors.New("invalid serviceUID")
}
return serviceUID + "-" + ULID(), nil
}
MistakeCause
Uppercase ULIDForgot .toLowerCase()
Minting your own service_uidThe platform mints it; only use it
Using UUIDUse ULID (sortable)
_ as separatorUse a single -
No service_uid validationValidate when minting
CREATE TABLE bets (
bet_uid char(43) PRIMARY KEY,
service_uid char(16) NOT NULL,
...
);

char(43) is fixed-length — ideal for index performance. The service_uid can also be parsed out into its own column for easier querying.