Part 2 of 7 · Gift card ledger series ~6 min read

How a gift card gets issued

Every gift card starts the same way: somebody pays for one, and the business now owes that amount back. Get the issue step wrong and everything downstream is built on sand — a duplicate code, a balance stored as a rounded dollar figure, or a card that exists in the till but not in the ledger. This post walks through how a card gets issued: a unique code that is awkward to guess, an opening balance held in integer cents, a conditional write that refuses to overwrite an existing card, and the first row appended to an immutable ledger.

Key takeaways

  • A card code is a bearer instrument, so it’s minted from a cryptographic random source and formatted to be awkward to guess and awkward to mistype.
  • The opening balance is set once, in integer cents, from the amount actually paid — never a rounded dollar figure.
  • The card is written with a strict “only if this code does not already exist” condition, so a code collision can never overwrite a live card.
  • Issuing the card and appending its opening ledger row happen as one DynamoDB transaction — both land, or neither does.
  • A retry of the same issue request is idempotent: it returns the existing card rather than minting a second one or charging twice.

Minting a code that resists guessing

The moment a card is sold, its code becomes money. Anyone who holds the code can spend the balance, so the first job of issuing is to mint a code that is hard to guess from another one and hard to stumble onto at random. A sequential number — card 1001, card 1002 — is the worst case: knowing one card tells you a hundred others. Instead the issue step pulls bytes from a cryptographic random source and encodes them into a code like GC-7K2M-4QYX: a fixed prefix so staff recognise it, then two groups drawn from an alphabet that drops the easily-confused characters (no 0/O, no 1/I/l) so it survives being read aloud or typed off a printed slip.

The code space is large enough that guessing is hopeless — far more combinations than a business will ever issue — so an attacker cannot simply try codes until one has a balance. That property matters again in part 4, where the fraud caps assume a leaked code is the realistic threat, not a guessed one.

Setting the balance in cents, once

The opening balance is whatever the customer actually paid, stored as an integer number of cents. A $50 card is 5000, a $25 card is 2500, a $37.50 promotional card is 3750. There is no floating-point anywhere near a balance: 0.1 + 0.2 famously is not 0.3 in a float, and a ledger that holds other people’s money cannot afford that drift accumulating over thousands of redemptions. Every figure in the system — balances, spends, caps, breakage — is an integer count of cents, and only ever formatted as dollars for display on a receipt.

How a gift card gets issued: from a sale to a card row and a ledger row written together A vertical flow inside the dotted AWS account container. At the top, an external box, "Point of sale", sends a sell request with the amount paid — for example $50 — down into AWS. The first component, "gcr-issue Lambda", does three things in sequence shown as a stack: mint a unique code from a cryptographic random source; convert the amount paid to integer cents (5000); and set the expiry date from policy. An arrow leads down to a box labelled "One DynamoDB transaction", drawn as a container holding two writes side by side. The left write is "Put card row" into the cards table with a condition: attribute_not_exists(code) — only if this code does not already exist. The right write is "Append opening row" into the append-only ledger table. A brace shows that both writes commit together or neither does. Below the transaction, two arrows fan out: one to "SES receipt" emailing the customer the new card and balance, and one back up to the point of sale returning the code. A side note reads: a repeat of the same request returns the existing card — it never mints a second card or charges twice. A bottom note reads: balance stored as integer cents, never a float. Point of sale sell request — amount paid $50 AWS account gcr-issue Lambda 1. mint code → GC-7K2M-4QYX 2. amount paid → 5000 cents 3. set expiry from policy One DynamoDB transaction Put card row if attribute_not_exists (code) Append opening row into append-only ledger table SES issue receipt code returned to till A repeat of the same request returns the existing card — it never mints a second or charges twice.
Fig 2. The issue step mints a code, converts the amount paid to cents, and sets the expiry, then writes the card row and the opening ledger row in one transaction guarded by attribute_not_exists(code). A repeat request is idempotent.

Writing the card without clobbering one

Two writes have to land together: the card itself in the cards table, and its opening row in the ledger. The issue step does them as a single DynamoDB transaction, so either both commit or neither does — there is no state where a card exists with no ledger history, or a ledger row points at a card that was never written.

The card write carries a condition: attribute_not_exists(code). It means “create this card only if no card with this code already exists.” The code space is enormous, so a genuine collision is astronomically unlikely — but “astronomically unlikely” is not “impossible”, and a system holding money should never silently overwrite a live balance. If the condition fails, the issue step simply mints a fresh code and tries again, and nobody’s balance is touched.

Making a retry safe

Tills lose connectivity mid-sale. A cashier taps “sell”, the network drops before the response gets back, and the natural reaction is to tap it again. Without care that mints two cards and, worse, could charge the customer twice. The issue step is therefore idempotent: every sell request carries a client-supplied request id, and the step records that id against the card it created. A repeat with the same id returns the card that already exists — same code, same balance — rather than minting a second one. The till sees one card, the customer is charged once, and the ledger has exactly one opening row.

Design rules for issuing

  • Codes come from a cryptographic random source, not a sequence, and skip easily-confused characters.
  • The opening balance is integer cents taken from the amount actually paid.
  • The card row and its ledger row are written in one transaction — both or neither.
  • A strict attribute_not_exists(code) condition means a code can never overwrite a live card.
  • A repeat sell request is idempotent by request id: one card, one charge, one opening row.
All posts