Transaction Flows & Message Matching
Lesson, slides, and applied problem sets.
View SlidesLesson
Module 5: Transaction Flows & Message Matching
The Conversation Between Machines
Every payment is a conversation. The terminal says "Can this card be charged $50?" The network responds "Yes, approved" or "No, insufficient funds." But what happens when the network doesn't respond at all? What if the response comes 30 seconds late? What if the terminal crashes after the approval but before printing the receipt?
ISO8583 isn't just a message format—it's a protocol for reliable financial conversations in an unreliable world. This module covers how those conversations work, how they fail, and how systems recover.
The Request-Response Paradigm
At its core, ISO8583 uses a simple pattern: every request expects a response.
Terminal Network/Issuer
│ │
│─────── 0100 ──────────►│ "Can I charge $50?"
│ │
│◄────── 0110 ───────────│ "Yes, auth code A12345"
│ │
The MTI encodes this relationship:
Request: 0x00 (position 3)
Response: 0x10 (position 3 = 1)
0100 → 0110 (Authorization)
0200 → 0210 (Financial)
0400 → 0410 (Reversal)
0800 → 0810 (Network management)
The pattern is predictable: to get the response MTI, add 10 to the request MTI.
But here's what makes payment systems complex: what if the response never comes?
Authorization Flow: 0100/0110
The authorization flow is the heartbeat of card payments. It happens billions of times daily.
The Request (0100)
┌─────────────────────────────────────────────────────────────────┐
│ AUTHORIZATION REQUEST (0100) │
├─────────────────────────────────────────────────────────────────┤
│ Terminal → Acquirer → Network → Issuer │
├─────────────────────────────────────────────────────────────────┤
│ MTI: 0100 │
│ │
│ Key Fields: │
│ ├─ DE2: PAN (4532015112830366) │
│ ├─ DE3: Processing Code (000000 = purchase) │
│ ├─ DE4: Amount (000000005000 = $50.00) │
│ ├─ DE7: Transmission Date/Time (1229143045 = Dec 29, 14:30:45)│
│ ├─ DE11: STAN (123456) ← Critical for matching │
│ ├─ DE12: Local Time (143045) │
│ ├─ DE13: Local Date (1229) │
│ ├─ DE22: POS Entry Mode (051 = chip) │
│ ├─ DE32: Acquiring Institution ID (123456) │
│ ├─ DE35: Track 2 Data │
│ ├─ DE41: Terminal ID (TERM0001) │
│ ├─ DE42: Merchant ID (MERCH001) │
│ └─ DE49: Currency Code (840 = USD) │
└─────────────────────────────────────────────────────────────────┘
The Response (0110)
┌─────────────────────────────────────────────────────────────────┐
│ AUTHORIZATION RESPONSE (0110) │
├─────────────────────────────────────────────────────────────────┤
│ Issuer → Network → Acquirer → Terminal │
├─────────────────────────────────────────────────────────────────┤
│ MTI: 0110 │
│ │
│ Key Fields (echoed from request): │
│ ├─ DE2: PAN (echoed) │
│ ├─ DE3: Processing Code (echoed) │
│ ├─ DE4: Amount (echoed or modified for partial approval) │
│ ├─ DE11: STAN (echoed) ← Must match request │
│ ├─ DE12: Local Time (echoed) │
│ ├─ DE32: Acquiring Institution ID (echoed) │
│ ├─ DE41: Terminal ID (echoed) │
│ │
│ New Fields (response-specific): │
│ ├─ DE37: Retrieval Reference Number │
│ ├─ DE38: Authorization Code (A12345) ← Proof of approval │
│ └─ DE39: Response Code (00 = approved) │
└─────────────────────────────────────────────────────────────────┘
What Makes Them Match?
The response must match the request. But how does the terminal know this 0110 is for that 0100?
The matching key typically consists of:
Matching Key = DE11 (STAN) + DE12 (Local Time) + DE32 (Acquirer ID)
Some networks also include:
- DE7 (Transmission DateTime)
- DE41 (Terminal ID)
- DE42 (Merchant ID)
type MatchingKey struct {
STAN string // DE11
LocalTime string // DE12 (hhmmss)
AcquirerID string // DE32
}
func (req *Message) MatchingKey() MatchingKey {
return MatchingKey{
STAN: req.GetField(11),
LocalTime: req.GetField(12),
AcquirerID: req.GetField(32),
}
}
func (req *Message) Matches(resp *Message) bool {
// Response MTI should be request MTI + 10
expectedMTI := incrementMTI(req.MTI)
if resp.MTI != expectedMTI {
return false
}
// Matching fields must be identical
return req.GetField(11) == resp.GetField(11) &&
req.GetField(12) == resp.GetField(12) &&
req.GetField(32) == resp.GetField(32)
}
The Critical Insight: STANs Must Be Unique
If two requests have the same STAN within the matching window, chaos ensues:
Time 14:30:45: Terminal sends 0100 with STAN=123456
Time 14:30:46: Terminal sends another 0100 with STAN=123456 (BUG!)
Time 14:30:47: Response arrives with STAN=123456
Which request does it belong to?
This is why STAN generation must be:
- Thread-safe - No race conditions
- Monotonically increasing - Within a settlement period
- Unique per terminal - Not globally unique, just per terminal per day
In production, STAN collisions are common when multiple POS workers execute concurrently. A robust design should:
- keep the counter in atomic shared state,
- persist on shutdown and restore on startup,
- reserve impossible values for diagnostics (e.g., all zeros).
Financial Advice Flow: 0120/0130 (Store-and-Forward)
What happens when the network is down? The terminal can't just reject every transaction—merchants would riot.
Store-and-Forward (SAF) allows terminals to approve transactions offline and send them later.
The Concept
Normal Flow:
Terminal ─── 0100 ───► Network ─── 0110 ───► Terminal (real-time)
SAF Flow:
Terminal ─── [offline approval] ───► Local Storage
... network comes back up ...
Terminal ─── 0120 ───► Network ─── 0130 ───► Terminal (advice)
Financial Advice (0120)
The 0120 message says: "This transaction already happened. I'm telling you about it."
Key difference from 0100:
- MTI 0120 instead of 0100
- DE25 (POS Condition Code) = 06 (offline transaction)
- No expectation of decline—the goods are already given
The issuer MUST accept these (within limits):
- Amount below floor limit
- Valid card (not on hot list)
- Merchant authorized for SAF
Why "Advice"?
The word "advice" is telling. In financial messaging, an "advice" is not a request—it's a notification. The sender is saying "this happened" not "can this happen?"
type MessageFunction int
const (
Request MessageFunction = iota // 0100: "May I?"
Response // 0110: "Yes/No"
Advice // 0120: "I already did"
AdviceResponse // 0130: "Acknowledged"
)
func (mti string) IsAdvice() bool {
if len(mti) < 3 {
return false
}
// Function digit (position 3) is 2 for advice
return mti[2] == '2'
}
Repeat Transmission: 0121
What if the 0130 response never arrives? The terminal retries with MTI 0121 (position 4 = 1 means "repeat").
Terminal ─── 0120 ───► Network (no response)
Terminal ─── 0121 ───► Network (retry)
Terminal ─── 0121 ───► Network (retry)
Network ─── 0130 ───► Terminal (finally!)
The issuer must handle duplicate 0121s idempotently—don't double-charge.
Reversal Flows: 0400/0410 and 0420/0430
Reversals are how the payment system "takes back" a transaction. They're critical for reliability.
Why Reversals Exist
Three scenarios require reversals:
Scenario 1: Terminal Timeout
Terminal ─── 0100 ───► Network
... 30 seconds pass, no response ...
Terminal ─── 0400 ───► Network "Cancel that request"
Scenario 2: Customer Cancellation
Terminal ─── 0100 ───► Network ─── 0110 (approved) ───► Terminal
Customer: "Wait, I don't want this"
Terminal ─── 0400 ───► Network "Cancel the approval"
Scenario 3: System Error
Terminal ─── 0100 ───► Network ─── 0110 (approved) ───► Terminal
... terminal crashes before printing receipt ...
Terminal ─── 0400 ───► Network "Unsure if completed, reverse to be safe"
Reversal Request (0400)
┌─────────────────────────────────────────────────────────────────┐
│ REVERSAL REQUEST (0400) │
├─────────────────────────────────────────────────────────────────┤
│ Contains most fields from original 0100/0110: │
│ ├─ DE2: PAN (from original) │
│ ├─ DE3: Processing Code (from original) │
│ ├─ DE4: Amount (original amount OR partial reversal amount) │
│ ├─ DE7: Transmission Date/Time (NOW, not original) │
│ ├─ DE11: STAN (NEW STAN, not original) │
│ ├─ DE12: Local Time (NOW) │
│ ├─ DE32: Acquiring Institution ID │
│ ├─ DE37: RRN (ORIGINAL from 0110) ← Links to original │
│ ├─ DE38: Auth Code (ORIGINAL from 0110) │
│ ├─ DE39: Response Code (ORIGINAL from 0110, if received) │
│ ├─ DE41: Terminal ID │
│ └─ DE90: Original Data Elements ← Complete original reference │
└─────────────────────────────────────────────────────────────────┘
DE90: Original Data Elements
DE90 is the key to linking reversals to their original transactions:
DE90 Structure (42 characters):
Position Length Content
1-4 4 Original MTI (e.g., "0100")
5-10 6 Original STAN (from original DE11)
11-20 10 Original Date/Time (from original DE7)
21-31 11 Original Acquiring Institution (from original DE32)
32-42 11 Original Forwarding Institution (from original DE33)
This creates an unambiguous link:
type OriginalDataElements struct {
OriginalMTI string // 4 chars
OriginalSTAN string // 6 chars
OriginalDateTime string // 10 chars (MMDDhhmmss)
OriginalAcquirer string // 11 chars (right-padded)
OriginalForwarder string // 11 chars (right-padded)
}
func BuildDE90(originalReq *Message) string {
return fmt.Sprintf("%-4s%-6s%-10s%-11s%-11s",
originalReq.MTI,
originalReq.GetField(11),
originalReq.GetField(7),
originalReq.GetField(32),
originalReq.GetField(33),
)
}
Reversal Response (0410)
The network responds to confirm the reversal was processed:
DE39 in 0410:
- "00" = Reversal successful, original transaction reversed
- "12" = Invalid transaction (nothing to reverse)
- "25" = Unable to locate record (original not found)
Reversal Advice: 0420/0430
What if the 0400 times out? The terminal sends a Reversal Advice (0420):
Terminal ─── 0400 ───► Network (no response)
Terminal ─── 0420 ───► Network "I'm reversing whether you like it or not"
Network ─── 0430 ───► Terminal "Acknowledged"
A 0420 is not a request—it's a declaration. The terminal has already treated the transaction as void.
Idempotent Processing for Duplicate or Out-of-Order Messages
Retries and duplicates are expected in the real world. The safest processor is idempotent:
- A duplicate response must not apply business effects twice.
- A duplicate request should still be acknowledged (so senders can stop retrying).
- Unknown messages should be logged and routed to a dead-letter channel if unresolved.
type ReplayDecision int
const (
ReplayNew ReplayDecision = iota
ReplayDuplicate
)
type ReplayStore struct {
mu sync.Mutex
seen map[string]time.Time
ttl time.Duration
}
func NewReplayStore(ttl time.Duration) *ReplayStore {
return &ReplayStore{
seen: make(map[string]time.Time),
ttl: ttl,
}
}
func (r *ReplayStore) CheckAndStore(key string) ReplayDecision {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now()
for k, exp := range r.seen {
if now.After(exp) {
delete(r.seen, k)
}
}
if _, ok := r.seen[key]; ok {
return ReplayDuplicate
}
r.seen[key] = now.Add(r.ttl)
return ReplayNew
}
func (r *ReplayStore) replayKey(resp *Message) string {
return fmt.Sprintf("%s:%s:%s:%s",
resp.MTI,
resp.GetField(11),
resp.GetField(37),
resp.GetField(90),
)
}
func handleResponse(resp *Message, replay *ReplayStore) {
if replay.CheckAndStore(replay.replayKey(resp)) == ReplayDuplicate {
log.Printf("duplicate ignored: mtI=%s stan=%s", resp.MTI, resp.GetField(11))
return
}
processResponse(resp)
}
For 0121 and 0420 duplicates, emit successful responses but avoid double side effects. The terminal may retransmit until it receives confirmation.
The Reversal Decision Tree
┌─────────────────────┐
│ Send 0100 Request │
└──────────┬──────────┘
│
┌──────────▼──────────┐
│ Response received? │
└──────────┬──────────┘
│ │
YES NO (timeout)
│ │
┌──────────▼──────────┐ │
│ DE39 = "00"? │ │
└──────────┬──────────┘ │
│ │ │
YES NO │
│ │ │
┌──────▼──────┐ ┌──────▼──────┐│
│ Transaction │ │ Log decline ││
│ approved │ └─────────────┘│
└──────┬──────┘ │
│ │
┌──────────▼──────────┐ │
│ Complete locally? │◄──────────┘
│ (print receipt, etc) │
└──────────┬──────────┘
│ │
YES NO (crash/error)
│ │
┌──────▼──────┐ ┌──────▼──────┐
│ Done │ │ Send 0400 │
│ │ │ (reversal) │
└─────────────┘ └──────┬──────┘
│
┌──────────▼──────────┐
│ Response received? │
└──────────┬──────────┘
│ │
YES NO
│ │
┌──────▼──────┐ ┌──────▼──────┐
│ Done │ │ Send 0420 │
│ │ │ (rev advice)│
└─────────────┘ └─────────────┘
Network Management: 0800/0810
Before any financial messages can flow, the terminal must establish a session with the network. This is network management.
Sign-On (0800 with DE70 = 001)
Terminal ─── 0800 (Sign-On) ───► Network
Network ─── 0810 (Accepted) ───► Terminal
DE70 = "001" indicates Sign-On
DE39 = "00" means successful sign-on
After sign-on, the terminal is authenticated and can send financial messages.
Sign-Off (0800 with DE70 = 002)
Terminal ─── 0800 (Sign-Off) ───► Network
Network ─── 0810 (Confirmed) ───► Terminal
DE70 = "002" indicates Sign-Off
End-of-day settlement typically includes sign-off.
Echo Test (0800 with DE70 = 301)
Terminal ─── 0800 (Echo) ───► Network
Network ─── 0810 (Echo Reply) ───► Terminal
Purpose: Check if network is alive
Should return within 10-30 seconds
Echo tests are used for:
- Keep-alive (prevent idle disconnection)
- Network health monitoring
- Timeout calibration
Key Exchange (0800 with DE70 = 101/102)
Cryptographic keys must be exchanged securely:
Key Exchange Request:
DE70 = "101" (Request new key)
DE48 = Key data or encrypted key block
Key Exchange Response:
DE70 = "102" (Key received)
DE39 = "00" (successful)
After successful key exchange, PIN encryption and MAC calculation use the new keys.
Network Management Code (DE70)
Code Meaning
001 Sign-On
002 Sign-Off
101 Key Exchange Request
102 Key Exchange Response
161 New Key (Push)
162 New Key Acknowledgment
201 Cutover
301 Echo Test
Timeout Handling: The Heart of Reliability
Timeouts are where payment systems earn their complexity. A timeout doesn't mean failure—it means uncertainty.
The Timeout Problem
Scenario A: Network never received the request
Terminal ─── 0100 ──X (packet lost)
Action: Safe to retry or reverse
Scenario B: Network received, issuer approved, response lost
Terminal ─── 0100 ───► Network ───► Issuer (approved)
Terminal ◄── 0110 ──X (packet lost)
Action: Reversal needed! The charge went through.
Scenario C: Network received, still processing
Terminal ─── 0100 ───► Network ───► Issuer (thinking...)
Terminal gives up after 30s
Action: Reversal needed—the approval might come later.
The terminal can't distinguish these cases. When a timeout occurs, the terminal must assume the worst and reverse.
Timeout Configuration
type TimeoutConfig struct {
// How long to wait for authorization response
AuthorizationTimeout time.Duration // Typically 30-60 seconds
// How long to wait for reversal response
ReversalTimeout time.Duration // Typically 30 seconds
// How many times to retry a reversal
ReversalRetries int // Typically 3
// How long between reversal retries
ReversalRetryDelay time.Duration // Typically 30 seconds
// After which, switch from 0400 to 0420
ReversalAdviceThreshold int // After N failed 0400s
}
var DefaultTimeouts = TimeoutConfig{
AuthorizationTimeout: 30 * time.Second,
ReversalTimeout: 30 * time.Second,
ReversalRetries: 3,
ReversalRetryDelay: 30 * time.Second,
ReversalAdviceThreshold: 2,
}
The Timeout State Machine
type TransactionState int
const (
StateInitial TransactionState = iota
StateSent
StateApproved
StateDeclined
StateTimedOut
StateReversalSent
StateReversed
StateReversalFailed
StateAdviceSent
StateComplete
)
type Transaction struct {
Request *Message
Response *Message
State TransactionState
SentAt time.Time
ReversalSent int // Number of reversal attempts
}
func (t *Transaction) HandleTimeout(cfg TimeoutConfig) TransactionState {
switch t.State {
case StateSent:
// Authorization timed out - send reversal
t.State = StateTimedOut
return StateReversalSent
case StateReversalSent:
t.ReversalSent++
if t.ReversalSent >= cfg.ReversalAdviceThreshold {
// Too many failed reversals - send advice
t.State = StateAdviceSent
return StateAdviceSent
}
// Retry reversal
return StateReversalSent
default:
return t.State
}
}
Late Responses
What if a response arrives after the timeout?
func (t *Transaction) HandleLateResponse(resp *Message) error {
switch t.State {
case StateTimedOut:
// We already started reversal - ignore late response
// but log it for debugging
log.Printf("Late response for STAN %s, state=%v",
t.Request.GetField(11), t.State)
return nil
case StateReversalSent:
// Check if it's response to reversal or original
if resp.MTI == "0410" {
// Reversal response - process it
t.State = StateReversed
return nil
}
// Late auth response - ignore, reversal in progress
return nil
case StateReversed:
// Already reversed - ignore any late messages
return nil
default:
return fmt.Errorf("unexpected late response in state %v", t.State)
}
}
The SAF Queue
When networks are down, Store-and-Forward holds transactions:
type SAFQueue struct {
mu sync.Mutex
messages []*Transaction
maxSize int
maxAge time.Duration
}
func (q *SAFQueue) Add(tx *Transaction) error {
q.mu.Lock()
defer q.mu.Unlock()
if len(q.messages) >= q.maxSize {
return errors.New("SAF queue full")
}
q.messages = append(q.messages, tx)
return nil
}
func (q *SAFQueue) Drain(send func(*Transaction) error) {
q.mu.Lock()
defer q.mu.Unlock()
remaining := make([]*Transaction, 0, len(q.messages))
for _, tx := range q.messages {
// Check age
if time.Since(tx.SentAt) > q.maxAge {
log.Printf("Dropping aged SAF transaction: %s", tx.Request.GetField(11))
continue
}
// Try to send
if err := send(tx); err != nil {
remaining = append(remaining, tx)
}
}
q.messages = remaining
}
Partial Reversals and Adjustments
Not all reversals are full cancellations. Sometimes you only need to reverse part of a transaction.
Partial Reversal
Customer authorizes $100, but only $80 is actually charged:
Original: 0100, DE4 = 000000010000 ($100.00)
Response: 0110, DE39 = 00 (approved for $100)
Actual charge is only $80, so:
Reversal: 0400, DE4 = 000000002000 ($20.00 partial reversal)
DE90 contains original reference
The partial reversal releases $20 of the $100 hold.
Adjustment (0220/0230)
Adjustments modify completed transactions:
Original: 0200 (Financial), DE4 = 000000005000 ($50.00)
Later: 0220 (Adjustment), DE4 = 000000006000 ($60.00)
"The actual amount was $60, not $50"
Common in:
- Hotel check-out (final bill differs from pre-auth)
- Car rental return
- Restaurant tips
type AdjustmentReason string
const (
AdjustmentTipAdded AdjustmentReason = "tip"
AdjustmentFinalBill AdjustmentReason = "final"
AdjustmentPartialRefund AdjustmentReason = "partial_refund"
)
func BuildAdjustment(original *Message, newAmount int64, reason AdjustmentReason) *Message {
adj := NewMessage("0220")
// Copy identifying fields
adj.SetField(2, original.GetField(2)) // PAN
adj.SetField(11, generateNewSTAN()) // New STAN
adj.SetField(37, original.GetField(37)) // Original RRN
adj.SetField(38, original.GetField(38)) // Original Auth Code
// Set new amount
adj.SetField(4, fmt.Sprintf("%012d", newAmount))
// Original amount in DE30
adj.SetField(30, original.GetField(4))
return adj
}
Message Matching in Practice
Let's implement a robust message matcher:
type PendingRequest struct {
Message *Message
SentAt time.Time
OnRespond func(*Message)
OnTimeout func()
}
type MessageMatcher struct {
mu sync.RWMutex
pending map[string]*PendingRequest // key: matching key
timeout time.Duration
stopChan chan struct{}
}
func NewMessageMatcher(timeout time.Duration) *MessageMatcher {
m := &MessageMatcher{
pending: make(map[string]*PendingRequest),
timeout: timeout,
stopChan: make(chan struct{}),
}
go m.timeoutLoop()
return m
}
func (m *MessageMatcher) makeKey(msg *Message) string {
// Composite key: STAN + LocalTime + AcquirerID
return fmt.Sprintf("%s:%s:%s",
msg.GetField(11),
msg.GetField(12),
msg.GetField(32),
)
}
func (m *MessageMatcher) RegisterRequest(req *Message, onResp func(*Message), onTimeout func()) {
m.mu.Lock()
defer m.mu.Unlock()
key := m.makeKey(req)
m.pending[key] = &PendingRequest{
Message: req,
SentAt: time.Now(),
OnRespond: onResp,
OnTimeout: onTimeout,
}
}
func (m *MessageMatcher) MatchResponse(resp *Message) bool {
m.mu.Lock()
defer m.mu.Unlock()
key := m.makeKey(resp)
pending, ok := m.pending[key]
if !ok {
// No matching request - could be late response
return false
}
// Verify MTI relationship
expectedRespMTI := incrementMTI(pending.Message.MTI)
if resp.MTI != expectedRespMTI {
return false
}
// Match found - invoke callback and remove from pending
delete(m.pending, key)
if pending.OnRespond != nil {
pending.OnRespond(resp)
}
return true
}
func (m *MessageMatcher) timeoutLoop() {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
m.checkTimeouts()
case <-m.stopChan:
return
}
}
}
func (m *MessageMatcher) checkTimeouts() {
m.mu.Lock()
defer m.mu.Unlock()
now := time.Now()
for key, pending := range m.pending {
if now.Sub(pending.SentAt) > m.timeout {
delete(m.pending, key)
if pending.OnTimeout != nil {
go pending.OnTimeout() // Don't block the loop
}
}
}
}
func incrementMTI(mti string) string {
if len(mti) != 4 {
return mti
}
// Add 10 to the numeric value
// 0100 → 0110, 0400 → 0410, etc.
val, err := strconv.Atoi(mti)
if err != nil {
return mti
}
return fmt.Sprintf("%04d", val+10)
}
Matching Across Different Networks
Different networks have different matching rules:
Visa
Primary Match: DE11 + DE7 (STAN + TransmissionDateTime)
Alternate: DE37 (RRN) alone
Visa's DE63 (Private Data) contains additional trace data
Mastercard
Primary Match: DE11 + DE12 + DE32
Also uses: DE63 (Trace ID) for cross-border
Local Networks
Often simpler: DE11 + DE41 (STAN + TerminalID)
Or even: DE37 (RRN) alone
The implementation must be configurable:
type MatchingStrategy interface {
BuildKey(msg *Message) string
ValidateResponse(req, resp *Message) error
}
type VisaMatching struct{}
func (v VisaMatching) BuildKey(msg *Message) string {
return fmt.Sprintf("%s:%s", msg.GetField(11), msg.GetField(7))
}
func (v VisaMatching) ValidateResponse(req, resp *Message) error {
if req.GetField(11) != resp.GetField(11) {
return errors.New("STAN mismatch")
}
if req.GetField(7) != resp.GetField(7) {
return errors.New("transmission datetime mismatch")
}
return nil
}
type LocalNetworkMatching struct{}
func (l LocalNetworkMatching) BuildKey(msg *Message) string {
return fmt.Sprintf("%s:%s", msg.GetField(11), msg.GetField(41))
}
func (l LocalNetworkMatching) ValidateResponse(req, resp *Message) error {
if req.GetField(11) != resp.GetField(11) {
return errors.New("STAN mismatch")
}
return nil
}
Why this matters:
- A one-size-fits-all matcher fails as soon as routing changes or cross-border traffic arrives.
- Build strategy selection from network routing metadata (
routingProfile,switchID,scheme). - Always validate MTI first; this catches accidentally swapped messages.
- Record strategy name in telemetry so you can detect unknown route behavior quickly.
type MatchEngine struct {
matcher MatchingStrategy
replay *ReplayStore
}
type MatchResult struct {
Matched bool
Why string
}
func (e *MatchEngine) MatchRequest(req, resp *Message) MatchResult {
if resp == nil {
return MatchResult{Matched: false, Why: "nil response"}
}
if resp.MTI != incrementMTI(req.MTI) {
return MatchResult{
Matched: false,
Why: fmt.Sprintf("bad MTI relationship: expected=%s got=%s", incrementMTI(req.MTI), resp.MTI),
}
}
if err := e.matcher.ValidateResponse(req, resp); err != nil {
return MatchResult{
Matched: false,
Why: err.Error(),
}
}
decision := e.replay.CheckAndStore(req.MatchingKeyString())
if decision == ReplayDuplicate {
return MatchResult{Matched: false, Why: "replayed response suppressed"}
}
return MatchResult{Matched: true, Why: "ok"}
}
Matcher helper for readable keys
func (m *Message) MatchingKeyString() string {
return fmt.Sprintf("mti=%s|stan=%s|time=%s|acq=%s",
m.MTI,
m.GetField(11),
m.GetField(12),
m.GetField(32),
)
}
Complete Transaction Lifecycle
Here's the full lifecycle of an authorization with timeout handling:
type TransactionManager struct {
matcher *MessageMatcher
safQueue *SAFQueue
network NetworkConnection
config TimeoutConfig
}
func (tm *TransactionManager) Authorize(req *Message) (*Message, error) {
// Create transaction tracker
tx := &Transaction{
Request: req,
State: StateInitial,
SentAt: time.Now(),
}
// Channel for response
respChan := make(chan *Message, 1)
errChan := make(chan error, 1)
// Register with matcher
tm.matcher.RegisterRequest(req,
func(resp *Message) {
respChan <- resp
},
func() {
errChan <- ErrTimeout
},
)
// Send request
tx.State = StateSent
if err := tm.network.Send(req); err != nil {
return nil, fmt.Errorf("failed to send: %w", err)
}
// Wait for response or timeout
select {
case resp := <-respChan:
tx.Response = resp
if resp.GetField(39) == "00" {
tx.State = StateApproved
} else {
tx.State = StateDeclined
}
return resp, nil
case err := <-errChan:
tx.State = StateTimedOut
// Initiate reversal
go tm.handleTimeout(tx)
return nil, err
}
}
func (tm *TransactionManager) handleTimeout(tx *Transaction) {
reversal := tm.buildReversal(tx.Request)
for attempt := 0; attempt < tm.config.ReversalRetries; attempt++ {
tx.State = StateReversalSent
tx.ReversalSent = attempt + 1
respChan := make(chan *Message, 1)
errChan := make(chan error, 1)
tm.matcher.RegisterRequest(reversal,
func(resp *Message) { respChan <- resp },
func() { errChan <- ErrTimeout },
)
if err := tm.network.Send(reversal); err != nil {
time.Sleep(tm.config.ReversalRetryDelay)
continue
}
select {
case resp := <-respChan:
if resp.GetField(39) == "00" {
tx.State = StateReversed
return
}
case <-errChan:
time.Sleep(tm.config.ReversalRetryDelay)
}
}
// All reversals failed - send advice
tx.State = StateAdviceSent
tm.sendReversalAdvice(tx)
}
func (tm *TransactionManager) buildReversal(original *Message) *Message {
rev := NewMessage("0400")
// Copy key fields
rev.SetField(2, original.GetField(2)) // PAN
rev.SetField(3, original.GetField(3)) // Processing Code
rev.SetField(4, original.GetField(4)) // Amount
rev.SetField(11, generateNewSTAN()) // New STAN
rev.SetField(12, time.Now().Format("150405")) // Current time
rev.SetField(13, time.Now().Format("0102")) // Current date
rev.SetField(32, original.GetField(32)) // Acquirer ID
rev.SetField(41, original.GetField(41)) // Terminal ID
rev.SetField(42, original.GetField(42)) // Merchant ID
// DE90: Original Data Elements
rev.SetField(90, BuildDE90(original))
return rev
}
Handling ambiguous outcomes while waiting for reversals
The same transaction can leave your codebase in these legitimate states:
pending(0100 sent, no response),reversal_retrying(0400 retries active),advice(0420 sent),closed_ambiguous(both 0110 and 0400 succeeded in opposite sides of the wire).
This ambiguity is a risk category, not a bug. Treat it explicitly:
- stop applying duplicate side effects,
- keep ledger entries linked by
DE90+ local id, - force reconciliation checks after reconnection.
type FlowOutcome string
const (
OutcomeApproved FlowOutcome = "approved"
OutcomeTimedOut FlowOutcome = "timed_out"
OutcomeReversed FlowOutcome = "reversed"
OutcomeAdviceOnly FlowOutcome = "advice_only"
OutcomeUnknownRecover FlowOutcome = "unknown_recover"
)
func (t *Transaction) outcomeFromReversalResp(resp *Message) FlowOutcome {
switch resp.GetField(39) {
case "00":
return OutcomeReversed
case "12", "14":
return OutcomeUnknownRecover
case "25":
return OutcomeUnknownRecover
default:
return OutcomeTimedOut
}
}
Progressive Flow Tests
Use this sequence as a grading rubric for your flow logic:
- Match:
0100/0110success path with exact MTI and composite key. - Timeout:
0100no response within timeout window triggers first reversal. - Late 0110: Timeout then delayed approval arrives; confirm it is logged and ignored.
- Retry pressure: repeated 0400 sends, with timeout and backoff.
- Advice fallback: after threshold, send 0420 and accept 0430 idempotently.
- Network drift: switch matching strategy for one network and verify no false matches.
- Partial reversal: pre-auth $100 then final capture $80; reversal amount
$20. - Reversal failure:
0410 = 12, route to manual recovery list.
Common Pitfalls
1. Reusing STANs Too Quickly
// WRONG: STAN generator resets on restart
var stan = 0
func getSTAN() string {
stan++
return fmt.Sprintf("%06d", stan)
}
// RIGHT: Persist STAN across restarts
func getSTAN() string {
stan := atomic.AddInt64(&persistedSTAN, 1)
if stan > 999999 {
atomic.StoreInt64(&persistedSTAN, 1)
stan = 1
}
saveToDisk(stan) // Persist!
return fmt.Sprintf("%06d", stan)
}
2. Not Handling Late Responses
// WRONG: Assume response always comes before timeout
resp := <-respChan // Blocks forever if response is late
// RIGHT: Handle late responses gracefully
select {
case resp := <-respChan:
process(resp)
case <-time.After(30 * time.Second):
initiateReversal()
}
// AND: Log late responses for debugging
func onLateResponse(resp *Message) {
log.Printf("LATE RESPONSE: STAN=%s, arrived %v after timeout",
resp.GetField(11), time.Since(originalSentTime))
}
3. Matching on Insufficient Fields
// WRONG: Only match on STAN
if req.GetField(11) == resp.GetField(11) {
return true // Could match wrong transaction!
}
// RIGHT: Match on composite key
if req.GetField(11) == resp.GetField(11) &&
req.GetField(12) == resp.GetField(12) &&
req.GetField(32) == resp.GetField(32) {
return true
}
4. Incorrect DE90 Construction
// WRONG: Using response fields in reversal
rev.SetField(90, BuildDE90(response)) // Should use REQUEST
// RIGHT: Use original request
rev.SetField(90, BuildDE90(originalRequest))
5. Ignoring Reversal Response Codes
// WRONG: Assume reversal always succeeds
sendReversal(rev)
tx.State = StateReversed // Dangerous!
// RIGHT: Check response code
resp := sendReversal(rev)
switch resp.GetField(39) {
case "00":
tx.State = StateReversed
case "25":
log.Printf("Original not found - may have been voided elsewhere")
tx.State = StateReversalFailed
default:
log.Printf("Reversal failed with %s, will retry", resp.GetField(39))
}
Real-World Case Study: The Orphan Authorization
A merchant reported that customers were seeing double charges. Investigation revealed:
The Symptom:
Customer sees:
- $50.00 pending charge (authorization)
- $50.00 posted charge (financial)
Merchant sees:
- One completed transaction
The Investigation:
Log analysis showed:
14:30:45 - Terminal sends 0100, STAN=123456
14:30:46 - Network receives 0100
14:30:47 - Issuer approves, sends 0110
14:31:15 - Terminal times out (30s), never saw 0110
14:31:16 - Terminal sends 0400 reversal
14:31:17 - Network processes reversal
14:31:17 - Issuer: "Cannot reverse - already posted"
14:31:18 - Reversal fails (DE39 = 12)
14:31:19 - Terminal treats as "no transaction occurred"
14:32:00 - Customer retries, new authorization
14:32:01 - Issuer approves again
Result: Two authorizations, one reversal failed
The Root Cause: The issuer's system had already moved the transaction from "pending" to "posted" by the time the reversal arrived (30 seconds after approval). Once posted, the reversal code 0400 was rejected—a refund (0200 with processing code 20) would be needed instead.
The Fix:
func handleReversalFailure(tx *Transaction, respCode string) {
switch respCode {
case "12", "14":
// Transaction already posted or invalid
// Queue for manual review or refund
queueForRefund(tx)
case "25":
// Not found - might already be reversed
verifyWithIssuer(tx)
default:
// Retry reversal
retryReversal(tx)
}
}
The Lesson: Reversals aren't guaranteed to succeed. Systems must handle reversal failures gracefully, potentially falling back to refunds or manual intervention.
Summary
Transaction flows are where ISO8583 becomes a reliable payment protocol:
| Flow | MTI | Purpose |
|---|---|---|
| Authorization | 0100/0110 | "Can this card be charged?" |
| Financial Advice | 0120/0130 | "This transaction already happened" |
| Reversal | 0400/0410 | "Cancel the previous transaction" |
| Reversal Advice | 0420/0430 | "I'm canceling, acknowledge it" |
| Network Mgmt | 0800/0810 | Sign-on, sign-off, echo, key exchange |
Critical concepts:
- Matching Key = DE11 (STAN) + DE12 (Time) + DE32 (Acquirer)
- Timeouts require reversals—you don't know what happened
- Late responses must be handled gracefully
- DE90 links reversals to original transactions
- Advice messages (x2x0) are declarations, not requests
The difference between a system that works and one that loses money is in the edge cases: timeouts, late responses, failed reversals, and duplicate detection. Master these flows, and you can build reliable payment systems.
What's Next
Apply your knowledge:
- message-matcher: Build a robust request-response matcher
- reversal-builder: Construct valid reversals from original transactions
- timeout-handler: Implement complete timeout detection and reversal logic
These problems simulate real production scenarios where reliability matters.
Module Items
Message Matcher
Reversal Builder
Timeout Handler
Transaction Flows & Message Matching Quiz
Test your understanding of ISO8583 transaction flows, message matching, timeouts, and reversals.