How it is built
Architecture overview
A point-of-sale (POS) system turns what happens at a counter (items scanned, a card tapped, a tip added, a refund given) into a correct record of money moved and stock sold, even when the network, the card reader or the power fails in the middle. It has two halves that fail differently. The front half is a terminal in a shop: a tablet or register, a card reader, a receipt printer, a local database, and a flaky uplink. The back half is a service that keeps the merchant's catalogue, orders, payments and reports, talks to the card network through an acquirer, and feeds accounting, inventory and loyalty. Everything in between is about one promise: a sale is recorded exactly once, a card is charged exactly once, and a crash at any point leaves a state that can be explained and repaired. Square is the best-documented example (its API docs describe the order, payment, terminal and webhook model), and the open-source ERP Odoo ships a POS module that follows the same shape at smaller scale.
Parts:
- Card path — reads the card, authenticates the chip, protects the PIN and the card number, speaks the bank's message format — emv-cryptograms (no page yet), dukpt-key-derivation (no page yet), iso-8583-messages (no page yet), Format-preserving encryption, constant-time-code (no page yet), csprng (no page yet)
- Payment engine — one persisted state machine per payment: authorize, capture, void, refund; retries are safe — Payment state machine, built on Hierarchical state machine, Idempotency keys, Exponential backoff with jitter
- Money and totals — amounts, tax, tips and splits that add up to the cent — Money representation, largest-remainder-rounding (no page yet)
- Local store and offline sync — the terminal's own database and its catch-up protocol with the server — Incremental sync cursors, built on Message sequence numbers, cursor-pagination (no page yet), Write-ahead log; CRDTs, built on Vector clocks, Lamport clocks, Fractional indexing; B+ tree, Optimistic concurrency control, CRC32C checksums
- Event backbone — tells inventory, accounting, loyalty and receipts what happened, without losing or doubling events — Transactional outbox, built on Change data capture, Event deduplication, Consumer offsets, Event sourcing, Segmented log
- Secure transport — every terminal-to-server link is encrypted and every terminal has an identity — TLS 1.3, built on AEAD (AES-GCM, ChaCha20), ECDH key exchange, HKDF key derivation, x509-certificates (no page yet), SHA-2 and BLAKE3; Mutual TLS
- Durability and recovery — the server's data survives a crash, a lost node and an operator mistake — Primary-backup replication, built on Leases, Health checking; Point-in-time recovery, built on ARIES recovery, Buffer pool, Object storage, copy-on-write-snapshots (no page yet); Fencing tokens
- Verification — finds the bugs that only appear in rare interleavings and malformed inputs — Deterministic simulation testing, built on PRNG: PCG and xoshiro, record-replay (no page yet), fault-injection (no page yet); Coverage-guided fuzzing, built on Code coverage instrumentation, sanitizers (no page yet), delta-debugging (no page yet)
- Operations — keeps the services running and replaceable without dropped sales — Process supervision, Graceful draining, hdr-histogram (no page yet)
shop cloud
┌───────────────────────────────────────┐ TLS 1.3 / mTLS ┌──────────────────────────────────────┐
│ scanner/UI ─► order ─► payment engine │◄──────────────────►│ API ─► payment engine ─► acquirer │
│ │ │ │ sync cursor, │ │ (ISO 8583 style messages)│
│ local DB (WAL) ▼ │ idempotency keys │ ledger/DB ─► outbox ─► CDC │
│ │ card reader │ │ │ │ │
│ │ (EMV, PIN, DUKPT) │ │ replicas + PITR event log │
└───────────────────────────────────────┘ │ ┌──────┴──────┐ │
│ inventory accounting loyalty│
└──────────────────────────────────────┘
A POS system is therefore a small distributed system with an unreliable edge. The money path (card path, payment engine) is designed for correctness first; the read path (reports, dashboards) can lag.
How the parts fit together
Card path
The terminal never sees more of a card than it must. With a chip card, the card itself computes a cryptogram over the transaction data (EMV's ARQC for an online authorisation request; the issuer's answer can carry its own cryptogram, the ARPC), so the issuer can tell a genuine card from a copied magnetic stripe. The primitives behind that are EMV session-key derivation and MAC checks (emv-cryptograms, no page yet). The PIN, if entered, is encrypted inside the reader's tamper-resistant hardware. Readers typically derive a fresh key per transaction from a base key with DUKPT (dukpt-key-derivation, no page yet; ANSI X9.24), so stealing one terminal's current key does not decrypt earlier transactions.
Between the terminal, the acquirer and the card network, authorisation messages follow the ISO 8583 family (iso-8583-messages, no page yet): a message type indicator, a bitmap that says which data elements are present, then the elements themselves. Requests, advices and reversals are different message types; the reversal matters for the payment engine below, because "I asked and heard nothing" must be answerable with "undo it".
Two rules keep the blast radius small. First, the card number is stored, if at all, encrypted or replaced by a token; Format-preserving encryption lets a 16-digit number become another 16-digit string so that old fields, validators and reports keep working. Second, anything that compares secrets (MACs, PIN blocks) must be constant-time (constant-time-code, no page yet) and every key and nonce must come from the operating system's secure random source (csprng, no page yet). The PCI Security Standards Council publishes the rules that decide how much of this a merchant system has to implement itself (see Sources).
Payment engine
The Payment state machine is the heart of the money path. A payment is a persisted object with states such as created, authorized, captured, voided, refunded and failed. The central design decision is how to treat a timeout: it is not a failure, it is "unknown". The engine records the unknown state, then asks the acquirer for the outcome or sends a reversal, and only then moves on. Nested states keep this readable: a Hierarchical state machine lets "any in-flight state" share one handler for "connection lost" instead of repeating it in every state.
Every call that changes money carries an Idempotency key: a client-chosen unique string for the logical operation, stored with the request fingerprint and the final response, so a retry returns the first result rather than charging again. Square documents the same pattern for its APIs (an idempotency key on create calls) and Stripe's engineering blog explains why (see Sources). Retries use Exponential backoff with jitter, so a hundred terminals that lost the same uplink do not retry in lockstep and knock over the recovering service.
Contracts with its neighbours:
- To the card path: one authorisation request per attempt; a reversal for every authorisation whose outcome is unknown; the network's response code is stored verbatim next to the engine's own state.
- To the order: a payment belongs to one order; the order cannot be closed until captured amounts equal the total, tips included. Tip adjustment is a capture for a different amount than the authorisation, within the network's rules.
- To the event backbone: every state change is also an event (below), written in the same transaction.
Money and totals
Money representation fixes the unit: integer minor units (cents) plus an explicit currency, never floats. Taxes, discounts and service charges are computed in wider integers and rounded once, with a named rounding mode, at the point the rules say. When an amount has to be divided (a bill split four ways, a tip pooled among staff, a discount spread over line items), the parts must sum to the whole exactly; the largest-remainder method (largest-remainder-rounding, no page yet) hands the leftover cents to the parts with the biggest fractional remainders. The reason to be strict is auditing: a one-cent drift per sale across millions of sales is a reconciliation break that nobody can explain.
Local store and offline sync
The terminal keeps its own database so a sale can finish with no network. That database is crash-safe for the reasons in Write-ahead log (log, checksum, fsync, then update pages), usually built on a B+ tree for catalogue lookups, with CRC32C checksums on blocks and log records to detect torn writes.
The sync protocol is the contract between terminal and server. With Incremental sync cursors the terminal holds one opaque token; each pull returns the changes after it plus the next token, so sync cost scales with what changed, not with the size of the catalogue. Message sequence numbers on the push side let the server detect gaps and duplicates in the terminal's outgoing sales; cursor-pagination (no page yet) bounds each page. Sales are append-only facts and are rarely in conflict. Configuration data that two people can edit while offline (a menu item's price at the shop and at head office) is where conflict handling begins: CRDTs with Vector clocks or Lamport clocks give merge rules that converge without coordination, and Fractional indexing keeps a re-ordered menu to one changed row. Many systems skip CRDTs and choose a plain rule ("head office wins, last write per field") with Optimistic concurrency control: read a version, write only if it is unchanged.
Event backbone
Inventory, accounting, loyalty and receipts must learn about a sale, and none of them may be allowed to block it. The Transactional outbox writes the event as a row in the same local transaction as the state change; a relay publishes it afterwards at-least-once. Either the sale and its event both exist or neither does. The relay can be a poller or Change data capture, which reads the database's own commit log in commit order with a resume position. Consumers receive duplicates by design, so they apply Event deduplication (a time-windowed set of event ids) and track progress with Consumer offsets. Where the business wants history rather than state, Event sourcing keeps the order or payment as an append-only stream of events and derives current state by folding over it; the Segmented log is the storage shape of such a stream. The loyalty system is one such consumer.
Secure transport
Every link from terminal to server is TLS 1.3: one round trip to set up, forward-secret key exchange (ECDH key exchange, keys expanded with HKDF key derivation) and authenticated encryption (AEAD), with little to misconfigure. Server certificates follow x509-certificates (no page yet). Terminals are not anonymous browsers, so Mutual TLS gives each one a rotatable client certificate, and the server can revoke a stolen terminal by revoking its identity. SHA-2 and BLAKE3 cover fingerprints, request signing and webhook verification (Square documents how to verify webhook notifications; see Sources).
Durability and recovery
On the server, Primary-backup replication orders all writes on one node and ships them to standbys; the acknowledgement rule (none, one, all) sets what a crash can lose. For payments the rule is strict, because an acknowledged capture that disappears is a financial incident. Leases decide which node is primary, Health checking decides when to fail over, and Fencing tokens make sure a deposed primary that wakes up late cannot write. Point-in-time recovery (base backups plus continuous log archive, with Object storage as the target and ARIES recovery as the crash-recovery model beneath it) answers the human failure: rebuild the state as of ten minutes before the bad deploy. The Buffer pool is where the database decides which dirty pages reach disk and when, which is why its write ordering is part of the durability story.
Verification
Deterministic simulation testing runs the whole system under a seeded scheduler with simulated network, disk and clock, so any failing run replays from its seed (PRNG; record-replay and fault-injection have no page yet). Coverage-guided fuzzing attacks the parsers that receive outside bytes, such as the ISO 8583 decoder, using Code coverage instrumentation; delta-debugging (no page yet) minimises failing inputs.
Operations
Process supervision restarts crashed services under a delay and burst limit. Graceful draining lets a service being replaced finish in-flight work and tell clients to reconnect; terminals resume from their cursor. hdr-histogram (no page yet) records authorisation latency percentiles with bounded error.
Data and control flow
One card sale, online:
- The cashier builds an order; totals and tax are computed in integer cents and written to the local database.
- The terminal creates a payment with a fresh idempotency key and sends it over mutual TLS.
- The server records the payment as created, then asks the acquirer to authorise (ISO 8583 style request).
- The reader runs the chip dialogue and PIN entry; the cryptogram travels with the request.
- The response moves the payment to authorized or failed. If the reply does not arrive, the state is unknown and a reversal or status query follows with the same key.
- Capture (immediately or at close of batch) moves the payment to captured and closes the order. The state change and its event commit in one transaction.
- The relay publishes the event; inventory, accounting, loyalty and the receipt service each apply it once.
One sale, offline:
- The order is stored locally; the card is accepted within the offline limits, or the sale is cash.
- The terminal queues the payment with its sequence number.
- When the uplink returns, the terminal pushes the queue in order, then pulls changes using its cursor.
- The server replays the payments through the same state machine; declines after the fact become exceptions for the merchant to handle.
Scaling
Limits, in the order they bite, and the lever for each:
- Per-terminal latency (the customer is waiting) is bound by the acquirer round trip, not by your code. Levers: keep connections open, avoid extra hops, measure percentiles, keep authorisation off the critical path of anything that can wait.
- Peak hour per merchant (lunch rush, Black Friday): the payment engine is a write path with strict ordering per payment. Lever: partition by merchant or payment id; the state machine needs no ordering across payments.
- Fan-out of events grows with the number of consumers. Levers: the commit log with consumer offsets lets each consumer proceed at its own pace; add consumers without touching the write path.
- Sync storms when a region comes back online and thousands of terminals pull at once. Levers: cursors keep each pull small, jittered backoff spreads the retries, draining protects a restarting node. No published figure is used here; measure your own peak. The shape is stable, though: one location is a low-rate write path, a platform of many merchants is a high-rate one, so partitioning by merchant is the first scaling step and rarely the last.
Failure modes
| Failure | Symptom | Cause | Fix |
|---|---|---|---|
| Double charge | customer charged twice, one order | retry without an idempotency key, or key reused across different amounts | key per logical operation, store the request fingerprint, reject a key reused with a different body |
| Lost response | payment shows failed but the money moved | a timeout treated as a decline | persist "unknown", query status or reverse before any new attempt |
| Sale without event | order paid, inventory not updated | event published outside the transaction, crash between the two | transactional outbox; relay at-least-once |
| Duplicate event | stock decremented twice | at-least-once delivery, consumer not idempotent | event ids plus a deduplication window |
| Cent drift | daily totals differ from the bank by small amounts | floats, or rounding per line instead of once | integer minor units, rounding mode named in one place, largest-remainder for splits |
| Offline pile-up | many declines after reconnect | offline limits too high, stale deny lists | configurable limits, report offline exposure, forward in sequence order |
| Split brain | two primaries accept payments | failover without fencing | leases plus fencing tokens checked by storage |
| Stolen terminal | fraudulent requests with a valid identity | long-lived client certificate | short-lived mTLS certificates, revocation, per-terminal limits |
Real implementations
| name | what it is | repo | licence | notes |
|---|---|---|---|---|
| Square | hosted POS with documented APIs for orders, payments, terminal and webhooks | Square developer docs | proprietary service | the reference for the API shape; Terminal API, Sandbox |
| Odoo Point of Sale | POS module of an open-source ERP, runs a web UI with offline mode | odoo/odoo | see repo | one codebase for POS, stock and accounting |
| Floreant POS | open-source restaurant POS | floreantpos/floreantpos | see repo | desktop application, local database |
| Apache OFBiz | open-source business framework with POS, orders and accounting | apache/ofbiz-framework | see repo | enterprise framework rather than a counter product |
| Debezium | change data capture for the outbox relay | debezium/debezium | see repo | reads the database commit log |
| Apache Kafka | partitioned commit log for the event backbone | apache/kafka | see repo | consumer offsets and retention as described above |
The alternative: let a payment provider own the card path and payment state machine and keep only orders and sync. You integrate through its API and webhooks instead of ISO 8583 and EMV, and most PCI scope moves to the provider. The price is fees, their outage becoming yours, and less control of offline behaviour.
Sources
- Square, Idempotency (common API patterns): docs; Webhooks: docs; Terminal API: docs.
- Stripe, Designing robust and predictable APIs with idempotency: blog.
- EMVCo, EMV contact chip technology: emvco.com.
- ISO 8583 overview: Wikipedia (message type indicator, bitmap, data elements).
- PCI Security Standards Council: pcisecuritystandards.org.
- The storage, security and distributed-systems blocks above: see their own pages for their sources.