How to Parse NDEF Messages — NFC Data Exchange Format Walkthrough
NDEF (NFC Data Exchange Format) is the standard binary format for data stored on NFC tags. Every NFC tag read returns an NDEF message — a sequence of records, each with a type, payload, and control flags. This guide walks through parsing NDEF from raw bytes, covering record header flags, TNF type name formats, payload length encoding, chunked records, and common record types (Text, URI, Smart Poster, MIME).
1. NDEF Message Structure Overview
An NDEF message is a sequence of one or more NDEF records. Each record has:
- Header byte — 5 flags: MB (Message Begin), ME (Message End), CF (Chunk Flag), SR (Short Record), IL (ID Length)
- Type Length — 1 byte, length of the Record Type field
- Payload Length — 1 or 4 bytes (SR flag controls this)
- ID Length — 1 byte, only present if IL=1
- Record Type — TNF-specific type name (e.g., "T" for Text, "U" for URI)
- Record ID — optional, only if IL=1
- Payload — the actual data
2. Parsing the Record Header Byte
The first byte of every NDEF record encodes 5 flags + 3-bit TNF:
Byte 0: [MB] [ME] [CF] [0] [SR] [IL] [TNF (3 bits)]
b7 b6 b5 b4 b3 b2 b1-b0
// Python parsing example
def parse_header(byte):
mb = (byte >> 7) & 1 # Message Begin
me = (byte >> 6) & 1 # Message End
cf = (byte >> 5) & 1 # Chunk Flag
sr = (byte >> 3) & 1 # Short Record
il = (byte >> 2) & 1 # ID Length present
tnf = byte & 0x07 # Type Name Format
return mb, me, cf, sr, il, tnf
3. TNF — Type Name Format Values
| TNF | Value | Meaning | Example |
|---|---|---|---|
| Empty | 0x00 | No type or payload | Used as first chunk of chunked record |
| Well-Known | 0x01 | NFC Forum RTD type | "T" (Text), "U" (URI), "Sp" (Smart Poster) |
| MIME | 0x02 | Internet media type | "text/html", "application/json", "image/png" |
| Absolute URI | 0x03 | Full URI type name | "urn:nfc:sn:mytype" |
| External | 0x04 | Vendor-specific | "android.com:pkg", "nfcpy:ndef" |
| Unknown | 0x05 | Opaque data | Proprietary payload, no type info |
| Unchanged | 0x06 | Chunked middle/final | Used in chunked record sequence |
4. Decoding a Well-Known Text Record
Text records (TNF=0x01, type="T") are the most common. The payload starts with a status byte encoding UTF-8/UTF-16 + language code length, followed by the language code and text.
# Text Record payload structure:
# [Status Byte] [Language Code (2-5 bytes)] [Text (UTF-8/UTF-16)]
def parse_text_payload(payload):
status = payload[0]
utf16 = (status >> 7) & 1 # 0=UTF-8, 1=UTF-16
lang_len = status & 0x3F # language code length
lang = payload[1:1+lang_len].decode('ascii')
text_bytes = payload[1+lang_len:]
encoding = 'utf-16-be' if utf16 else 'utf-8'
text = text_bytes.decode(encoding)
return lang, text
# Example: "Hello NFC" in English
# Hex: 02 65 6E 48 65 6C 6C 6F 20 4E 46 43
# │ └─en─┘ └── Hello NFC ──────────┘
# status=0x02 (UTF-8, lang_len=2)
5. Decoding a URI Record
URI records (TNF=0x01, type="U") use a clever abbreviation: the first payload byte is a prefix code (0x00-0x24) that maps to common URI schemes like "http://www.", "https://", "tel:", "mailto:". The remaining bytes are the suffix.
URI_PREFIXES = {
0x00: "", 0x01: "http://www.", 0x02: "https://www.",
0x03: "http://", 0x04: "https://", 0x05: "tel:",
0x06: "mailto:", 0x07: "ftp://anonymous:anonymous@",
# ... 0x08-0x24 for more prefixes
}
def parse_uri_payload(payload):
prefix_code = payload[0]
prefix = URI_PREFIXES.get(prefix_code, "")
suffix = payload[1:].decode('utf-8')
return prefix + suffix
# Example: 0x04 + "cupass.com" → "https://cupass.com"
6. Parsing Chunked Records
When a record payload is too large for a single NDEF record (common on tags with small memory), the payload is split across multiple records using the Chunk Flag (CF):
- First chunk: MB=1, ME=0, CF=1 — TNF and type present
- Middle chunks: MB=0, ME=0, CF=1 — TNF=Unchanged (0x06)
- Last chunk: MB=0, ME=1, CF=0 — TNF=Unchanged (0x06)
def reassemble_chunks(records):
"""Combine chunked records into a single payload."""
payload = bytearray()
record_type = None
tnf = None
for rec in records:
if rec.cf:
if rec.mb: # First chunk
tnf = rec.tnf
record_type = rec.type
payload.extend(rec.payload)
else:
payload.extend(rec.payload) # Last chunk
return tnf, record_type, bytes(payload)
7. Smart Poster Record Parsing
A Smart Poster (type "Sp") contains nested NDEF records: a URI record (mandatory), plus optional Title, Icon, Type, Size, and Action records. It's an NDEF message embedded inside a single record's payload.
def parse_smart_poster(payload):
"""Smart Poster payload is itself an NDEF message."""
records = parse_ndef_message(payload)
uri = None
titles = {}
for rec in records:
if rec.type == b'U':
uri = parse_uri_payload(rec.payload)
elif rec.type == b'T':
lang, text = parse_text_payload(rec.payload)
titles[lang] = text
return {'uri': uri, 'titles': titles}
8. Common NDEF Pitfalls
| Issue | Cause | Fix |
|---|---|---|
| MB flag missing on first record | Corrupted tag write | Assume MB=1 for first record in raw dump |
| SR flag wrong | Payload > 255 bytes but SR=1 | Use 4-byte payload length (SR=0) for >255 bytes |
| Text encoding mismatch | UTF-16 flag set but data is ASCII | Always check status byte bit 7 before decoding |
| Chunk signature failure | Mismatched TNF across chunks | Verify first chunk TNF, remaining have TNF=0x06 |
Related Tools
NDEF Parser Tool — Paste hex, see all records decoded | NDEF Writer Simulator — Build NDEF messages interactively | NFC Capacity Calculator — Check if your payload fits | Android NFC NDEF Guide — Read/write NDEF on Android