Pular para o conteúdo
Node.js

Node.js: gerando QR Code PIX do zero

Paloma Macetko
Ilustração colorida de um unicórnio de crina arco-íris ao lado de uma coruja mágica segurando uma moeda lisa, com um QR Code desenhado em linhas de luz flutuando entre os dois e uma varinha lançando um feixe que o lê, tendo ao fundo um castelo com velas flutuantes

Olá meus Unicórnios! 🦄✨

Sabe quando você acha que vai ser meia hora de trabalho? 😅 Pois é. "Gerar um QR Code PIX" tem cara de tarefa de sexta à tarde: pega a chave, joga numa biblioteca, cospe a imagem, vai tomar um café.

Só que o QR Code do PIX não guarda um link. Ele guarda uma string estruturada, num padrão do BACEN chamado EMV QR Code, com campos numerados, tamanhos declarados e um checksum no fim. Se um único caractere estiver fora do lugar, o app do banco lê o QR perfeitamente — e recusa o pagamento.

E é aí que mora a parte cruel: o QR fica lindo. Gera, abre, escaneia, o celular reconhece na hora. Só na tela do banco é que aparece o "QR Code inválido". Nenhum erro no seu terminal. Nenhum log. 🙃

Este artigo é o que eu aprendi montando esse payload à mão: como cada campo é construído, como o CRC é calculado, e as duas armadilhas que me fizeram gerar QR Codes válidos que nenhum banco aceitava. Node.js puro, duas dependências.

Todo o código está no GitHub, com uma página pronta para você digitar os dados e ver o QR Code nascer:

Exemplos_QRCodePIX no GitHubO gerador do payload, a API em Express e uma página com formulário para você testar. Node.js, duas dependências, licença MIT.github.com

🧱 O formato: tudo é ID + tamanho + valor

Antes de qualquer código, a única ideia que você precisa entender. O payload inteiro é uma sequência de blocos assim:

ID (2 dígitos) + TAMANHO (2 dígitos) + VALOR

Nada de separador, nada de JSON, nada de vírgula. O campo diz quantos caracteres ele ocupa, e o próximo campo começa exatamente ali. É o velho TLV (Tag-Length-Value). Um exemplo real, o campo do país:

5802BR
││ ││ └── valor: "BR"
││ └──── tamanho: 02
└─────── id: 58 (Country Code)

E a função que monta isso é tão pequena que chega a ser decepcionante:

function emv(id, value) {
  const len = value.length.toString().padStart(2, '0');
  return `${id}${len}${value}`;
}

Repare no padStart(2, '0'). O tamanho tem sempre dois dígitos: um valor de 3 caracteres é 03, nunca 3. Esqueça esse zero e todo o resto do payload desanda — o leitor vai contar os caracteres errado a partir dali e ler lixo até o fim. 😵

Os campos podem ainda ser aninhados: o valor de um campo pode ser, ele mesmo, uma sequência de blocos TLV. É assim que a chave PIX entra, dentro do campo 26.

🗺️ O mapa dos campos

Estes são os campos que um PIX estático precisa. Guarde esta tabela — ela é 80% do trabalho:

ID   Campo                          Valor
──   ────────────────────────────   ──────────────────────────────
00   Versão do formato              "01" (sempre)
26   Dados da conta do recebedor    GUI + chave PIX (aninhado)
     └ 00  Arranjo de pagamento     "br.gov.bcb.pix" (sempre)
     └ 01  Chave PIX                sua chave PIX
52   Categoria do estabelecimento   "0000" (sem categoria)
53   Moeda da transação             "986" (BRL, ISO 4217)
54   Valor da transação             valor — OPCIONAL
58   País                           "BR"
59   Nome do recebedor              nome, máx. 25
60   Cidade do recebedor            cidade, máx. 15
62   Dados adicionais               txid (aninhado)
     └ 05  Id. da cobrança          "***" (genérico)
63   CRC16                          checksum, sempre por último

Se você for consultar o manual do BACEN ou a especificação EMV, os nomes lá estão em inglês. A tradução de cada um, para você achar o campo: 00 Payload Format Indicator, 26 Merchant Account Information (com GUI e a chave), 52 Merchant Category Code, 53 Transaction Currency, 54 Transaction Amount, 58 Country Code, 59 Merchant Name, 60 Merchant City, 62 Additional Data Field (com o Reference Label).

Dois pontos dessa tabela que não são óbvios:

A ordem importa. Os campos vão em ordem crescente de ID, e o 63 é obrigatoriamente o último — porque o checksum é calculado sobre tudo que veio antes dele.

O 986 não é "código do PIX". É o código ISO 4217 do real. E o 0000 do campo 52 quer dizer "estabelecimento sem categoria definida" — é o valor certo para pessoa física, não uma gambiarra.

🔐 O CRC16: o campo que se calcula sozinho

Aqui está a parte que assusta na primeira leitura e é mansa na segunda. O último campo é um CRC16-CCITT calculado sobre a string inteira — incluindo os quatro caracteres 6304 que abrem o próprio campo do checksum.

Isso é menos maluco do que parece: você monta o payload todo terminando em 6304 (o ID 63 e o tamanho 04, sem o valor), calcula o CRC dessa string e cola o resultado no fim.

function crc16(str) {
  let crc = 0xFFFF;
  const buf = Buffer.from(str, 'utf8');
  for (let i = 0; i < buf.length; i++) {
    crc ^= buf[i] << 8;
    for (let j = 0; j < 8; j++) {
      crc = (crc & 0x8000) ? ((crc << 1) ^ 0x1021) : (crc << 1);
      crc &= 0xFFFF;
    }
  }
  return crc.toString(16).toUpperCase().padStart(4, '0');
}

Três detalhes desse trecho que valem mais que a fórmula:

Aquele crc &= 0xFFFF dentro do laço não é decoração. Em JavaScript, << trabalha com 32 bits, então o deslocamento empurra bits para fora da faixa de 16 que a gente quer. Sem essa máscara a cada volta, o valor cresce e o resultado sai errado. É o bug clássico de portar CRC de C para JS — em C o uint16_t trunca sozinho, aqui não.

O padStart(4, '0') resolve um caso raro e traiçoeiro. Se o CRC calculado for, digamos, 0x0D10, o toString(16) devolve "d10" — três caracteres. Sem o padStart, você geraria um campo de 3 dígitos onde o tamanho declarado diz 4. E como isso só acontece quando o checksum começa com zero, o bug aparece em 1 QR Code a cada 16. Bom apetite para depurar isso em produção. 😬

É Buffer.from(str, 'utf8'), não charCodeAt. O CRC é calculado sobre bytes. Como a gente já removeu os acentos (já chego lá), na prática dá no mesmo — mas se um caractere multibyte escapar, as duas contas divergem, e aí você tem um QR que falha só para alguns nomes.

✂️ Sanitização: o BACEN não quer o seu acento

Nome e cidade não vão crus. O padrão só aceita um subconjunto bem restrito de caracteres, e os limites de tamanho são apertados: 25 para o nome, 15 para a cidade.

function sanitize(str, maxLen) {
  return str
    .normalize('NFD')
    .replace(/[\u0300-\u036f]/g, '') // remove acentos
    .replace(/[^a-zA-Z0-9 ]/g, '')   // só alfanumérico e espaço
    .replace(/\s+/g, ' ')            // colapsa espaços múltiplos
    .trim()
    .substring(0, maxLen);
}

A dupla normalize('NFD') + remoção da faixa ̀-ͯ é o truque bonito daqui. O NFD decompõe o "ã" em dois caracteres — o "a" e o til, separados — e a faixa removida é exatamente a dos acentos soltos. Resultado: "ã" vira "a" em vez de sumir.

Faça na ordem inversa e você perde a letra inteira: o filtro [^a-zA-Z0-9 ] comeria o "ã" antes de alguém decompor coisa alguma, e "João" viraria "Joo". 😅

Aquele replace(/\s+/g, ' ') chegou depois, num commit separado, e é consequência direta da linha de cima: ao remover "&" de "Ação & Cia", sobram dois espaços onde havia um símbolo. Rodando de verdade:

"João  Ação & Cia."                        -> "Joao Acao Cia" (13)
"Paloma Macetko Comercio de Software LTDA" -> "Paloma Macetko Comercio d" (25)
"  Ana   Maria  "                          -> "Ana Maria" (9)

Repare no do meio: o corte em 25 caracteres é cego, cai no meio da palavra e o comprovante do cliente vai dizer "Comercio d". Não é bug — é o limite do padrão. Mas é o tipo de coisa que você prefere descobrir agora do que pelo WhatsApp de um cliente. 😬

🚨 Duas armadilhas que me custaram o dia

Se você só for ler um pedaço deste artigo, leia este. 🙏 As duas coisas abaixo geram QR Codes perfeitamente válidos — CRC correto, campos bem formados, leitura impecável — que simplesmente não funcionam.

1. O telefone precisa do +55 (e eu não sabia)

A primeira versão do meu código punha a chave no payload assim:

const chaveEmv = emv('01', chave.trim());   // ❌ o bug

Parece inofensivo, né? Um trim(), o mínimo de higiene. E funciona lindamente para e-mail, CPF e chave aleatória.

Aí eu testei com o telefone. O QR gerou, o celular leu, o banco recusou. Fui conferir o CRC — certo. Conferi os tamanhos dos campos — certos. Passei um bom tempo achando que era problema do banco. Era meu. 😳

Olha o que estava indo para dentro do payload:

entrada : (17) 99999-8888
  antes : 0126370014br.gov.bcb.pix0115(17) 99999-8888
  depois: 0126360014br.gov.bcb.pix0114+5517999998888

entrada : 17999998888
  antes : 0126330014br.gov.bcb.pix011117999998888
  depois: 0126360014br.gov.bcb.pix0114+5517999998888

Isso mesmo: parênteses, espaço e hífen viajaram para dentro do QR Code. 🤯 E mesmo limpando a máscara, o número puro continua sendo recusado — o BACEN exige o formato E.164, com o código do país: +5517999998888.

O detalhe que faz esse bug ser tão difícil é que tudo ao redor está certo. O CRC é calculado sobre a string errada, então ele bate. O tamanho declarado (15) confere com o conteúdo. O QR é impecável. O único que sabe que aquilo não é uma chave PIX é o banco.

2. Onze dígitos podem ser um CPF ou um celular

Corrigido o E.164, veio o problema de verdade — o interessante. Para saber se coloco o +55, preciso saber se aquilo é um telefone. Mas repare:

17999998888   → celular  (DDD 17 + 9 dígitos)  → precisa de +55
12345678909   → CPF                             → NÃO pode ter +55

Onze dígitos os dois. Só dígitos os dois. Se você errar a classificação, gera um QR com +5512345678909 — um CPF fantasiado de telefone que não existe em lugar nenhum. E, de novo: CRC válido, banco recusa.

A saída é o nono dígito do celular. Todo celular brasileiro é DD9XXXXXXXX — o 9 obrigatório logo depois do DDD, no índice 2. CPF não tem essa regra:

function normalizeChave(chave) {
  const k = chave.trim();

  // E-mail ou chave aleatória (UUID) contêm letras: mantém como está.
  if (/[a-zA-Z]/.test(k)) return k;

  // Já em formato E.164: mantém.
  if (k.startsWith('+')) return k;

  const digits = k.replace(/\D/g, '');

  // Telefone já com código do país (12-13 dígitos iniciando em 55).
  if ((digits.length === 12 || digits.length === 13) && digits.startsWith('55')) {
    return `+${digits}`;
  }

  // Celular: 11 dígitos com o 9º dígito = '9' (DDD + 9XXXXXXXX).
  // Isso o distingue de um CPF (que também tem 11 dígitos).
  if (digits.length === 11 && digits[2] === '9') {
    return `+55${digits}`;
  }

  // Telefone fixo: 10 dígitos (DDD + 8 dígitos).
  if (digits.length === 10) {
    return `+55${digits}`;
  }

  // CPF (11) / CNPJ (14) ou qualquer outro: mantém os dígitos.
  return digits || k;
}

A ordem dos testes aqui é a lógica, e cada linha evita um caso específico:

O teste de letras vem primeiro porque é o mais barato e o mais seguro: e-mail e chave aleatória (UUID) sempre têm letra, telefone e CPF nunca têm. Um return e pronto — nenhuma das regras numéricas abaixo pode estragá-los por acidente.

O startsWith('+') vem logo depois para respeitar quem já mandou certo. Sem ele, o replace(/\D/g, '') arrancaria o + e a gente teria que adivinhar de novo o que já estava explícito.

E o digits[2] === '9' é o coração. Não é elegante, mas é a única informação disponível para separar as duas coisas. Note que ele fica depois do teste de 12-13 dígitos: um número já com o 55 na frente tem o 9 em outra posição, e cairia fora da regra.

🧩 Juntando tudo

Com emv(), crc16(), sanitize() e normalizeChave() prontos, o gerador é quase uma transcrição da tabela de campos:

function generatePixPayload({ chave, nome, cidade, valor }) {
  if (!chave) throw new Error('Chave PIX é obrigatória');
  if (!nome)  throw new Error('Nome do recebedor é obrigatório');
  if (!cidade) throw new Error('Cidade é obrigatória');

  const nomeSanitizado   = sanitize(nome, 25);
  const cidadeSanitizada = sanitize(cidade, 15);

  // ID 26 - Merchant Account Information (PIX)
  const gui          = emv('00', 'br.gov.bcb.pix');
  const chaveEmv     = emv('01', normalizeChave(chave));
  const merchantInfo = emv('26', gui + chaveEmv);

  // ID 54 - Valor (opcional)
  let valorEmv = '';
  if (valor) {
    const num = parseFloat(String(valor).replace(',', '.'));
    if (!isNaN(num) && num > 0) {
      valorEmv = emv('54', num.toFixed(2));
    }
  }

  // ID 62 - Additional Data (txid obrigatório, "***" para genérico)
  const additionalData = emv('62', emv('05', '***'));

  const payload =
    emv('00', '01')             +  // Payload Format Indicator
    merchantInfo                +  // Merchant Account Information
    emv('52', '0000')           +  // MCC
    emv('53', '986')            +  // Moeda BRL
    valorEmv                    +  // Valor (se informado)
    emv('58', 'BR')             +  // País
    emv('59', nomeSanitizado)   +  // Nome recebedor
    emv('60', cidadeSanitizada) +  // Cidade
    additionalData              +  // Additional Data
    '6304';                        // CRC placeholder

  return payload + crc16(payload);
}

module.exports = { generatePixPayload };

Rodando de verdade, com chave de e-mail e R$ 10,00:

00020126430014br.gov.bcb.pix0121paloma@exemplo.com.br5204000053039865405
10.005802BR5914Paloma Macetko6009Sao Paulo62070503***63040D10

tamanho: 133 | CRC: 0D10

E aquele payload destrinchado campo a campo, que é a melhor forma de conferir se você acertou:

00 (02) 01
26 (43) [email protected]
    00 (14) br.gov.bcb.pix
    01 (21) [email protected]
52 (04) 0000
53 (03) 986
54 (05) 10.00
58 (02) BR
59 (14) Paloma Macetko
60 (09) Sao Paulo
62 (07) 0503***
    05 (03) ***
63 (04) 0D10

Repare que o CRC saiu 0D10 — começando com zero, justamente o caso do padStart(4, '0') lá de cima. Não foi escolha minha: foi o primeiro exemplo que eu rodei. É mesmo 1 em 16. 😄

💸 O campo do valor mente calado

Esse if (valor) lá em cima merece um capítulo só dele, porque ele é a terceira armadilha — e a mais silenciosa das três.

O campo 54 é opcional, e isso é um recurso ótimo: sem ele, o QR vira um "me paga o quanto você quiser", perfeito para doação ou gorjeta. O pagador digita o valor no app.

O problema é o que acontece quando você queria mandar um valor e mandou lixo. Rodando com várias entradas:

valor="10"       -> campo 54: 540510.00
valor="10.5"     -> campo 54: 540510.50
valor="10,50"    -> campo 54: 540510.50
valor=1234.5     -> campo 54: 54071234.50
valor="0"        -> campo 54: (ausente)
valor="0.00"     -> campo 54: (ausente)
valor=" "        -> campo 54: (ausente)
valor="abc"      -> campo 54: (ausente)

Olha os quatro últimos. 😳 Um valor inválido não gera erro: ele apaga o campo e devolve um QR de valor livre, com CRC perfeito e aparência normal.

Imagine a cena: seu formulário manda valor vazio por um bug qualquer, e você imprime 500 QR Codes de cobrança em que o cliente escolhe quanto pagar. Nada no seu log. Nada na sua tela. O QR está lindo. 🙃

Os dois lados dessa moeda:

Aceitar vírgula é acerto. Aquele String(valor).replace(',', '.') existe porque no Brasil 10,50 é o que sai de qualquer formulário — e parseFloat("10,50") devolveria 10, cobrando dez reais em vez de dez e cinquenta. Um bug de arredondamento silencioso e caro.

Engolir o inválido é escolha. A minha regra hoje: se o valor é opcional na sua interface, esse comportamento serve. Se ele é obrigatório, valide antes de chamar o gerador — não deixe o silêncio decidir por você:

const num = parseFloat(String(valor).replace(',', '.'));
if (isNaN(num) || num <= 0) {
  throw new Error(`Valor inválido: ${JSON.stringify(valor)}`);
}

E note o num > 0 do código original: um valor 0 também some. Correto, aliás — o padrão não aceita cobrança de zero real. Mas é mais um caminho para o campo evaporar sem avisar.

🖼️ Enfim, a imagem

Depois de tudo isso, virar a string em QR Code é a parte fácil — a biblioteca qrcode resolve numa linha, sem dependência nativa:

npm install express qrcode
const QRCode = require('qrcode');

const qrOptions = {
  errorCorrectionLevel: 'M',
  type: 'png',
  margin: 2,
  width: 300,
  color: { dark: '#000000', light: '#ffffff' },
};

const pngBuffer = await QRCode.toBuffer(payload, qrOptions);

Duas opções aí que não são gosto pessoal:

O margin: 2 não é frescura de designer. É a quiet zone — a borda branca que o leitor usa para achar onde o código começa. QR colado no fundo, principalmente impresso, vira QR que não lê. Não corte essa margem para o layout ficar bonitinho.

O errorCorrectionLevel: 'M' é o meio-termo certo aqui. Ele recupera cerca de 15% de dano — bom para papel amassado ou tela suja. Subir para 'H' (30%) parece mais seguro, mas aumenta o número de módulos: o desenho fica mais denso e, no mesmo tamanho em pixels, cada quadradinho fica menor. Em QR impresso pequeno, o 'H' pode ler pior que o 'M'. Só suba se o código vai para um ambiente hostil de verdade.

Servindo por HTTP

Com o Express, um endpoint que devolve a imagem direto:

app.get('/api/v1', async (req, res) => {
  const { nome, cidade, chave, valor, saida = 'qr' } = req.query;

  if (!chave)  return res.status(400).json({ erro: 'Parâmetro "chave" é obrigatório' });
  if (!nome)   return res.status(400).json({ erro: 'Parâmetro "nome" é obrigatório' });
  if (!cidade) return res.status(400).json({ erro: 'Parâmetro "cidade" é obrigatório' });

  try {
    const payload = generatePixPayload({ chave, nome, cidade, valor });

    if (saida === 'txt') {
      return res.type('text/plain').send(payload);
    }

    if (saida === 'json') {
      const qrDataUrl = await QRCode.toDataURL(payload, qrOptions);
      res.set('Cache-Control', 'no-store');
      return res.json({ payload, qrcode: qrDataUrl });
    }

    const pngBuffer = await QRCode.toBuffer(payload, qrOptions);
    res.set('Content-Type', 'image/png');
    res.set('Cache-Control', 'no-store');
    return res.send(pngBuffer);

  } catch (err) {
    return res.status(500).json({ erro: err.message });
  }
});

Aquele Cache-Control: no-store é o detalhe mais importante do endpoint inteiro, e o mais fácil de esquecer. 🙏

Pense no que a URL é: um endereço que muda só na query string. Se o navegador (ou um proxy, ou uma CDN no meio do caminho) resolver guardar aquele PNG, o cliente seguinte pode receber o QR Code da cobrança anterior — com o valor errado, ou pior, com a chave de outra pessoa. É um bug de cache que vira problema de dinheiro. Sem discussão: no-store.

O saida=txt, que parece supérfluo, foi o que mais me serviu no dia da caçada ao +55: ele devolve o payload cru, para você ler campo a campo em vez de escanear imagem para descobrir o que foi parar lá dentro.

E o saida=json devolve os dois juntos — o payload e a imagem em data URL base64, pronta para um <img src> sem uma segunda requisição:

$url = 'http://localhost:3001/api/v1'
     . '?nome='   . urlencode($NomeRecebedor)
     . '&cidade=' . urlencode($Cidade)
     . '&saida=qr'
     . '&chave='  . urlencode($Chave)
     . '&valor='  . urlencode($Valor);

echo '<img src="' . $url . '">';

🧪 Como testar sem depender do banco

O ciclo "gera QR, pega o celular, escaneia, abre o banco" é lento demais para depurar. Duas ferramentas mais rápidas:

Um parser TLV de 8 linhas. Foi ele que produziu aquela árvore de campos lá em cima, e é o que eu abro primeiro quando algo não bate:

function parse(s, ind = '') {
  let i = 0;
  while (i < s.length) {
    const id  = s.substr(i, 2);
    const len = parseInt(s.substr(i + 2, 2), 10);
    const val = s.substr(i + 4, len);
    console.log(`${ind}${id} (${String(len).padStart(2, '0')}) ${val}`);
    if (id === '26' || id === '62') parse(val, ind + '    '); // aninhados
    i += 4 + len;
  }
}

Se o parser chegar ao fim sem sobrar caractere solto, seus tamanhos estão certos. Se ele começar a imprimir lixo no meio, o campo anterior declarou o tamanho errado — e o ponto exato onde a bagunça começa é o campo culpado.

Um segundo CRC, escrito diferente. Foi assim que eu confirmei o 0D10 deste artigo: implementei o mesmo CRC-16/CCITT-FALSE por tabela, em vez de bit a bit. Duas implementações independentes que concordam é uma evidência bem melhor que uma implementação que "parece certa" — sobretudo depois de descobrir que a versão bit a bit dependia daquela máscara & 0xFFFF para não sair errado.

E a lição que resume todas as outras: CRC válido não significa QR aceito. Eu passei horas conferindo checksum porque o checksum era a única coisa que eu sabia verificar sozinha — e ele estava certo o tempo todo. 😅 O erro estava num lugar que nenhuma validação minha alcançava: no significado dos catorze caracteres que eu tinha posto ali dentro.

📦 O código completo

Juntei tudo num repositório: o gerador, a API em Express e uma página com formulário — você digita a chave, o nome e a cidade, clica, e vê o QR Code junto com o payload separado campo a campo. É o jeito mais rápido de brincar com o que este artigo explicou. 🎉

Exemplos_QRCodePIX no GitHubClone, npm install, npm start — e abra o localhost:3010 para gerar o seu QR Code.github.com

Experimente digitar um nome com acento e um telefone com máscara, e repare no que sai do outro lado: é a melhor forma de ver as armadilhas acontecendo. 😄

Por hoje é só, meus unicórnios! 🦄✨

Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟

Leia também