Telebirr Receipt QR Code: What It Contains and How to Decode It
The Payment QR Code on a Telebirr receipt is base64 around an EMVCo-style TLV with a CRC-16 checksum, and it holds one thing cheki uses: the transaction number. How the format was worked out, a Python decoder, and why the QR is not proof of payment.
In this article8
Every receipt in the Telebirr app shows a small square labelled Payment QR Code. cheki reads it so a shop can scan the QR instead of typing a 10-character transaction number at a busy counter. There is no public specification for the format, so this guide documents how it was worked out from one real receipt, the readings that were tried and rejected, a decoder you can run, and why a decoded QR still proves nothing on its own.
Why it matters: much everyday commerce in Ethiopia runs on bank transfers and mobile money, and the buyer usually shows the seller a screenshot of the receipt. Screenshots are easy to edit and easy to reuse, so the only trustworthy check is to ask the bank. Telebirr publishes each receipt at transactioninfo.ethiotelecom.et/receipt/{transaction number}, its official ethiotelecom receipt page. Telebirr blocks cloud servers, so cheki.et does not check Telebirr itself: it opens that page for you, and you check the amount, the receiver's name and the date there. The slow part at a counter is copying the transaction number off the customer's phone without a typo, which is what the QR fixes.
Step 1: the QR text is base64
Scanning the QR gives an 80-character string. The trailing == gives it away as base64:
ODAxODAwMDIwNjAxMDI5MTAyMDEzMTgxMjQwMDBBNDE0MjQzMzE0NDMyNDUzMzQ2MzQ2MzA0RTk2OQ==Decoded, it is neither binary nor JSON but ASCII text made of 58 hex characters:
80180002060102910201318124000A414243314432453346346304E969The tail is the first clue. 41424331443245334634 is ABC1D2E3F4 written as ASCII hex, and 6304 followed by four hex digits near the end is familiar from payment QR codes: in EMVCo merchant QR codes, tag 63 with length 04 is the checksum.
Step 2: finding the TLV rule
TLV means tag, length, value. The work is in deciding what the length counts and in which base. These readings were tried first and rejected:
- BER-TLV over the bytes. Read as bytes,
80 18is tag0x80with 24 bytes of value. The following tags do not line up and 3 bytes are left over. - Lengths as hex, counted in characters.
80 18would be 24 characters, which cuts into what is clearly the next tag. - Lengths in bytes at the top level.
63 04would mean 4 bytes of checksum, but only 2 bytes (4 hex characters) follow.
The EMVCo rule fits: two characters of tag, then two decimal digits giving the value length in characters. It consumes the string exactly:
| Tag | Length | Value | Meaning |
|---|---|---|---|
| 80 | 18 | 000206010291020131 | Unknown; cheki ignores it |
| 81 | 24 | 000A41424331443245334634 | Container holding the transaction number |
| 63 | 04 | E969 | CRC checksum |
Tag 80 is still unexplained: 9 bytes (00 02 06 01 02 91 02 01 31) that parse cleanly under neither the EMVCo rule nor a byte TLV.
Step 3: a TLV inside the TLV
The value of tag 81 is itself hex. Turned into bytes, it is a byte-level TLV with a 1-byte tag and a 1-byte length counted in bytes:
00 tag 0x00
0A length: 10 bytes
41 42 43 31 44 32 45 33 46 34 "ABC1D2E3F4"So the transaction number sits at tag 81, inner tag 00. The inner length 0A is not decimal: the two levels count length differently, which is much of why the format took a while to pin down.
Step 4: the CRC, and why letter case matters
EMVCo QR codes use CRC-16/CCITT-FALSE: polynomial 0x1021, initial value 0xFFFF, no reflection, no final XOR. It is computed over every character before the checksum value, including the 6304 itself:
def crc16_ccitt_false(text: str) -> int:
crc = 0xFFFF
for ch in text:
crc ^= (ord(ch) & 0xFF) << 8
for _ in range(8):
if crc & 0x8000:
crc = ((crc << 1) ^ 0x1021) & 0xFFFF
else:
crc = (crc << 1) & 0xFFFF
return crc
payload = "80180002060102910201318124000A414243314432453346346304E969"
body, crc = payload[:-4], payload[-4:]
assert crc16_ccitt_false(body) == int(crc, 16) # 0xE969The first attempts did not match the real receipt, because the hex had been upper-cased before hashing. The real payload mixes cases: lengths and checksum in upper case, the transaction-number bytes in lower case. A synthetic example in the same style, for the fake number XKJ0L9N8M7:
...8124000A584b4a304c394e384d376304D22BThe checksum is computed over the characters exactly as emitted. Over that string it is D22B; upper-case the string first and you get E6B2, a mismatch. Computing it over the decoded bytes, or without the 6304 prefix, matched with none of the seven CRC-16 variants tried (CCITT-FALSE, XMODEM, KERMIT, X-25, MCRF4XX, ARC, MODBUS). Only CCITT-FALSE over the original-case characters matched.
Because it validated on the real receipt, cheki enforces the checksum: a QR whose CRC does not match is rejected with a prompt to type the transaction number instead. Everything else about the hex is read case-insensitively.
Step 5: a decoder in Python
A compact version, using crc16_ccitt_false from above. cheki's production decoder is TypeScript, in src/lib/qr/telebirr.ts in the repository, and adds size caps and stricter checks.
import base64, re
def parse_top_level(hex_str):
"""EMVCo style: 2-char tag, 2 decimal digits = value length in characters."""
i, out = 0, []
while i < len(hex_str):
tag, length = hex_str[i:i+2].upper(), hex_str[i+2:i+4]
if not re.fullmatch(r"\d\d", length):
raise ValueError("length must be two decimal digits")
n, at = int(length), i + 4
if n == 0 or at + n > len(hex_str):
raise ValueError("length runs past the end")
out.append((tag, hex_str[at:at+n], at))
i = at + n
return out
def parse_inner(b: bytes):
"""Byte TLV inside tag 81: 1-byte tag, 1-byte length in bytes."""
i, out = 0, {}
while i < len(b):
if i + 2 > len(b):
raise ValueError("bad inner TLV")
tag, n = b[i], b[i+1]
if n >= 0x80 or i + 2 + n > len(b) or tag in out:
raise ValueError("bad inner TLV")
out[tag] = b[i+2:i+2+n]
i += 2 + n
return out
def read_telebirr_qr(qr_text: str) -> str:
hex_str = base64.b64decode(qr_text).decode("ascii")
entries = parse_top_level(hex_str)
tag, value, at = entries[-1]
if tag != "63" or len(value) != 4:
raise ValueError("CRC must be the last entry")
if crc16_ccitt_false(hex_str[:at]) != int(value, 16):
raise ValueError("CRC mismatch")
containers = [v for t, v, _ in entries if t == "81"]
if len(containers) != 1:
raise ValueError("expected exactly one tag 81")
ref = parse_inner(bytes.fromhex(containers[0]))[0x00].decode("ascii")
if not re.fullmatch(r"[A-Z0-9]{10}", ref):
raise ValueError("not a Telebirr transaction number")
return ref
print(read_telebirr_qr(
"ODAxODAwMDIwNjAxMDI5MTAyMDEzMTgxMjQwMDBBNDE0MjQzMzE0NDMyNDUzMzQ2MzQ2MzA0RTk2OQ=="
)) # ABC1D2E3F4The production decoder also:
- Strips whitespace, accepts URL-safe base64 and missing
=padding, and accepts the already-decoded hex form from scanner apps that decode it for you. - Caps the size before doing any work: 1,024 characters of QR text, 512 hex characters, 16 TLV entries.
- Uses only anchored, single character-class regular expressions, so nothing can backtrack.
- Never throws. Every failure comes back as a typed reason (
notBase64,crc,noReferenceand so on).
The QR is not proof of payment
The QR holds the transaction number and nothing else cheki uses: no amount and no names. Anyone who knows a transaction number can generate a valid QR for it, checksum included, exactly as shown above.
The same rule holds for every bank and wallet cheki supports: a decoded QR or pasted receipt text is labelled not confirmed, never verified.
Telebirr's receipt page only answers Ethiopian networks and blocks cloud servers, cheki.et's included, so cheki.et opens the official receipt page instead of checking it; the page opens on a phone or computer on an Ethiopian network. Direct checks return once a local relay is live. A copy self-hosted on an Ethiopian network checks Telebirr directly today.
Scan it on cheki.et, or send it to the API
- 1.
Get the receipt
Ask for a screenshot of the Telebirr receipt, or have the customer show it on their phone.
- 2.
Scan the QR first
On cheki.et, scan the QR with the camera, upload or paste the screenshot, or paste the QR text into the receipt field. cheki reads the transaction number in your browser and selects Telebirr. No readable QR? Paste the receipt link; type the transaction number only as a fallback.
- 3.
Check the official receipt page
cheki opens Telebirr's official ethiotelecom receipt page for that number; it does not check Telebirr itself. On that page, check that the amount, the receiver's name and the date are the ones you expected before releasing goods.
Developers can send the QR text as qrData with bank telebirr, or no bank, to POST /api/verify or /api/verify/batch. No API key is needed:
curl -X POST https://cheki.et/api/verify \
-H "Content-Type: application/json" \
-d '{"bank":"telebirr","qrData":"ODAxODAwMDIwNjAxMDI5MTAyMDEzMTgxMjQwMDBBNDE0MjQzMzE0NDMyNDUzMzQ2MzQ2MzA0RTk2OQ=="}'The hosted server cannot reach Telebirr, so the response is a 502 with success: false and a fallbackUrl: the official ethiotelecom receipt page, to open in a browser on an Ethiopian network and check the amount, the receiver's name and the date. Sending a Telebirr QR with another bank named returns 400.
Open questions
The format was worked out from one real receipt. Two things are still open: what tag 80 means, and whether other Telebirr receipt types use other tags. If you have a Telebirr QR that cheki rejects, an issue on GitHub (https://github.com/1RB/cheki/issues) with the payload redacted helps. The full format notes are in docs/telebirr-qr.md in the repository.