Ironically, I don't cover error correction

Decoding QR Codes for fun and profit

Written: 2024-06-01

QR code decoders

If you’ve ever wanted to make your own QR Code decoder, you’ve probably come across these tutorials, and while they are fantastic, they are for encoding not decoding. Trying to use them myself I ran into numerous pitfalls, so I’ll save you from bashing your head against your desk for several hours.

Generally speaking, the decoding process goes like this:

  • Read the format bits to find the error correction level and mask pattern
  • Read and unmask the data codewords
  • Deinterleave the data codewords
  • Read the encoding and length information
  • Decode the data

There’s a lot more involved on the error correction and image processing side, but I won’t be going over those in this guide.

Anatomy of a QR Code

Before we get to decoding, let’s first understand the different parts of a QR code.

  1. Modules

    • These are the distinct black and white boxes that make up the QR code
    • They are called modules to separate them from pixels, because in an image a module is usually made up of many pixels
    • A module is considered “on” or 1 when it is black, and “off” or 0 when it is white
  2. Version

    • The QR version is what defines the size of the QR code (and vice versa)
    • version = ((size - 21) / 4) + 1 (size being width or height in modules)

Functional modules

Functional modules are used for various metadata or image processing purposes.

  1. Finder pattern

    • Three distinct boxes in each corner except for the bottom-left.
    • They are 7x7 module boxes, with a 3x3 module box inside
    • The 3x3 inner box has a 1 module white border around it
    • They always have a 1 module white border around the two sides facing inwards
  2. Timing pattern

    • Two 1 module wide lines, alternating on/off
    • Horizontal and vertical
    • Aligned to the bottom-left corner of the top-left finder pattern
  3. Alignment pattern

    • These are smaller boxes spread evenly across the QR code
    • 5x5 modules boxes with 1 module in the center
    • Not present on smaller QR codes (version == 1)
    • The positions are dependent on QR version
      • And they are always in sync with the timing pattern
  4. Format bits

    • A 15 bit string containing QR code format metadata
    • Always wrapped around the top-left finder pattern
    • Also to the left of the bottom-left finder pattern, and below the top-right finder pattern
  5. Dark Module

    • A single module that’s always on (dark)
    • Always in the same place - aligned to the top-left corner of the bottom-left finder pattern
  6. Version bits

    • 18 bits defining the QR version
    • Always a 6x3 rectangle, above the bottom-left finder pattern and to the left of the top-left finder pattern
    • Only present on large QR codes (version > 6)
    • 6 bit version number with 12 bits for error correction
    • Mostly useful for error correction; QR version can be computed from the size

Tip: Hover over me, I’m interactive!

[Finder pattern] [Timing pattern] [Alignment pattern]
[Format bits] [Dark Module] [Version bits]

(The dark module is a bit hard to see, it’s next to the top-right corner of the bottom-left finder pattern)

Text manipulation

For no particular reason let’s assume you have a QR code in the following format:

const qrText = `
█▀▀▀▀▀█ █▀   ███▀ █▄█▀▀▀▄▄▀ ▄▀█▄ ▄▄▄  ▄▄█ █▀▀▀▀▀█
█ ███ █ ▀█▄▄▄  ▄█ ▄▄██▄█▄▄▀▀▄█  ▄▀▀▄▄▄ █▀ █ ███ █
█ ▀▀▀ █ ▄█▄   ▀█▄█ ▀ ██▀▀▀█▄▀▄ ▀ ▄▄ ▀▀▄   █ ▀▀▀ █
▀▀▀▀▀▀▀ █ █ ▀ ▀▄▀ █▄▀ █ ▀ █▄▀ █ █▄▀ █ █▄▀ ▀▀▀▀▀▀▀
  ▀▀█▄▀▄█▀█   ▄██▄ ▀▄ ▀█▀▀█ ▄█▄█▀▀▀▀  ▀█▀███ ▄▀▀▀
▄▄█ ▄▄▀▀▄▀ ██▄██▄ ▄▀██  ▄▀▀█▀ ▄▄█▀█▀▄ ██▀▀▄▀▄█▀▀▀
█▀▄▄  ▀  ▀▄ ▀█▀ ▄ █ ▀  ▄█▀▄█▄ ▀▀▀▀█▄█▄ █▀▄▄ ▄▄▀▀ 
▀  ██▀▀█ █▀ ▀ █  ▄ ▄█▄▀██▄▀ ▀█ ▀▄█ ▀  ▀██▄ ▄▄▄ █▀
  ▀█ ▀▀ ▀  ▀ ▄ █▀▄ ▀▄▀▀ ▄▄   ▄▄ ▄▄  █▀ ▀▄▄ █ ▄▄ ▀
██ ▀█▄▀█▀█ ▄▄▀▄ ▀█▀▀▀ ▀▀▄█▀▄ ▄█ ▄  ▀ ▀ ▀█▄█▀▄▄▀ ▀
█▀██ ▀▀▄█▄ █▀▀█▄▀▀ ▀▄▄█▄█▄▄ ▀ ▀    █▀▄▀▄▀ ██ ▀█▄▀
█ ▀▀█▀▀▀█▄▀▄▀ ▀█ ▄█▀▀▄█▀▀▀█  ▄ ▀▀▀█  ▀▀▀█▀▀▀██ ██
 ▀█▄█ ▀ █▄██▀▄██▄ ▀   █ ▀ █▀▀▀▀▀█ ▀▀▀ ▀ █ ▀ █▀▀ █
 █ ██▀▀▀█▄▄▀▀▀█▄▄▀▄██▄▀████ █▄ █▀ █▄▀▀▀ ▀▀▀▀█  ▀ 
▄█   █▀█ ▀█ ██▀█ ▀█▀▄▀▄  ▀ ▄▀█▄▄ ▀▀█▀█ ▀ ▄▀▀ ▀█▀ 
██▄ ▀▀▀ █  ▄▀ ▄▀  ▀▄██ ▄█▀▄▀▄  ▄ ███▄ ▀▀█▀▀▀  ▀▄▀
▀  ▄█▀▀██ ▄▀█ ▄███ █▀▄███ ▀▄ ▀▀▄ █ ▄█ ▀▀ ▀▄▄▀▄█  
▄ ██ ▀▀ ██▀█▄█▀ ▄█ ▀ ▀█▄▄▀▀▀ ▄█  ▄▀▄▀ █▀█ ▀ ▀█▀█▀
 █ █▄▄▀▀ ▄▄██▄▄█  ▄ ▀▄█ ██▄█ ▀▄▀█▄ ▀▀▀ ██▄▀▄▀▀██ 
 █▄▄ ▀▀  █ ▄▀ ▀▀▀ ▄ ███▄▄▄▄ ▄▄ ▀█ ▄ ▀▄█▀▀ ▀▄ ▀███
▀▀▀   ▀▀▄▄▄ ▄▀▄▀ ▄█▀█ █▀▀▀█ ▄██▀▄▀█ ▀▄███▀▀▀██ ▀▀
█▀▀▀▀▀█  ▀█▄██▀█ █ ▀▄▄█ ▀ █▄▀█▀█ ▀  ▀ ▄▄█ ▀ █▀▀ ▀
█ ███ █ █▀▀▄ ▄ ▄▀ █ ██████▀ ▀███▄▀ █▀█ ▀▀▀█▀██▀▀▄
█ ▀▀▀ █ ▀██ ▄▄▀▀▄▀█ ▀▄█  ▄▄ ▀▀▀█▄▀▀ ▀ █▀ █▄  ▀▀▀█
▀▀▀▀▀▀▀   ▀▀▀▀▀▀ ▀▀ ▀▀  ▀      ▀ ▀  ▀ ▀▀   ▀  ▀▀▀`;

If you squint your eyes, it somewhat resembles a QR code. Your phone probably can’t read it. Let’s parse that text and turn it into something more useful.

Given a coordinate (x, y), we have to get the right character from the text. While each character in a line represents one x coordinate, each line represents two y coordinates. Therefore, we get the correct character in each line by selecting (x, y/2) of the text characters.

// clean and split up the qr code text into lines for simpler access
const qrLines = qrText.trim().split("\n");

const char = qrLines[Math.floor(y / 2)][x];

Now for a detour about binary math. Stay with me, I promise it will be quick.

The text is made up of block characters, such that two pixels are stacked vertically in one line of characters:

const emptyBlock = " "; // colloquially "space"
const topHalf    = "▀";
const bottomHalf = "▄";
const fullBlock  = "█";

If you rotate them 90 degrees to the right, you can think of them as a two-bit binary number

each block character in order, with the bit representation below

const blockMap = {
    [emptyBlock]: 0b00, // 0
    [topHalf]:    0b01, // 1
    [bottomHalf]: 0b10, // 2
    [fullBlock]:  0b11  // 3
}

With this map, we can turn each character into a number, and then we “extract” the bits we want by using some bitwise operations.

So, to get the numerical value we look up the character in the map (blockMap[char]).

This still gives us two possible modules. In order to figure out if we want the top or bottom module, we simply do y & 1. This returns 0 for the top, and 1 for the bottom.

Conveniently, we ordered our bits such that the right-most bit corresponds to the top, and the left-most bit is the bottom. We can now bit-shift our numerical representation by the result of y & 1, so that the right-most bit is always the one we need.

blockMap[char] >> (y & 1)

Last, we need to wrap the result of all of that in one final & 1. This is because in the case where y & 1 == 0, we don’t bit shift at all, and so we return the whole blockMap[char], rather than the top bit.

Putting it all together, we can make a function to get whatever bit we want from our QR code text:

const getModule = (x,y) => {
    const char = qrLines[Math.floor(y / 2)][x];

    return (blockMap[char] >> (y & 1)) & 1;
}

Honestly, I’m not sure if these two lines of code are ugly or elegant. Both I guess?

By iterating over each line twice (and with some flexbox magic), we now have a clean QR code.

Now you can scan it with your phone. But you don’t want spoilers, do you?

Decoding Pt.1: Metadata

To begin decoding we have to know the QR code version, which we can calculate from the size.

const size = QRLines[0].length; // easier to get the width than the height

const version = ((size - 21) / 4) + 1;

You can also get it from the version bits on the larger QR codes, if you want to double-check (or perform error correction).

In our case, we have a 49x49 module QR code, which means it’s version 8.

Next we need the format metadata. It’s 15 bits long; The first 5 bits contain format data and the last 10 are for error correction. It’s wrapped around the top-left finder pattern, or next to the bottom-left and top-right patterns. Watch out for the timing pattern and dark module!

simplified QR code showing the order of format bits

As we do not care about error correction we only have to read the first 5 bits, from either one. The format is then masked with these bits: 101010000010010. Unmasking is performed by XORing the masked data with the mask.

const formatBits = [];

for(let x = 0; x < 5; x++) 
    formatBits.push(getModule(x, 8));

const format = parseInt(formatBits.join(""), 2) ^ 0b10101;

The format contains:

  • 2 bits for error correction level
  • 3 bits for the mask pattern

With some simple bitwise operations, we can get their values

const ecLevel = format >> 3;
const mask = format & 0b111;

There are 4 error correction levels, and 8 mask patterns

Level Binary Decimal
M 00 0
L 01 1
H 10 2
Q 11 3

Note that the error correction levels are not in order, it should be L -> M -> Q -> H (from lowest to highest)

Mask Binary Formula
0 000 (y + x) % 2
1 001 y % 2
2 010 x % 3
3 011 (y + x) % 3
4 100 (~~(y/2) + ~~(x/3)) % 2
5 101 ((y*x) % 2 + (y*x) % 3)
6 110 ((y*x) % 2 + (y*x) % 3) % 2
7 111 ((y+x) % 2 + (y*x) % 3) % 2

(NB: ~~ is shorthand for Math.floor())

In our QR code the format starts with 00111. After unmasking, that’s 10010, meaning our error correction is 10 (2 or High) and the mask pattern is 010 (also 2 or x % 3).

The mask pattern is a 2D mask applied to the data and error correction codewords before they get placed in the QR code. Mask patterns are expressed as formulas that take in the position of the module and produce cool patterns to further break up the QR code.

Mask patterns visualized

(Yoinked from the QR code wikipedia page)

While I’m not implementing error correction in this guide, the error correction level will be useful later.

Decoding Pt.2: Reading and Unmasking

Now that we have the mask pattern, we can begin reading the data codewords in the QR code.

Encoded data split into 8-bit codewords. The codewords are placed starting from the bottom-right corner tightly packed, snaking up and down, left to right. Immediately following the data codewords are the error correction codewords, similarly masked, interleaved, and snaked left to right.

The individual bits in each codeword is placed in a zig-zag pattern, in either upwards or downwards direction.

Diagram showing the order of bits read in a codeword

If you encounter functional modules, skip over them.

Diagram showing skipping over an alignment pattern

When you reach the top or bottom, flip the vertical reading direction and continue reading two modules to the left.

Diagram showing vertical direction reversing

The logic for reading data bits is quite simple, despite many diagrams on the internet trying to confuse you.

  • Start at the bottom-right corner and set your vertical direction to up

While you have data to read:

  • Read the module at your current position
  • If you are in an odd column, move one module left
  • Otherwise, move one module vertically and to the right
  • If you are outside the vertical bounds of the QR code:
    • Flip your vertical direction
    • Move one module vertically (so you’re back inside)
    • Move two modules left
  • If you are currently on a functional module, repeat the moving logic until you aren’t

There is one notable exception - if you are in the 7th column (the vertical timing pattern), move one additional module to the left. However if you don’t care about error correction it shouldn’t matter as the data codewords don’t reach that far into the QR code.

There’s are many ways that you could implement reading; Personally I came up with the “skip mask” approach.

Create a square 2D array the same size as the QR code filled with 0, and set all the locations with functional modules to 1. This simplifies reading logic as we can easily check if the module we’re on is a functional module to be skipped over.

const skipMask = Array.from({length: size}, () => new Array(size).fill(0));

We have to calculate the locations with functional modules as they are different for each QR version. Luckily, most of the areas we’re interested in are grouped together and always in the same place. We don’t even need all the functional modules, as we’re mainly interested in the second half of the QR code.

// a few convenience functions

// set a rectangular region of the skip mask
const setRectangular = (startX, startY, endX, endY) => {
   for(let y = startY; y < endY; y++)
       for(let x = startX; x < endX; x++)
           skipMask[y][x] = 1;
}

// set a square region of the skip mask
const setSquare = (startX, startY, size) =>
    setRectangular(startX, startY, startX + size, startY + size);

// the coordinate of the last module inside the QR code
const end = size - 1;


// finder pattern + version block
setRectangular(end - 11, 0, size, 7);

// bottom border of qr code and format bits
setRectangular(end - 8, 7, size, 9);

// horizontal timing pattern
setRectangular(9, 6, end - 11, 7);

Alignment patterns get a little tricky, as each QR code version has different positions. I’ve omitted most of them for brevity, you can see the full list here.

The the positions of each alignment patterns are based on a grid in sync with the timing pattern and spread equally throughout the QR code. The coordinates where each line intersects is the center of an alignment pattern, except where they overlap with finder patterns.

A QR code with alignment patterns and their coordinates labelled

// an array of alignment pattern row/column positions, for each QR version
const alignmentPatterns = [
    // 7 elements not shown...
    [6, 24, 42],
    // ...
];

const patternPos = alignmentPatterns[version];

// iterate through all alignment patterns to generate all permutations
for(const x of patternPos)
    for(const y of patternPos) {
        // filter disallowed positions
        if(
            (x == 6 && y == 6) ||
            (x == 6 && y == end - 6) ||
            (x == end - 6 && y == 6)
        )
            continue;
        
        // the coordinates are for the center, but setSquare 
        // starts from the top-left corner 
        setSquare(x - 2, y - 2, 5);
    }

This will give us a rudimentary “skip mask”:

Next, we need to get the mask function using the mask pattern from the format bits earlier in Pt.1: Metadata.

const maskFunctions = [
    // ...
    (x, y) => x % 3 == 0,
    // ...
];

const maskFunction = maskFunctions[mask]

Now we can implement the module reading logic.

// start by going up
let direction = -1;

const readBit = (x, y) => {
    // get current module and XOR with the mask
    const bit = getModule(x, y) ^ maskFormula(x, y);

    // calculate the next module position to read, skipping any functional modules
    do {
        if (x & 1) { // left column
            x++; // move right & vertical
            y += direction;
        } else { // right column
            x--; // move left
        }

        // when we reach the top/bottom edge
        if(y < 0 || y == size) {
            y -= direction; // go back
            direction *= -1; // flip reading direction
            x -= 2; // jump to next column
        }

        // if we go OOB (x == -1), there's no more to read
        if(x < 0)
            break;
        
    } while (skipMask[y][x] == 1)

    return [bit, x, y]; 
}

And just for good measure, a convenience function is useful too.

const readBits = (x, y, n) => {
    const bits = [];

    while(n-- > 0) {
        let bit;
        [bit, x, y] = readBit(x, y);
        
        bits.push(bit);
    }

    return [bits.join(""), x, y];
}

But how do we know when to stop reading? The data codewords are immediately followed by the error correction codewords without any indicator. The amount of data stored in each QR code is predefined for each version and error correction level. This is where that ecLevel from the format is needed.

In order to get the total number of data codewords we will need to use the group and block information. This will be explained later in the deinterleaving section, for now you can treat it as a black box.

const codewordGroups = [
    // ...
    [ // 8
        [[2,97]],        // L
        [[2,38],[2,39]], // M
        [[4,18],[2,19]], // Q
        [[4,14],[2,15]], // H
    ],
    // ...
];

// the ec levels are not in order for some reason
const ecLevelToIndex = [1, 0, 3, 2]; // M, L, H, Q
const ecIndex = ecLevelToIndex[ecLevel];

const groups = codewordGroups[version][ecIndex];

let totalDataCodewords = groups.reduce(
    (sum, [blocks, codewords]) => sum + (blocks * codewords), 0
);

Finally, we can read in all the data codewords by reading 8 modules at a time

const interleavedData = [];

// start at the bottom-right corner
let currentX = end;
let currentY = end;

while(totalDataCodewords-- > 0) {
    let bits;
    [bits, currentX, currentY] = readBits(currentX, currentY, 8);

    interleavedData.push(bits);
}

It should look like this:

Decoding Pt.3: Deinterleaving

This part definitely threw me for a loop. Not many places explain interleaving, especially if they use smaller QR codes, as they do not interleave data at all. The most frustrating thing was that the first data codeword is always the first one interleaved, so it looks like you’re reading data correctly for the first 8 bits - and then you’re not. Let me show you how it really works.

Interleaving is the process of splitting up the data and weaving it into itself in a sequential pattern to break it up and help with error correction. Data codewords are put into a number of blocks, which are then grouped into groups - I know, amazing terminology. Each block contains a fixed number of codewords.

There are predefined number of groups, blocks, and codewords per block for every version and error correction level. You can find the full list over here. Note that again, we’re not concerned with error correction codewords, so we can ignore that portion.

Data codewords are placed into each block for each group sequentially. So for 15 codewords from 0 - E put into 2 groups with 3 blocks each:

// data to interleave
[0,1,2,3,4,5,6,7,8,9,A,B,C,D,E];

[ // group 0
    [0,6,C], // block 0
    [1,7,D], // block 1
    [2,8,E]  // block 2
];

[ // group 1
    [3,9],   // block 0
    [4,A],   // block 1
    [5,B]    // block 2
];

The blocks and groups are then concatenated in order, so the final interleaved data looks like:

//  g0                   g1
//  b0     b1     b2     b0   b1   b2
  [ 0,6,C, 1,7,D, 2,8,E, 3,9, 4,A, 5,B ]

Deinterleaving is “simply” reversing this process. As usual there’s a number of ways to approach this.

A naive approach would be to calculate the stride by summing up total number of blocks for all groups and then placing each data codeword into a buffer such that i % codewordsPerBlock * stride + floor(i / stride) (where i is the index of the interleaved data). This appears to work, however the number of codewords per block is not the same in all the groups. This leads to some characters being corrupted, though a large portion of the data will be readable.

One approach I came up with was “binning”. Create arrays for each block in each group and iterate over the data codewords, incrementing counters for which group and block you are in. When incrementing, check if the group you are in is full - if you are, then keep incrementing the group index.

First, to explain the format in which I store group and block information

const codewordGroups = [
    // ...
    [ // 8
        [[2,97]],        // L
        [[2,38],[2,39]], // M
        [[4,18],[2,19]], // Q
        [[4,14],[2,15]], // H
    ],
    // ...
];

codewordGroups is a 4D array. At the top level is the version.

  • For each version there are 4 error correction arrays (in order from low to high)
    • For every error correction level, there is at least 1 group
      • Each group is an array with two numbers:
        • The number of blocks in a group
        • The number of data codewords in each block

In our example, with a version 8 QR code and High error correction, we have 2 groups

  • Group 1 has 4 blocks with 14 codewords each
  • Group 2 has 2 block with 15 codewords each

This means that we have 4 * 14 + 2 * 15 = 86 data codewords in total.

You should backtrack to the reading section and understand how we calculate the total number of data codewords. That’s right, this tutorial has metroidvania elements!

Now to implement deinterleaving:

let currentGroup = 0;
let currentBlock = 0;

const dataBlocks = groups.map(([blocks]) => 
    Array.from({length: blocks}, () => new Array())
);

for(let i = 0; i < interleavedBlocks.length; i++) {
    dataBlocks[currentGroup][currentBlock].push(interleavedData[i]);

    const [blocksInGroup] = groups[currentGroup];

    do {
        if(i == interleavedBlocks.length - 1)
            break; // avoid an infinite loop at the end

        currentBlock += 1;

        if(currentBlock >= blocksInGroup) {
            currentBlock = 0;
            currentGroup += 1;

            if(currentGroup >= groups.length) {
                currentGroup = 0;
            }
        }
    } while(dataBlocks[currentGroup][currentBlock].length >= groups[currentGroup][1])
}

This should work for any combination of group and block sizes, though there’s definitely room to optimize if you are only targeting a few select QR versions.

To give you an idea of how it should look:

Group 0

Block 0: 4417b226964223a22796f7865646

Block 1: e222c2275736572223a226e6f747

Block 2: 5736564222c227061636b696e675

Block 3: f6e6f746573223a22222c2268366

Group 1

Block 0: b223a2262636e3869227d0ec11ec11

Block 1: ec11ec11ec11ec11ec11ec11ec11ec


Tip: I’m interactive too! Hover over a block to isolate it on the QR code

Last but not least, we just concatenate all the groups and blocks in order to get our final bit string

const encodedData = dataBlocks.flat(3).join(""); // isn't ES6 nice

Here’s what that looks like in our QR code:

44 17 b2 26 96 42 23 a2 27 96 f7 86 56 46 e2 22
c2 27 57 36 57 22 23 a2 26 e6 f7 47 57 36 56 42
22 c2 27 06 16 36 b6 96 e6 75 f6 e6 f7 46 57 32
23 a2 22 22 c2 26 83 66 b2 23 a2 26 26 36 e3 86
92 27 d0 ec 11 ec 11 ec 11 ec 11 ec 11 ec 11 ec
11 ec 11 ec 11 ec

(I’ve converted the bits to bytes for your viewing pleasure)

Decoding Pt.4: Decoding the data

We’re now in the final stretch. We have our raw bits hot off the QR code and carefully deinterleaved. We just need to turn those bits into something useful.

Luckily for us, it’s not too hard. The first 4 bits are the encoding type, and despite what you may expect there’s only 5 encoding modes:

Type Bits
Numeric 0001
Alphanumeric 0010
Byte 0100
Kanji 1000
ECI 0111

Most of these are self-explanatory:

  • Numeric encodes only numbers 0-9
  • Alphanumeric encodes only letters, numbers, and a few symbols
  • Bytes can be any data, but usually interpreted as UTF-8 text
  • Kanji uses double-byte Shift-JIS

ECI is a little weird. It’s not an encoding format in of itself, but rather an indicator that multiple encoding formats are used. It’s quite rare, and not supported by every QR code reader. Aside from that, you can find the details for each encoding mode here. You’ll most commonly encounter alphanumeric and bytes modes (maybe Kanji if you’re dealing with 日本語).

The following 8 bits are the length. The number of length bits is actually dependent on the QR size and encoding type, which you can find the reference for here. We’ll just deal with bytes mode here, because it’s the simplest to decode.

const encoding = parseInt(encodedData.slice(0,4), 2);
const length = parseInt(encodedData.slice(4,12), 2);

If you scroll up a bit and read off the first two bytes, you can see our QR code has an encoding of 4 or 0100 (bytes) and our length is 41 hex or 65 bytes total.

Immediately following the encoding and length is the data itself.

const dataBitstring = encodedData.slice(12, 12+length*8);

// split up the bit string into 8-bit strings with some magical regex
const dataBits = [...dataBitstring.matchAll(/.{1,8}/g)]; 

const decoded = dataBits
    .map(bits => parseInt(bits, 2))
    .map(num => String.fromCharCode(num))
    .join("")

Finally, we have our big reveal, the secret message you’ve undoubtedly been dying to read:

{"id":"yoxedn","user":"notused","packing_notes":"","h6k":"bcn8i"}

Oh. Not sure what that means, really. Maybe you should carry on working on t2 scraper?

PS: If you’ve been paying attention, you’ll notice that we only have 65 bytes, but our qr code has 86 codewords total. If you look closely, you’ll see a repeated pattern of ec and 11 at the end. This is actually intentional padding, used to fill up any remaining space. It’s not that interesting unless you’re worried about data integrity…