Transaction Flows & Message Matching

Lesson, slides, and applied problem sets.

View Slides

Lesson

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:

  1. Thread-safe - No race conditions
  2. Monotonically increasing - Within a settlement period
  3. 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:

  1. stop applying duplicate side effects,
  2. keep ledger entries linked by DE90 + local id,
  3. 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:

  1. Match: 0100/0110 success path with exact MTI and composite key.
  2. Timeout: 0100 no response within timeout window triggers first reversal.
  3. Late 0110: Timeout then delayed approval arrives; confirm it is logged and ignored.
  4. Retry pressure: repeated 0400 sends, with timeout and backoff.
  5. Advice fallback: after threshold, send 0420 and accept 0430 idempotently.
  6. Network drift: switch matching strategy for one network and verify no false matches.
  7. Partial reversal: pre-auth $100 then final capture $80; reversal amount $20.
  8. 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:

FlowMTIPurpose
Authorization0100/0110"Can this card be charged?"
Financial Advice0120/0130"This transaction already happened"
Reversal0400/0410"Cancel the previous transaction"
Reversal Advice0420/0430"I'm canceling, acknowledge it"
Network Mgmt0800/0810Sign-on, sign-off, echo, key exchange

Critical concepts:

  1. Matching Key = DE11 (STAN) + DE12 (Time) + DE32 (Acquirer)
  2. Timeouts require reversals—you don't know what happened
  3. Late responses must be handled gracefully
  4. DE90 links reversals to original transactions
  5. 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:

  1. message-matcher: Build a robust request-response matcher
  2. reversal-builder: Construct valid reversals from original transactions
  3. timeout-handler: Implement complete timeout detection and reversal logic

These problems simulate real production scenarios where reliability matters.


Module Items

Join Discord