I want to share something with you today that brought me a lot of headaches when I was working on my first contactless EMV implementation. If you’re diving into EMV transactions, especially building or testing somethink like a SoftPOS or EMV kernel, you’ll eventually bump into a critical question:
“How does the terminal know what data to request from the card”?
That’s exactly what I asked myself one late night, debugging why some tags were missing in my TLV output. The answer, it turns out, is both simple and nuanced: It’s all about reading EMV records.
Let’s break this down, the way I would explain it to a teammate sitting beside me, sipping 3-in-1 coffee at 2AM while waiting for the next APK build.
Why Does the Terminal Need to Read Records?
When you insert or tap your card, the terminal doesn’t magically know your card number or expiration date. It has to ask the card, politely and properly for the data it needs. This process is defined by the EMV specification and done in a secure and predictable sequence.
The card itself stores data in records, kind of like pages in a little book. The terminal needs to “read” these pages using a command called READ RECORD to get what it needs: PAN, expiration date, issuer scripts, cryptographic keys, and more.
But the trick is: the card doesn’t just hand over its table of contents. The terminal must first ask what records exist and that happens during GPO.
Recap: The Role of GPO and AFL
If you’re not familiar yet with GPO (Get Processing Options) and AFL (Application File Locator), you might want to check my other post: What Happens When You Tap or Insert? A Look at GPO and AFL.
To summarize quickly:
- After selecting the application (using SELECT AID), the terminal sends a GPO command.
- The card responds with a structure called the AFL, which tells the terminal which records are available, and how to read them.
The AFL is a set of entries that look like this:
SFI | Start Record | End Record | Number of records to be read offlineEach entry tells the terminal:
- Which SFI (Short File Identifier) to read from
- The range of record numbers available
- Whether to include them for offline data authentication (like CDA, DDA)
This is the terminal’s reading plan.
Sample AFL Breakdown
Let’s look at an actual AFL returned from a real EMV card:
94 0A 08 01 02 01 1C 01 02 02If we split it:
08 01 02 01
1C 01 02 02Each group of 4 bytes is one AFL entry:
- First entry:
- SFI: 08 -> means file SFI 8 (stored as 08 << 3 = 0x40)
- Start Record: 01
- End Record: 02
- Records for offline auth: 01
- Second entry:
- SFI: 1C (SFI 28)
- Start Record: 01
- End Record: 02
- Records for offline auth: 02
So the terminal must now issue READ RECORD commands for:
- SFI 8, Records 1 and 2
- SFI 28, Records 1 and 2
The READ RECORD Command
The EMV READ RECORD command is APDU-based, and its format looks like this:
CLA INS P1 P2 Lc
00 B2 XX (SFI<<3 | 4) 00Explanation:
- INS = B2 (instruction code for READ RECORD)
- P1 = record number
- P2 = (SFI << 3) | 4
- Le = 00 (terminal expects full response)
Let’s say we’re reading SFI 8, Record 1:
00 B2 01 44 00- 01 = record number
- 0x44 = (SFI 8 << 3) | 4 = 0x40 + 0x04 = 0x44
What Comes Back? TLV, Of Course
The response will contain TLV data like this:
70 1A
5A 08 4761************
5F24 03 251231
5F34 01 02
...The tag 70 means “Record Template”. It contains the actual data elements (like PAN 5A, Expiry 5F24, etc.) that the terminal uses for the transaction.
This is how we build our Card Data Object List (CDOL), Terminal Verification Results (TVR), and even determine how to perform offline data authentication.
Real Code Example (C# Style)
Here’s a simple example from my own project — when we built our own terminal-side card reader module for SoftPOS.
public async Task<List<byte[]>> ReadEMVRecords(List<AFLRecord> aflRecords, ICardReader reader)
{
var result = new List<byte[]>();
foreach (var record in aflRecords)
{
for (int i = record.StartRecord; i <= record.EndRecord; i++)
{
var sfiP2 = (byte)((record.SFI << 3) | 4);
var apdu = new byte[] { 0x00, 0xB2, (byte)i, sfiP2, 0x00 };
var response = await reader.TransmitAsync(apdu);
if (response.IsSuccess)
result.Add(response.Data);
else
Console.WriteLine($"Failed to read SFI {record.SFI}, Record {i}: {response.StatusWord}");
}
}
return result;
}Nothing too fancy, but it works. And this function eventually feeds data into my TLV parser.
Gotchas and Lessons Learned
Here are a few hard-earned lessons from the trenches:
- Not All Records Are Always There. Just because the AFL says to read from Record 1 to 3 doesn’t mean all records will succeed. Cards can be a bit… moody. Handle failures gracefully.
- Some Cards Use SFI 1, Some Use SFI 2. Don’t hardcode this. Always parse the AFL. I did that once, bad idea.
- Not All Terminals Read All Records. Some terminals stop reading early if they find what they need. But in most cases, for robustness and compliance, it’s better to read everything in the AFL.
- TLV Parsers Must Be Smart. I highly suggest writing a flexible TLV parser. Some values are primitive, some are constructed (like 0x70, 0x77). I used a recursive approach that handles nested TLVs.
Where You Might See This In Action
If you’re doing any of the following, this topic is 100% relevant:
- Building your own SoftPOS or terminal
- Creating EMV test tools or sniffers
- Debugging why some tags are missing during transaction
- Implementing CDA/DDA or Offline Data Authentication
- Working on EMV L2 certification (fun times…)
References and Credits
Everything in this post is based on what I personally implemented and debugged in real-life projects. But to go deeper or cross-reference specs, I recommend:
- EMVCo Book 3
- EMV TLV Utility Tool (by Kevin Smith): very helpful for decoding raw data
- EMVCo Book 4 for commands/APDU formats
- CashlessNomad.com for related posts
Final Thoughts
Reading EMV records isn’t just a technical step. It’s the moment when the terminal really “meets” the card. It’s the handshake before the payment happens.
If your terminal reads the wrong records or doesn’t read enough you’ll have a bad time. But once you get it right, it feels like solving a puzzle.
Hope this post helped you connect the dots a bit more clearly.



