DEV Community

cheki
cheki

Posted on Originally published at cheki.et

Reverse-engineering the Telebirr receipt QR code

A customer holds up their phone at a shop counter in Addis. On the screen is a Telebirr receipt, and in the corner is a small square labelled Payment QR Code. The shopkeeper has a queue and a 10-character transaction number to copy by hand.

cheki.et is a free, MIT-licensed tool that checks Ethiopian bank and wallet receipts against the bank's own record. We wanted that shopkeeper to be able to scan the square instead of typing.

Some context if you've never paid for anything in Ethiopia: a lot of everyday commerce runs on bank transfers and mobile money, and the buyer usually "proves" payment by showing 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. A screenshot is a claim. The bank's record is the proof. Conveniently, Ethiopian banks and wallets publish each receipt at a public URL. Telebirr's is its official ethiotelecom receipt page, transactioninfo.ethiotelecom.et/receipt/{transaction number}. (Telebirr blocks cloud servers, so the hosted cheki.et opens that page for you rather than reading it itself. More on that below.) Either way, the hard part at a busy counter is getting the transaction number off the customer's phone without a typo. The QR is the obvious answer.

There is no public spec for it. This is how we worked out the format from one real receipt, the dead ends along the way, and why the decoded QR still proves nothing on its own.

Everything below uses a fake transaction number, ABC1D2E3F4. The real payload isn't published anywhere.

Step 1: the QR is base64

Scanning the QR gives you an 80-character string that is obviously base64 (trailing ==):

ODAxODAwMDIwNjAxMDI5MTAyMDEzMTgxMjQwMDBBNDE0MjQzMzE0NDMyNDUzMzQ2MzQ2MzA0RTk2OQ==
Enter fullscreen mode Exit fullscreen mode

Decoding it gives neither binary nor JSON but ASCII text made of hex characters, 58 of them:

80180002060102910201318124000A414243314432453346346304E969
Enter fullscreen mode Exit fullscreen mode

The tail is the first clue. 41424331443245334634 is ABC1D2E3F4 in ASCII hex, and anyone who has looked at payment QR codes will recognise 6304 followed by four hex digits near the end. In EMVCo merchant QR codes, tag 63 with length 04 is the CRC.

Step 2: finding the right TLV rule

TLV means tag, length, value. The hard part is working out what the length counts and in which base. We tried the obvious readings first:

BER-TLV over the bytes. Convert the hex to bytes and read 80 18 ... as tag 0x80, length 0x18 = 24 bytes. The following tags don't line up, and 3 bytes are left over at the end. Rejected.

Lengths as hex, counted in characters. 80 18 would be 24 characters, which cuts straight through what is clearly the next tag. Rejected.

Lengths in bytes at the top level. 63 04 would mean 4 bytes of CRC, but only 2 bytes (4 hex characters) follow. Rejected.

EMVCo style: two characters of tag, two decimal digits of length, counted in characters. This one consumes the string exactly:

Tag Length Value Meaning
80 18 000206010291020131 Unknown. cheki.et ignores it.
81 24 000A41424331443245334634 Container holding the transaction number
63 04 E969 CRC

Tag 80 is still a mystery: 9 bytes (00 02 06 01 02 91 02 01 31) that parse cleanly under neither the EMVCo rule nor a byte TLV. If you know what it is, please open an issue.

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"
Enter fullscreen mode Exit fullscreen mode

So the transaction number lives at tag 81, then inner tag 00. Notice that the inner length 0A isn't decimal, so the two levels use different length rules. That inconsistency is part of why the format took a while to pin down.

Step 4: the CRC, and why case matters

EMVCo QR codes use CRC-16/CCITT-FALSE: polynomial 0x1021, initial value 0xFFFF, no reflection, no final XOR, computed over every character before the CRC 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)   # 0xE969
Enter fullscreen mode Exit fullscreen mode

Our first attempts didn't match the real receipt, because we had normalised the hex to upper case before hashing. The real payload mixes cases: lengths and CRC in upper case, the transaction-number bytes in lower case. Here's a synthetic example in the same style, for the fake number XKJ0L9N8M7:

...8124000A584b4a304c394e384d376304D22B
Enter fullscreen mode Exit fullscreen mode

The CRC 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. We also tried computing it over the decoded bytes, and without the 6304 prefix, with seven CRC-16 variants (CCITT-FALSE, XMODEM, KERMIT, X-25, MCRF4XX, ARC, MODBUS). Only CCITT-FALSE over the original-case characters matched.

Since it validated on the real receipt, cheki.et enforces the CRC: a QR whose CRC doesn't match is rejected, and you're asked to paste the receipt link or, as a fallback, type the transaction number. Everything else about the hex is parsed case-insensitively.

Step 5: a decoder

Here's a compact Python version of the decoder (it uses crc16_ccitt_false from step 4). The production decoder is TypeScript (src/lib/qr/telebirr.ts) 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=="
))  # ABC1D2E3F4
Enter fullscreen mode Exit fullscreen mode

The production version also:

  • strips whitespace, accepts URL-safe base64 and missing = padding, and accepts the already-decoded hex form, for 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 regexes, so nothing can backtrack;
  • never throws: every failure comes back as a typed reason (notBase64, crc, noReference and so on).

The QR is not proof of payment

This is the part that matters most. The QR contains the transaction number and nothing else cheki.et uses: no amount, no names. Anyone who knows a transaction number can generate a perfectly valid QR for it, CRC and all. You've just seen how.

So in cheki.et a decoded QR is only an input. It says which receipt to look up, nothing more. The same rule holds for every bank and wallet cheki.et supports: a decoded QR or pasted receipt text is never shown as "verified". Green means the bank's own record confirmed it.

For Telebirr there's a catch. Its receipt page only answers Ethiopian networks and blocks cloud servers, cheki.et's included. So the hosted cheki.et doesn't check Telebirr itself: it opens the official ethiotelecom receipt page for that number, and you check the amount, the receiver's name and the date there before handing over the goods. That page is the proof, not the QR and not cheki's screen. Direct checks will come back once a relay inside Ethiopia is running. Until then, the self-hosted Docker version on an Ethiopian network checks Telebirr directly.

You have Do this
The receipt QR Scan it on cheki.et. Best option, no typos.
The receipt link Paste it.
Only the transaction number Type it, as a fallback.
Then, for Telebirr Check amount, receiver and date on the official page it opens.

Try it, or help

  • Scan or paste a Telebirr receipt QR at cheki.et. The decoding happens in your browser.
  • Or send it to the API as qrData with bank telebirr (POST /api/verify, no API key). The hosted API answers 502 with success: false and a fallbackUrl, the official receipt page, because it can't reach Telebirr.
  • The same walkthrough lives on cheki.et, and the format notes are in docs/telebirr-qr.md.
  • Open questions: what tag 80 means, and whether other receipt types use other tags. The format was worked out from one real receipt, so community fixes are very welcome. If you have a Telebirr QR that cheki.et rejects, an issue on GitHub with the payload redacted helps a lot.

cheki.et is MIT licensed, maintained by Ray (@1RB on GitHub), and not affiliated with Ethio Telecom or any bank.

Top comments (0)