Payment Card Transaction Internals
Payment Card Transaction Internals
When you tap your card at a coffee shop, you see a single event: the terminal beeps, a receipt prints, you leave with coffee. Behind that 2-second interaction, a message traverses at least four organizations, crosses multiple encryption boundaries, undergoes fraud scoring, triggers a real-time balance check, and initiates a multi-day settlement process that eventually moves actual money between banks.
This chapter dissects each layer.
The Four-Party Model
Every card payment involves four parties (five if you count the network separately):
-
Cardholder: The person paying. Their card carries a PAN, cryptographic keys, and (if EMV) a chip that generates per-transaction cryptograms.
-
Merchant: Accepts payment. Their POS terminal or e-commerce platform constructs an authorization request, encrypts sensitive fields, and sends it to their acquirer.
-
Acquirer (Merchant’s Bank): Routes the authorization request to the card network. Takes on the risk that the merchant is legitimate. Deposits settlement funds into the merchant’s account.
-
Issuer (Cardholder’s Bank): Receives the authorization request, checks the cardholder’s account (balance, credit limit, fraud signals), and approves or declines. Bears the fraud liability in most cases.
-
Card Network (Visa, Mastercard, etc.): Routes messages between acquirer and issuer. Sets interchange rates. Operates the clearing and settlement system.
ISO 8583: The Payment Message Protocol
ISO 8583 is the binary message format used for financial transaction messages between acquirers and card networks. It predates REST, gRPC, and even HTTP. Every card authorization, reversal, chargeback, and settlement message uses this format.
Message Structure
An ISO 8583 message has three parts:
- Message Type Indicator (MTI): 4-digit code identifying the message
- Bitmap: 64 or 128 bits indicating which data elements are present
- Data Elements (DE): The actual fields, variable-length, packed in binary
from dataclasses import dataclass
from enum import Enum
import struct
class MessageType(Enum):
AUTH_REQUEST = "0100" # Authorization request
AUTH_RESPONSE = "0110" # Authorization response
AUTH_REVERSAL = "0400" # Reversal (void)
AUTH_REVERSAL_RESP = "0410" # Reversal response
FINANCIAL_REQUEST = "0200" # Purchase / cash advance
FINANCIAL_RESPONSE = "0210" # Purchase response
NETWORK_MGMT = "0800" # Network management (sign-on, key exchange)
@dataclass
class ISO8583Message:
"""
Represents a parsed ISO 8583 message.
In production, use a library like py8583 or jPOS.
This implementation shows the internal structure.
"""
mti: str
bitmap: int # 64-bit primary bitmap (128-bit if DE1 is set)
data_elements: dict[int, bytes]
def has_field(self, field_number: int) -> bool:
"""Check if a data element is present using the bitmap."""
if field_number <= 64:
return bool(self.bitmap & (1 << (64 - field_number)))
return bool(self.bitmap & (1 << (128 - field_number)))
# Key data elements in a typical authorization request
AUTHORIZATION_FIELDS = {
2: "Primary Account Number (PAN)", # Up to 19 digits
3: "Processing Code", # 6 digits: txn type + accounts
4: "Transaction Amount", # 12 digits, minor units
7: "Transmission Date and Time", # MMDDhhmmss
11: "Systems Trace Audit Number (STAN)", # 6 digits, unique per terminal
12: "Local Transaction Time", # hhmmss
13: "Local Transaction Date", # MMDD
14: "Expiration Date", # YYMM
22: "POS Entry Mode", # How card data was captured
23: "Card Sequence Number", # For multi-card accounts
25: "POS Condition Code", # e-commerce, MOTO, etc.
26: "PIN Capture Code", # Length of PIN captured
32: "Acquiring Institution ID", # Acquirer BIN
35: "Track 2 Data", # Magnetic stripe equivalent
37: "Retrieval Reference Number", # Unique transaction reference
38: "Authorization ID Response", # 6-char approval code
39: "Response Code", # "00" = approved, "05" = declined
41: "Card Acceptor Terminal ID", # Terminal identifier
42: "Card Acceptor ID Code", # Merchant identifier
43: "Card Acceptor Name/Location", # Merchant name + city + country
48: "Additional Data", # EMV data, 3DS data, tokens
49: "Transaction Currency Code", # ISO 4217 (840 = USD, 978 = EUR)
52: "PIN Data", # Encrypted PIN block (8 bytes)
55: "ICC System Related Data", # EMV chip data (TLV-encoded)
}
A Real Authorization Flow
Here’s what happens at the protocol level when you tap your card:
def build_authorization_request(
pan: str,
amount_cents: int,
currency_code: str,
terminal_id: str,
merchant_id: str,
emv_data: bytes,
pin_block: bytes | None = None
) -> ISO8583Message:
"""
Build an ISO 8583 0100 (authorization request) message.
This is the message the acquirer sends to the card network
after receiving the transaction from the merchant's terminal.
"""
from datetime import datetime
now = datetime.utcnow()
elements = {
2: pan.encode('ascii'),
3: b'000000', # Purchase, default accounts
4: str(amount_cents).zfill(12).encode('ascii'),
7: now.strftime('%m%d%H%M%S').encode('ascii'),
11: b'000001', # STAN — would be sequential in production
12: now.strftime('%H%M%S').encode('ascii'),
13: now.strftime('%m%d').encode('ascii'),
22: b'071', # ICC + contactless
25: b'00', # Normal presentment
32: b'123456', # Acquirer BIN
37: b'000000000001', # Retrieval reference
41: terminal_id.encode('ascii'),
42: merchant_id.ljust(15).encode('ascii'),
49: currency_code.encode('ascii'),
55: emv_data, # The chip's ARQC and related data
}
if pin_block:
elements[52] = pin_block # Encrypted PIN block
elements[26] = b'04' # 4-digit PIN captured
# Build the bitmap
bitmap = 0
for field_num in elements:
bitmap |= (1 << (64 - field_num))
return ISO8583Message(
mti="0100",
bitmap=bitmap,
data_elements=elements
)
Data Element 55: The EMV Payload
DE55 is where chip card data lives. It’s a TLV (Tag-Length-Value) encoded structure containing:
EMV_TAGS_IN_DE55 = {
"9F26": "Application Cryptogram (ARQC)", # 8 bytes — the chip's signature
"9F27": "Cryptogram Information Data (CID)", # 1 byte — type of cryptogram
"9F10": "Issuer Application Data (IAD)", # Variable — issuer-specific
"9F37": "Unpredictable Number", # 4 bytes — terminal random
"9F36": "Application Transaction Counter (ATC)", # 2 bytes — monotonic counter
"95": "Terminal Verification Results (TVR)", # 5 bytes — terminal checks
"9A": "Transaction Date", # 3 bytes (YYMMDD)
"9C": "Transaction Type", # 1 byte
"9F02": "Amount Authorized", # 6 bytes (BCD)
"9F03": "Amount Other", # 6 bytes (BCD)
"9F1A": "Terminal Country Code", # 2 bytes
"5F2A": "Transaction Currency Code", # 2 bytes
"9F33": "Terminal Capabilities", # 3 bytes
"9F34": "Cardholder Verification Method Results", # 3 bytes
"9F35": "Terminal Type", # 1 byte
"9F09": "Application Version Number", # 2 bytes
}
def parse_tlv(data: bytes) -> dict[str, bytes]:
"""
Parse EMV TLV-encoded data from DE55.
EMV uses BER-TLV encoding where tags can be 1-3 bytes
and lengths follow ASN.1 BER rules.
"""
result = {}
pos = 0
while pos < len(data):
# Parse tag
tag_start = pos
tag_byte = data[pos]
pos += 1
# Multi-byte tag: if lower 5 bits are all 1s
if (tag_byte & 0x1F) == 0x1F:
while pos < len(data) and (data[pos] & 0x80):
pos += 1
pos += 1 # Last byte of tag
tag = data[tag_start:pos].hex().upper()
# Parse length
length_byte = data[pos]
pos += 1
if length_byte <= 0x7F:
length = length_byte
elif length_byte == 0x81:
length = data[pos]
pos += 1
elif length_byte == 0x82:
length = (data[pos] << 8) | data[pos + 1]
pos += 2
else:
break
# Extract value
value = data[pos:pos + length]
pos += length
result[tag] = value
return result
Authorization, Clearing, and Settlement
A card payment isn’t a single event — it’s a three-phase process:
Phase 1: Authorization (Real-time, ~2 seconds)
The merchant’s terminal sends an auth request through the acquirer and network to the issuer. The issuer checks:
- Does the account exist?
- Is the card active (not frozen, expired, or reported stolen)?
- Is there sufficient balance/credit?
- Does the fraud scoring system flag this transaction?
If approved, the issuer returns response code 00 and an authorization code (6 alphanumeric characters). The issuer places a hold on the cardholder’s available balance but does not move any money yet.
Phase 2: Clearing (Batch, T+0 to T+1)
At end of day, the merchant (or their acquirer) submits a clearing file — a batch of all authorized transactions. The card network processes these, calculates interchange fees, and prepares settlement instructions.
Clearing Record for Transaction:
Auth Code: A12B3C
Amount: $42.50
Merchant: Coffee Shop LLC
MCC: 5812 (Eating Places/Restaurants)
Interchange rate: 1.80% + $0.10 (Visa CPS Retail)
Interchange fee: $0.87
Network assessment: 0.14% = $0.06
Acquirer keeps: $42.50 - $0.87 - $0.06 - acquirer_margin
Merchant receives: ~$41.15 (varies by acquirer agreement)
Phase 3: Settlement (T+1 to T+2)
The card network instructs settlement through the banking system:
- The issuer’s settlement bank transfers funds to the network
- The network transfers (net of interchange) to the acquirer’s settlement bank
- The acquirer deposits into the merchant’s bank account
No actual card network handles individual transactions in settlement. They calculate net positions: if Bank A issued cards that spent $10M at merchants acquiring through Bank B, and Bank B issued cards that spent $8M at Bank A’s merchants, the net settlement is Bank A paying Bank B $2M.
from dataclasses import dataclass, field
from decimal import Decimal
from collections import defaultdict
@dataclass
class SettlementPosition:
"""
Calculate net settlement positions between institutions.
In production, the card network (e.g., VisaNet, Banknet)
runs this calculation across all member institutions,
producing a single net amount per institution per currency.
"""
positions: dict[str, Decimal] = field(
default_factory=lambda: defaultdict(Decimal)
)
def add_transaction(
self,
issuer_id: str,
acquirer_id: str,
amount: Decimal,
interchange_rate: Decimal
):
interchange = amount * interchange_rate
# Issuer receives interchange
self.positions[issuer_id] += interchange
# Issuer pays the transaction amount
self.positions[issuer_id] -= amount
# Acquirer receives transaction amount minus interchange
self.positions[acquirer_id] += (amount - interchange)
def get_net_positions(self) -> dict[str, Decimal]:
"""
Returns net position per institution.
Positive = institution receives funds.
Negative = institution owes funds.
"""
return dict(self.positions)
This net settlement approach is why card payments take 1-2 business days to settle. The clearinghouse batches millions of transactions, calculates the smallest possible number of actual fund transfers between banks, and instructs those transfers through the interbank payment rail (ACH in the US, SEPA in Europe, BACS in the UK).