Skip to content

Payload Format

You don't need to read this to use the package. It's here for anyone debugging why a particular banking app is behaving oddly, or building a decoder.

Structure

The payload is a tag-length-value string. Each field is a two-digit tag, a two-digit length, then the value.

0002 02                    format version
0102 12                    point of initiation
0202 00                    payload type
0424 PK33ABCD0000...       destination IBAN
0504 2500                  amount          (optional)
0712 311220262359          expiry          (optional)
1004 A1B2                  CRC-16 checksum

The Fields

TagLengthValue
002Format version, always 02
01211 static, 12 dynamic
022Payload type, always 00
0424The Pakistani IBAN, normalised
051–10Normalised amount, omitted when static
0712Expiry as DDMMYYYYHHMM, omitted when static
104CRC-16 checksum

Static vs Dynamic

Tag 01 is 12 when the payload carries an amount or an expiry, and 11 when it carries neither.

php
RaastQr::iban($iban)->toPayload();
// 000202 010211 020200 0424PK33...

RaastQr::iban($iban)->amount('2500')->toPayload();
// 000202 010212 020200 0424PK33... 05042500 0712...

The Amount Field

The length is the actual character count, so it varies:

0501 4          amount of 4
0504 2500       amount of 2500
0507 5000.50    amount of 5000.50

Normalisation strips commas and leading zeros, and rejects anything with more than two decimal places or more than ten characters. See Setting the Amount.

The Expiry Field

Always twelve characters, DDMMYYYYHHMM:

0712 311220262359      31 December 2026 at 23:59
0712 311220261830      31 December 2026 at 18:30

A date supplied without a time is encoded at 23:59.

The Checksum

Tag 10 holds a CRC-16/CCITT-FALSE checksum:

  • Polynomial 0x1021
  • Initial value 0xFFFF
  • No reflection, no final XOR
  • Rendered as four uppercase hexadecimal characters

The checksum covers everything before it including the literal 1004. That last detail catches people out when they write their own encoder.

php
$payload = '000202...0424PK33...' . '1004';
$payload .= Crc16::calculate($payload);

Verifying by Hand

bash
php artisan raast-qr:generate PK33ABCD0000000000000000 --amount=2500 --expiry=2026-12-31 --payload
0002020102120202000424PK33ABCD000000000000000005042500071231122026235910040CDF

    └─ IBAN, 24 chars                └─ CRC
    └─ payload type 00        └─ expiry 31/12/2026 23:59
    └─ dynamic (12)                         └─ amount 2500
└─ format version 02

What It Does Not Contain

  • ❌ No account title — the payer's app resolves that from the IBAN
  • ❌ No merchant identifier
  • ❌ No payment reference or order number
  • ❌ No currency code — PKR is implied
  • ❌ No signature or authentication

That last point is worth sitting with. The payload is not signed. Anyone can construct one for any IBAN. That's fine, because a Raast QR can only ever send money to an account — it can't withdraw. But it does mean the payer's confirmation screen is the only real check in the system, which is why every screen you build should tell people to look at it.

Stability

The vector tests in tests/PayloadTest.php assert exact payload strings. If a change to the package makes one fail, the encoder has drifted from what banking apps read — that's a release blocker, not a test to update.


Made with ❤️ from Pakistan