An ATM withdrawal looks like a simple request and response: insert card, enter PIN, take cash. Underneath is a distributed transaction across at least four organizations, with secret keys in tamper-resistant hardware, a message format older than the web, and one physical side effect that cannot be rolled back. Once notes leave the dispenser, no database rollback returns them.
This article follows one withdrawal from the card reader to the issuer's ledger and back. It explains the components and how keys protect the PIN, then spends most of its time on the case that makes ATM systems hard: the moment when one party does not know whether the transaction happened. It ends with stand-in processing, reconciliation, fraud defences and a checklist. General payment-system design, state machines and provider calls are covered in designing a payment system.
The parties and the path
Four roles appear in almost every ATM transaction. The terminal is the machine with its devices and application. The acquirer owns or operates the terminal and runs an ATM host that drives it. A switch or card network routes the request to the bank that issued the card. The issuer checks the PIN, the card and the account, and approves or declines. When the card belongs to the same bank that runs the ATM, acquirer and issuer are the same organization and the network leg disappears, but the structure stays the same.
Each hop has its own timeout, retry policy and log. The design problem is to make the four logs agree on whether cash left the machine and whether the account was debited, even when hops fail mid-flight.
Inside the terminal
An ATM is a PC running an application on top of a device abstraction layer. The long-standing industry standard for that layer is CEN/XFS, with a newer successor called XFS4IoT; both give the application a vendor-neutral interface to the card reader, the encrypting PIN pad (EPP), the cash dispenser, the receipt and journal printers and sensors. The application drives screens and decides the flow.
Many deployments keep the terminal deliberately thin. The host sends it screen and state definitions and makes every decision, and the terminal reports what the customer did and what the devices did. Proprietary terminal-driving protocols of this kind are common; their message formats vary by vendor and are not covered here. The principle that matters is that the host, not the terminal, holds the business logic, and the terminal is the only witness to what physically happened.
The electronic journal is that witness's record. Every card read, every host message, every dispenser command and every device error is written locally, with sequence numbers, before and after each step. In a dispute, the journal and the dispenser's own counters are the evidence.
Protecting the PIN: blocks, keys and the HSM
The PIN is encrypted inside the EPP the moment it is typed, and from then on it exists in the clear only inside hardware security modules. The EPP formats it as a PIN block defined by ISO 9564. In the widely used format 0, the PIN with its length is padded to 16 hexadecimal digits and XORed with 12 digits of the account number, so the same PIN gives a different block on different cards. Newer AES-based deployments use format 4.
# ISO 9564 format 0 PIN block, for understanding only. Real PIN blocks are built inside the EPP.
def pin_block_format0(pin: str, pan: str) -> str:
pin_field = f"0{len(pin):X}{pin}".ljust(16, "F") # 0, length, PIN, F padding
pan_field = "0000" + pan[:-1][-12:] # rightmost 12 digits excluding check digit
return f"{int(pin_field, 16) ^ int(pan_field, 16):016X}"
print(pin_block_format0("1234", "4000001234567899")) # test values onlyThe block is encrypted under a terminal PIN key. That key is protected by a terminal master key, which modern estates load remotely using asymmetric key exchange as described in ANSI X9 TR-34, instead of two custodians typing key components on site. Keys travelling between systems are wrapped in TR-31 key blocks, which bind each key to its permitted usage so a PIN key cannot be misused as a data key.
At the ATM host, the encrypted PIN block goes into the acquirer's HSM, which decrypts it under the terminal key and re-encrypts it under the zone PIN key shared with the switch. The switch and issuer repeat the translation, and the issuer's HSM verifies the PIN. Application servers only ever handle ciphertext, which is why their compromise does not expose PINs. For key-management patterns more broadly, see envelope encryption with a KMS.
The authorization message
The host-to-switch and switch-to-issuer legs speak ISO 8583: a message type indicator (MTI), a bitmap saying which fields are present, and the fields themselves. A cash withdrawal is usually a single-message financial request, MTI 0200, answered by 0210, which authorizes and posts the debit in one exchange.
| Field | Content | Why it matters |
|---|---|---|
| 2 | Primary account number | Routing to the issuer; often tokenized or masked in logs |
| 3 | Processing code | Identifies a cash withdrawal and the account type |
| 4 | Amount | In minor units of the transaction currency |
| 11 | System trace audit number (STAN) | Matches requests, replies and reversals |
| 37 | Retrieval reference number | Ties the transaction across systems for disputes |
| 39 | Response code | 00 approves; other codes decline with a reason |
| 41 | Terminal ID | Part of the identity of the transaction |
| 52 | PIN data | The encrypted PIN block |
| 55 | Chip (EMV) data | Cryptogram the issuer verifies, and its response cryptogram |
With chip cards, the card itself generates an authorization request cryptogram over the transaction details. The issuer verifies it with the card's keys, proving the card is genuine and the amount was not changed, and returns a response cryptogram the card checks. Exact field contents and sub-elements vary by network, so treat the table as the common core, not a specification.
Worked example: a withdrawal that goes wrong
A customer asks for 200 at 14:02:10. The terminal builds the request with STAN 004711 and the host sends a 0200. The issuer approves and debits the account at 14:02:11, and the 0210 travels back. Then one of three things happens.
- Normal. The terminal receives approval, dispenses four notes, the customer takes them, and the journal records success. Done.
- Timeout. The 0210 is lost between switch and host. The terminal waits up to its timeout, shows an error and dispenses nothing. The issuer has debited 200 that the customer never received.
- Dispense fault. Approval arrives, but the dispenser jams after presenting two notes. The customer took 100; the account was debited 200.
Both failure cases leave the system in a state where no single party knows the truth. The terminal knows what it dispensed but not whether the issuer debited. The issuer knows it debited but not what was dispensed. The fix is a reversal: an advice from the acquirer side, MTI 0420, telling the issuer to undo all or part of the transaction. A partial reversal carries the amount actually dispensed, so the issuer can adjust the debit from 200 to 100. The issuer acknowledges with 0430.
Making reversals reliable
A reversal that gets lost is money taken from a customer. So reversals are not fire-and-forget requests; they are store-and-forward advices. The terminal or host writes the reversal to durable storage before sending, and keeps resending until it receives an acknowledgement, marking repeats so the receiver can tell a retry from a new advice (ISO 8583 uses repeat MTIs such as 0421 for this). The terminal logic is a small state machine whose every transition is journalled first.
def withdraw(txn):
journal.write("REQUEST", txn) # durable before anything leaves
try:
reply = host.authorize(txn, timeout=30)
except Timeout:
journal.write("TIMEOUT", txn)
reversals.enqueue(txn, dispensed=0) # we may have been debited: undo it
return show("Unable to complete")
if reply.code != "00":
return show(decline_message(reply.code))
journal.write("APPROVED", txn, reply)
result = dispenser.dispense(txn.amount) # returns what was actually presented
journal.write("DISPENSED", txn, result)
if result.presented < txn.amount:
reversals.enqueue(txn, dispensed=result.presented) # partial or full reversal
if result.retracted:
journal.write("RETRACTED", txn, result) # cash not taken, pulled back
reversals.enqueue(txn, dispensed=0)
def reversal_sender():
for r in reversals.pending(): # survives reboots: stored on disk
if host.send_reversal(r, repeat=r.attempts > 0).acknowledged:
reversals.mark_done(r)
else:
reversals.backoff(r)The pattern is the same one used for reliable events anywhere: record the intent durably, then deliver it at least once. It is the terminal-side cousin of the transactional outbox.
Host-side idempotency
At-least-once delivery means the issuer and switch see duplicates: repeated requests after a timeout and repeated reversals. Each must be applied at most once. The key is the identity of the original transaction, typically built from the terminal ID, STAN, transmission date and time and acquirer identity, depending on network rules.
def on_reversal(msg):
key = (msg.acquirer_id, msg.terminal_id, msg.original_stan, msg.original_datetime)
with db.transaction():
original = db.find_authorization(key, for_update=True)
if original is None:
# Reversal overtook its request, or the request never arrived.
db.insert_tombstone(key, msg.dispensed_amount) # a late 0200 must then be declined
return ack(msg)
if original.reversed:
return ack(msg) # duplicate: acknowledge, do nothing
db.credit(original.account, original.amount - msg.dispensed_amount)
db.mark_reversed(original, msg.dispensed_amount)
return ack(msg)Two details deserve attention. The tombstone handles a reversal that arrives before the request it cancels, which happens when the request was delayed in a queue; without it, the late request would debit an account whose transaction has already been abandoned. And the acknowledgement is sent for duplicates too, or the sender retries forever. The general technique is described in idempotency.
Stand-in processing
Issuers go down for maintenance and outages. Rather than decline every withdrawal, a switch can approve on the issuer's behalf, called stand-in processing, using rules the issuer has agreed: per-card limits, velocity checks and sometimes a negative file of blocked cards. The switch records each stand-in decision and sends it to the issuer as an advice when it returns, and the issuer posts the debits.
Stand-in is a deliberate trade between availability and risk. The issuer accepts that some stand-in approvals will exceed a balance, in exchange for customers getting cash during an outage. The limits should be small and visible, and the advice queue must be as durable as the reversal queue, because a lost advice is an undebited withdrawal.
Reconciliation and cash accounting
Real-time messages are not the final word. Each day, the acquirer reconciles four records: its host log, the switch's clearing file, the terminal journals and the physical cash. When a cassette is replenished, counted notes in, dispensed notes per the journal and the dispenser's counters, and notes left over must balance. A shortfall points to a dispense fault that was not reversed; an overage points to a reversal for cash that was in fact taken.
Unmatched items become exceptions worked by an operations team, using the journal as evidence. Disputes from customers who say they did not receive cash are resolved the same way. Designing for reconciliation means every record carries the shared identifiers, terminal ID, STAN and retrieval reference number, so that matching is a join rather than an investigation. The broader money-movement version of this process is in the saga pattern and the payment system design above.
Fraud and physical attacks
ATM security is unusual because the attacker can touch the machine. Defences live in several layers: anti-skimming hardware and card-reader monitoring against copied cards, chip cryptograms that make copied magnetic stripes useless where chip is enforced, encrypted and authenticated communication between the PC and the dispenser so that a device plugged into the dispenser cable cannot command it, application allow-listing and locked-down boot on the PC, and host-side velocity and anomaly checks. Alarms for door opening and cassette removal tie the physical and logical sides together.
Trade-offs
- Timeout length. Long timeouts reduce false reversals but make customers wait; short ones create more reversals for transactions that actually succeeded.
- Thin versus thick terminals. Host-driven terminals are easy to change centrally but cannot work during a host outage; richer terminals add risk and a larger attack surface.
- Stand-in generosity. Higher stand-in limits keep customers happy during outages and increase credit exposure.
- Reconciliation frequency. Daily batches are cheap; intraday matching catches faults before a cassette runs out but needs more infrastructure.
What to do next
- Draw your own withdrawal path with every hop, timeout and log, and mark where a reply can be lost.
- List every state in which a party does not know the outcome, and name the message that resolves each one.
- Make reversals and stand-in advices store-and-forward with durable queues, repeat flags and alerting on queue age.
- Make the issuer's reversal handler idempotent, including the reversal-before-request case, and test it with injected duplicates.
- Confirm PINs exist in the clear only inside EPPs and HSMs, and that keys travel in usage-bound key blocks.
- Automate the cassette balance check per replenishment, joining journal, host log and counters on shared identifiers.