License Keys Are Inventory

  • Concurrency
  • Inventory

Software used to come with keys. This was before subscriptions took over. You bought the thing once, you got a license key and you typed it into the software. IDM, McAfee, Visual Studio. The key was the proof that you paid.

I did not think much about those keys until I had to build the system that hands them out. Before that, a license key was just a thing you send after payment. I used to group license keys with the other things we send after payment.

One bucket for receipts, download links, confirmation emails, API credentials, invite links, license keys etc. The order is paid, so we send the thing and mark the fulfillment done.

Once I started designing a system that hands out license keys, I realised how wrong my intuitions were.

You can send a receipt twice. Many customers can open one download link. You can reuse an email template forever. You cannot do this with a license key. After a customer gets ABC-123, that unit is gone from inventory.

This sounds obvious in that form. But it is easy to miss in code, because the key still looks like a string.

"ABC-123-XYZ"

The database does not know the string is scarce. You must model it as scarce. The application does not know the key is used. Allocation must be a real state change. Support cannot explain the history from logs alone. You must record each movement.

That was the useful shift for me. License keys are not fulfillment metadata. They are stock.

A Small Failure Story

A merchant uploads five license keys for a product. Two customers buy the product at almost the same time.

The naive code does this:

SELECT id, key_value
FROM license_key
WHERE product_id = $1
  AND status = 'available'
LIMIT 1;

Then it sends the key and marks it used:

UPDATE license_key
SET status = 'used'
WHERE id = $1;

This is the shape of many early inventory bugs:

look at available stock
decide what to take
mark it taken

The gap between “look” and “mark” is the problem. In that gap, two requests can read the same key.

Local testing usually misses this because local tests are usually sequential - send one request, wait for it to finish, then send another. Real traffic, on the otherhand, does not do that. Two paid orders can enter the fulfillment path together, both read the oldest available key, and both email it.

Now the database says the key was used. The customer says they got an already-used key. Support asks which order actually owns it. The answer is awkward because our system never made ownership a first-class fact.

Put The Keys On A Shelf

I use a model with a pool and rows inside the pool. The pool is the shelf:

CREATE TABLE license_pool (
  id UUID PRIMARY KEY,
  merchant_id UUID NOT NULL,
  product_id UUID NOT NULL,
  status TEXT NOT NULL,

  total_keys INT NOT NULL DEFAULT 0,
  available_keys INT NOT NULL DEFAULT 0,
  used_keys INT NOT NULL DEFAULT 0,
  low_threshold INT,

  UNIQUE (merchant_id, product_id)
);

For one merchant and one product, there is one place to draw keys from. The unique constraint removes a question I do not want in the hot path: which pool does this order use?

The key table is the real inventory:

CREATE TABLE license_key (
  id UUID PRIMARY KEY,
  pool_id UUID NOT NULL REFERENCES license_pool(id),

  key_value_encrypted BYTEA NOT NULL,
  key_hash TEXT NOT NULL,

  status TEXT NOT NULL DEFAULT 'available',
  fulfillment_id UUID,
  allocated_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),

  UNIQUE (pool_id, key_hash)
);

One uploaded key becomes one row. If a merchant uploads 500 keys, the system stores 500 rows.

This is heavier than a JSON array or a text file. But rows give you what this problem needs: uniqueness, status, timestamps, indexes, and row-level locks.

The last one is the most important.

The Secret And The Fingerprint

The license value is sensitive. It is not a password, but it is still a secret the customer paid for. So we do not want plaintext keys in the database.

When a merchant uploads a key, we store two values:

key_value_encrypted
key_hash

The encrypted value is for delivery. To show or email the key, the system decrypts this value.

The hash is for duplicate detection. If someone uploads the same key twice into one pool, the unique constraint on (pool_id, key_hash) rejects it.

This split is necessary because good encryption is bad for dedupe. With a random nonce, the same key encrypts to a different ciphertext each time. That is good for secrecy and useless for uniqueness.

So the write path looks like this:

encrypted, err := encryptor.Encrypt([]byte(rawKey))
if err != nil {
    return err
}

keyHash := hash(rawKey)

key := LicenseKey{
    PoolID:            poolID,
    KeyValueEncrypted: encrypted,
    KeyHash:           keyHash,
    Status:            "available",
}

If the keys are short or easy to guess, I use a keyed hash (HMAC) instead of a plain hash. The principle is the same. Dedupe must not need the usable secret.

Claim A Key

This one query is the whole post:

WITH selected AS (
  SELECT id
  FROM license_key
  WHERE pool_id = $1
    AND status = 'available'
  ORDER BY created_at
  LIMIT 1
  FOR UPDATE SKIP LOCKED
)
UPDATE license_key
SET status = 'allocated',
    fulfillment_id = $2,
    allocated_at = now()
FROM selected
WHERE license_key.id = selected.id
RETURNING license_key.*;

The query is not for finding a key, rather for claiming one.

  • ORDER BY created_at gives the pool a predictable order. The system uses the oldest key first.
  • FOR UPDATE locks the selected row. Another transaction cannot claim the same unit.
  • SKIP LOCKED lets many workers move through the pool without a queue. If one transaction locks the oldest key, the next transaction skips it and takes another.
  • UPDATE ... RETURNING gives the application the row it actually claimed, not a hopeful candidate.

If the query returns no rows, there is no stock left.

I like this shape because the database does the arbitration. There is no Redis lock, no in-memory mutex, no custom “currently allocating” table. The inventory row is the lock.

With five keys and ten concurrent fulfillments, the result should be boring:

5 rows become allocated
5 requests get no key
0 keys are shared

That is the standard I want for scarce digital goods.

Ownership Matters More Than Status

An allocated status is not enough. Allocated to whom? This is why the key row holds fulfillment_id.

In this model, a paid order line creates a fulfillment. The system claims a key for that fulfillment. The fulfillment owns the delivery payload.

paid order line
  -> fulfillment
      -> allocated license key

Now the system can answer normal support questions: Which key did this order get? Can we send it again? Did allocation happen before the email failed? Did this key ever belong to a customer?

These are inventory questions. They should be database questions too.

The resend case exposes weak models. If email delivery fails after allocation, the retry must not pick a new key. It must send the same key, because the inventory already moved. So the allocation result must be durable before any notification retry.

Do Not Let Email Decide Inventory

One convenient order is bad:

claim key
send email
commit transaction

If the email goes out and the transaction rolls back, the customer holds a real key that the database still marks available. That is worse than a failed email. That is leaked stock.

I separate the facts:

claim key
store delivery payload on fulfillment
enqueue notification intent
commit

After the commit, a worker sends the email. If the email fails, the worker retries the notification. It does not reopen allocation.

This small change changes how the system fails. The fulfillment can now say:

this customer owns this key
notification is still pending

That is better than a guess from an SMTP timeout about whether the inventory moved.

Duplicate Payment Events

Payment and event systems retry by design. So the license path must survive the same order.paid event twice.

We could use two protections here.

First, the event handler records each consumed message. On replay, it skips a message it already handled.

Second, the fulfillment table blocks duplicate work for the same order line:

(order_id, product_id) -> one fulfillment

The first protection handles message delivery. The second handles the business rule.

I keep both because they fail in different places. If a bug bypasses message idempotency, the domain still refuses a second fulfillment for the same order and product. If a product is legitimately two line items, the rule needs a line-item identifier too. The uniqueness must match the thing you fulfill.

Without this, a duplicate event consumes a second key for the same customer. The issue then becomes less of a duplicate notification but more of a inventory loss.

Counters Are A Convenience

The pool counters are for people and operations:

total_keys
available_keys
used_keys

They make dashboards cheap. They make low-stock alerts easy. The merchant UI can show “3 keys left” without a scan of every row.

But the counters are not the deepest truth. The key rows are. If the counters drift, you can rebuild them from the rows:

SELECT status, count(*)
FROM license_key
WHERE pool_id = $1
GROUP BY status;

The counters must still be correct. Update them in the same transaction as the allocation:

claim key
update pool counters
complete fulfillment
enqueue notification
commit

If the transaction rolls back, the key claim rolls back too.

Product Decisions Hidden In The Table

When keys become inventory, some product questions appear.

  • Should checkout block a purchase when available_keys = 0?
  • Should a paid fulfillment wait for more stock, or fail now?
  • Should merchants get a warning at 10 keys, 5 keys, or a percentage of the pool?
  • Should a key ever be reserved before payment?

That last question needs a strict answer. We do not allocate a key during a read-only checkout preview. Allocation consumes real stock. An abandoned checkout must not burn a key.

If you really need reservation, model it as a separate state:

available -> reserved -> allocated

Add reserved_until and a worker that releases expired reservations. Do not use allocated to mean “maybe this customer pays later”. That makes the word useless at the moment you need it to be precise.

Revoked Is Also A State

Keys also need an exception path. A merchant revokes a key. The provider expires it. Support replaces it. A merchant uploads a key by mistake.

An intuitive approach may be to delete the row, but it erases too much. A revoked key can still explain a support case. An expired key can still explain a replaced fulfillment. An allocated key is part of the order history.

For inventory, a delete is, thus, not the clean state. Instead, we keep the row and use a named state.

available
allocated
revoked
expired

Only available keys can be claimed. Every other state is history or an exception.

The Final Shape

Here is the model in short:

one row per key
encrypted value for delivery
hash for duplicate detection
status for the lifecycle
fulfillment_id for ownership
row lock for allocation
outbox or post-commit worker for notification

Closing Thought

If I can be honest, the design does add ceremony. There are more states, more constraints, and more transaction boundaries than “grab the next key from a list”. But that ceremony buys one concrete thing: two successful fulfillments cannot share one key.

In a production system, we want it to be structurally hard.