Node.js: gerando QR Code PIX do zero
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:
🧱 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. 🎉
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
Node.js: alterando o plano de energia do Windows
Como ler e trocar o plano de energia do Windows com Node.js e powercfg: por que resolver por GUID, e o acento que o TextDecoder nao decodifica.
Node.js: exportando grandes volumes do Elasticsearch
Como exportar milhões de registros do Elasticsearch para CSV com Node.js: a parede dos 10.000, a Scroll API e o scroll que fica aberto.
Node.js: gerando certificado SSL e instalando no IIS
Como emitir um certificado gratuito do Let's Encrypt com Node.js e instalar no IIS do Windows, com as armadilhas que travaram tudo.