Transaction Flows & Message Matching
Work through examples, try the reasoning yourself, then put it into practice.
View SlidesLesson
25 min readModule 5: Transaction Flows & Message Matching
Profile boundary: The worked messages and code below use explicit teaching profiles. Field widths, mandatory fields, response actions, timers, and matching keys must come from the selected exercise or a versioned endpoint agreement. Schematic dumps and scenarios are synthetic; they are not network certification fixtures. Diagnostic snippets illustrate mechanisms and are not a complete production library.
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).
Authorization Advice Flow: 0120/0130
The class digit matters: 0120/0130 is an authorization advice pair in this course's MTI scheme; 0220/0230 is a financial advice pair. An advice reports an event or status under the selected profile. It is not a universal instruction to approve a previously offline transaction. Mastercard's public transaction rules provide a concrete use of Authorization Advice/0120 after fuel dispensing; that use has its own conditions. Mastercard transaction processing rules
Synthetic store-and-forward profile:
Terminal records an allowed offline decision and a durable local identity.
Connection returns → send configured advice → receive its advice response.
No response → retry according to the profile, retaining the original identity.
The example's permission to act offline, transaction limits, retry MTI, and deadline are configuration. A 0121 repeat convention does not create a new business transaction. The receiver must recognize duplicate identity and return the recorded outcome without applying a second side effect; it must not invent success for an unprocessed or rejected original.
Do not store full track data, PIN blocks, or other sensitive authentication data merely to make replay easy. A durable queue needs an approved data-minimization and retention design as well as idempotency.
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
These scenarios can trigger reversal handling under an applicable profile:
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
One synthetic policy escalates an unanswered 0400 into a configured 0420 advice. Real retries and escalation follow the endpoint agreement:
Terminal ─── 0400 ───► Network (no response)
Terminal ─── 0420 ───► Network "Reversal advice under the agreed recovery policy"
Network ─── 0430 ───► Terminal "Acknowledged"
An advice can report the sender’s local state. Its transmission does not prove that the remote ledger was compensated; persist unresolved outcomes until the required acknowledgement and reconciliation checks succeed.
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 recognized duplicates, return the recorded or profile-defined duplicate response without repeating effects. Do not force an approved response when the original failed, is unknown, or is still processing. The in-memory ReplayStore sketch below cannot provide durable idempotency across crashes; record identity, outcome, and business effect atomically in persistent storage.
Synthetic Reversal Decision Tree
The following diagram illustrates one configured policy. Every “Done” box requires a validated business outcome; receiving any response is not itself confirmation of reversal.
┌─────────────────────┐
│ 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 cannot distinguish these cases from the timeout alone. Persist an unknown outcome and follow the agreed status, reversal, and reconciliation policy. A transport timeout is not proof of either failure or remote approval.
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 are evidence to reconcile
Do not drop a late approval merely because a reversal was sent. First correlate it to the durable original transaction and record its event identity. Then apply the profile's state transition idempotently.
| Durable state | Incoming evidence | Required reasoning |
|---|---|---|
| Timed out, no confirmed compensation | Late 0110 approval |
Record remote approval and continue the required compensation/reconciliation workflow; do not apply another purchase |
| Reversal sent | Matching 0410 with configured success outcome |
Confirm compensation only after validating identity and response semantics |
| Reversal sent | 0410 failure or unknown status |
Keep an unresolved outcome and follow the retry/escalation policy |
| Reversed | Duplicate of a recorded event | Return/record the existing outcome without repeating ledger effects |
| Any state | Unmatched or contradictory event | Preserve metadata for investigation and reconcile; do not silently relabel it as success |
A returned MTI alone cannot move the transaction to Reversed. Response codes, original identity, and persisted state must be evaluated together. Logs should contain correlation metadata, not complete sensitive messages.
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
The following keys are synthetic strategy examples, not documented Visa/Mastercard interface rules. Choose and validate a key with sufficient uniqueness for the actual endpoint and retention window:
Synthetic Matching Profile A
Primary Match: DE11 + DE7 (STAN + TransmissionDateTime)
Alternate: Only a documented unique endpoint correlation ID
Profile A includes an explicitly configured private trace field
Synthetic Matching Profile B
Primary Match: DE11 + DE12 + DE32
Also uses: DE63 (Trace ID) for cross-border
Synthetic Matching Profile C
Often simpler: DE11 + DE41 (STAN + TerminalID)
Alternative: A profile-scoped reference with a proven retention window
The implementation must be configurable:
type MatchingStrategy interface {
BuildKey(msg *Message) string
ValidateResponse(req, resp *Message) error
}
type ProfileAMatching struct{}
func (v ProfileAMatching) BuildKey(msg *Message) string {
return fmt.Sprintf("%s:%s", msg.GetField(11), msg.GetField(7))
}
func (v ProfileAMatching) 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 durably correlated and reconciled without applying a duplicate effect.
- 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: persist correlation metadata and reconcile the late outcome
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))
}
Synthetic Diagnostic Scenario: 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
Hypothesis confirmed within this synthetic scenario: 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, using the agreed reconciliation and manual-resolution policy. A refund is a separate financial operation and is not an automatic substitute for an uncertain reversal.
Summary
Transaction flows are where ISO8583 becomes a reliable payment protocol:
| Flow | MTI | Purpose |
|---|---|---|
| Authorization | 0100/0110 | "Can this card be charged?" |
| Authorization 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.
A durable recovery design couples duplicate detection to the ledger transition and an outbox/inbox record. An in-memory “seen” map followed by processing can lose an event if the process dies between those operations. Correlation-key equality alone also does not authorize applying an event or releasing a hold.
Practice sequence
Use the order below for a guided path, or choose an item to revisit. A pass records a successful attempt; try a fresh example later to check what you can reconstruct.
- Step 1
Message Matcher
- Step 2
Reversal Builder
- Step 3
Timeout Handler
- Step 4
Transaction Flows & Message Matching Quiz
Test your understanding of ISO8583 transaction flows, message matching, timeouts, and reversals.