QR Code PIX Dinâmico (EMV BR Code) em Node.js
QR Code PIX Dinâmico (EMV BR Code) em Node.js
O padrão EMV BR Code, especificado pelo Banco Central na Resolução BCB nº 1 de 2020, define dois modos de operação para QR Codes PIX: estático e dinâmico. A diferença não é cosmética — ela determina quem controla o valor cobrado, como a conciliação acontece e quais dados o app do banco efetivamente lê. Este artigo cobre a implementação completa do modo dinâmico em Node.js: do payload TLV ao CRC16-CCITT, passando pela geração da imagem e pelos testes com dados fictícios.
PIX dinâmico difere do estático em dois aspectos críticos
O que o Banco Central define como "dinâmico" (Resolução BCB nº 1, 2020)
A Resolução BCB nº 1, de 21 de janeiro de 2020, define o PIX dinâmico como a modalidade em que o payload contém uma URL para um endpoint externo (o PSP do recebedor), de onde o app do banco obtém os detalhes da cobrança em tempo real. O QR Code em si não carrega valor nem identificação completa — ele carrega um ponteiro.
Por que o payload dinâmico exige um endpoint próprio (URL no campo 26)
No modo dinâmico, o campo 26 (Merchant Account Information) contém a URL do PSP. Quando o usuário escaneia o código, o app do banco faz um GET nessa URL e recebe um JSON com o valor, o vencimento e os dados do recebedor. Isso permite cobranças com vencimento, descontos condicionais e invalidação após pagamento — funcionalidades impossíveis no modo estático.
Quando usar estático vs. dinâmico — regra prática
| Critério | Estático | Dinâmico |
|---|---|---|
| Valor fixo no QR Code | Sim | Não (busca na URL) |
| Controle de expiração | Não | Sim |
| Conciliação por TXID | Limitado | Completa |
| Requer backend próprio | Não | Sim |
| Caso de uso típico | Doações, gorjetas | E-commerce, cobranças |
Use dinâmico quando o valor varia, quando você precisa identificar exatamente qual QR Code foi pago, ou quando a cobrança expira.
A estrutura EMV BR Code campo a campo
TLV (Tag-Length-Value) e a hierarquia de IDs do BACEN
O payload é uma string ASCII onde cada campo segue o formato IDLLVALUE: dois dígitos de ID, dois dígitos de comprimento e o valor. Um campo pode conter sub-campos no mesmo formato. O BACEN define os IDs de 00 a 85; campos de 26 a 51 são reservados para informações do provedor de pagamento.
Campos obrigatórios: ID 00, 26, 52, 53, 54, 58, 59, 60, 62, 63
| ID | Nome | Exemplo |
|---|---|---|
| 00 | Payload Format Indicator | 01 |
| 26 | Merchant Account Information | URL do PSP |
| 52 | Merchant Category Code | 0000 |
| 53 | Transaction Currency | 986 (BRL) |
| 54 | Transaction Amount | 10.00 |
| 58 | Country Code | BR |
| 59 | Merchant Name | FakeForge Ltda |
| 60 | Merchant City | Sao Paulo |
| 62 | Additional Data Field | TXID |
| 63 | CRC16 | calculado por último |
Campo 26 no dinâmico — GUI e URL do PSP
O campo 26 contém obrigatoriamente o sub-campo 00 (GUI, sempre br.gov.bcb.pix) e o sub-campo 25 com a URL do seu PSP. No modo estático, o sub-campo 01 teria a chave PIX diretamente. No dinâmico, a URL deve ser HTTPS e responder ao padrão da API Open Finance do BACEN.
Campo 62 (Additional Data) — TXID e sua importância para conciliação
O sub-campo 05 do campo 62 é o TXID — identificador único da cobrança. O BACEN exige entre 26 e 35 caracteres alfanuméricos (sem espaços, sem acentos). É esse valor que aparece no extrato do banco e que você usa para confirmar o pagamento sem ambiguidade.
Campo 63 — CRC16-CCITT: como e por que é calculado por último
O campo 63 é sempre os últimos 4 caracteres hexadecimais do payload. O cálculo usa CRC16-CCITT (polinômio 0x1021, valor inicial 0xFFFF) sobre toda a string que vem antes — incluindo 6304 (o prefixo do próprio campo 63). Se qualquer caractere anterior mudar, o CRC muda. Apps de banco rejeitam payloads com CRC inválido sem mensagem de erro clara.
Configuração do projeto Node.js
Estrutura de pastas e package.json mínimo
mkdir pix-dinamico && cd pix-dinamico
npm init -y
npm install qrcode express
npm install -D typescript @types/node @types/express ts-node jest @types/jest ts-jest
npx tsc --initEstrutura:
src/
pix/
payload.ts # builder + CRC
types.ts # interfaces TypeScript
server.ts # endpoint ExpressTypeScript opcional — tipos para o payload dinâmico
// src/pix/types.ts
export interface PixDynamicPayload {
/** URL HTTPS do PSP — mantenha abaixo de 77 chars */
url: string;
/** Valor com 2 casas decimais, ex: "49.90" */
amount: string;
/** Nome do recebedor, max 25 chars, sem acentos */
merchantName: string;
/** Cidade do recebedor, max 15 chars, sem acentos */
merchantCity: string;
/** TXID: 26–35 chars alfanuméricos [A-Za-z0-9] */
txid: string;
}Implementação do builder de payload EMV BR Code
Função buildField(id, value) — serialização TLV
// src/pix/payload.ts
function buildField(id: string, value: string): string {
const len = value.length.toString().padStart(2, "0");
return `${id}${len}${value}`;
}O comprimento é sempre dois dígitos decimais. Campos com valor vazio não devem ser incluídos.
Montagem do payload e cálculo do CRC16-CCITT
function crc16ccitt(payload: string): string {
let crc = 0xffff;
for (let i = 0; i < payload.length; i++) {
crc ^= payload.charCodeAt(i) << 8;
for (let j = 0; j < 8; j++) {
crc = crc & 0x8000 ? (crc << 1) ^ 0x1021 : crc << 1;
}
}
return (crc & 0xffff).toString(16).toUpperCase().padStart(4, "0");
}
export function buildPixQrPayload(options: PixDynamicPayload): string {
if (options.txid.length < 26 || options.txid.length > 35) {
throw new Error("TXID deve ter entre 26 e 35 caracteres.");
}
const gui = buildField("00", "br.gov.bcb.pix");
const pspUrl = buildField("25", options.url);
const mAI = buildField("26", gui + pspUrl);
const addData = buildField("62", buildField("05", options.txid));
const withoutCrc = [
buildField("00", "01"),
mAI,
buildField("52", "0000"),
buildField("53", "986"),
buildField("54", options.amount),
buildField("58", "BR"),
buildField("59", options.merchantName),
buildField("60", options.merchantCity),
addData,
"6304",
].join("");
return withoutCrc + crc16ccitt(withoutCrc);
}DICA: O"6304"já deve estar na string antes do CRC ser calculado. A stringwithoutCrctermina em"6304"— o CRC é calculado sobre ela e então concatenado. Inverter essa ordem gera um CRC diferente do esperado pelo app do banco.
Geração da imagem QR Code e entrega ao cliente
Servindo o QR Code via endpoint Express
toDataURL() retorna base64 (data:image/png;base64,...), ideal para embutir em HTML ou JSON. toFile() salva em disco — use apenas para caching ou CDN estática.
// src/server.ts
import express from "express";
import QRCode from "qrcode";
import { buildPixQrPayload } from "./pix/payload";
import type { PixDynamicPayload } from "./pix/types";
const app = express();
app.get("/pix/qrcode", async (req, res) => {
const options: PixDynamicPayload = {
url: "https://psp.exemplo.com.br/cobv/abc123",
amount: "49.90",
merchantName: "FakeForge Ltda",
merchantCity: "Sao Paulo",
txid: "FAKEFORGE20240520ABC123DEF",
};
const payload = buildPixQrPayload(options);
const dataUrl = await QRCode.toDataURL(payload, { errorCorrectionLevel: "M" });
res.json({ payload, qrcode: dataUrl });
});
app.listen(3000);Para retornar PNG direto, use QRCode.toBuffer() com Content-Type: image/png. Para forçar download, adicione Content-Disposition: attachment; filename="pix.png".
Dados de teste com FakeForge BR — sem CPF ou CNPJ real
Por que nunca usar documentos reais em ambiente de dev (LGPD Art. 46)
A LGPD Art. 46 exige medidas técnicas e administrativas para proteger dados pessoais contra acesso não autorizado. Usar CPF ou CNPJ reais em seeds, logs ou fixtures de teste viola esse princípio — e cria passivo jurídico mesmo sem vazamento confirmado.
Gerando chave PIX fictícia e CNPJ para testes
O gerador de PIX do FakeForge produz chaves nos quatro formatos suportados pelo BACEN (CPF, CNPJ, e-mail, telefone e EVP). Use o gerador de CNPJ para o campo merchantName e confirme com o validador de CNPJ antes de subir para staging — o algoritmo mod-11 é exigente e um dígito errado causa rejeição silenciosa em alguns PSPs.
Usando a API REST do FakeForge em scripts de seed
// seed.ts — busca dados fictícios antes de rodar os testes
async function getFakePixKey(): Promise<string> {
const res = await fetch(
"https://fakeforge.com.br/api/generate?type=pix&quantity=1&format=json"
);
const [item] = await res.json();
return item.key;
}
async function getFakeMerchant(): Promise<{ cnpj: string; name: string }> {
const res = await fetch(
"https://fakeforge.com.br/api/generate?type=empresa&quantity=1&format=json"
);
const [item] = await res.json();
return { cnpj: item.cnpj, name: item.razaoSocial };
}Para times com CI/CD intenso, os planos da API (Dev R$29/mês ou Team R$79/mês) removem o limite de 100 chamadas/dia. Consulte a documentação para todos os tipos disponíveis.
Teste do payload — verificação sem depender do app do banco
Decodificador TLV para debug local
// tlv-decoder.ts
export function decodeTlv(payload: string): Record<string, string> {
const result: Record<string, string> = {};
let i = 0;
while (i < payload.length - 4) {
const id = payload.slice(i, i + 2);
const len = parseInt(payload.slice(i + 2, i + 4), 10);
result[id] = payload.slice(i + 4, i + 4 + len);
i += 4 + len;
}
return result;
}Teste unitário com Jest — validar CRC e campos obrigatórios
// pix.test.ts
import { buildPixQrPayload } from "./src/pix/payload";
const opts = {
url: "https://psp.exemplo.com.br/cobv/test001",
amount: "1.00",
merchantName: "Teste Ltda",
merchantCity: "Curitiba",
txid: "TESTETXIDVALIDO26CHARS0001",
};
test("payload termina com CRC de 4 chars hex maiúsculos", () => {
const payload = buildPixQrPayload(opts);
expect(payload.slice(-4)).toMatch(/^[0-9A-F]{4}$/);
});
test("payload contém campos 26 e 62", () => {
const payload = buildPixQrPayload(opts);
expect(payload).toContain("26");
expect(payload).toContain("6205");
});
test("TXID com 25 chars lança erro", () => {
expect(() =>
buildPixQrPayload({ ...opts, txid: "CURTODEMAIS1234567890ABCDE" })
).toThrow("TXID deve ter entre 26 e 35 caracteres.");
});Para teste de integração, simule a resposta do PSP no campo 26 com nock ou msw — intercepte o GET na URL do campo 26 e retorne o JSON de cobrança esperado pelo padrão BACEN.
Erros comuns e como depurá-los
CRC incorreto: verifique se withoutCrc termina em "6304" antes do cálculo. Acentos em merchantName corrompem o CRC — normalize com .normalize("NFD").replace(/[\u0300-\u036f]/g, ""). Use & 0xffff em cada iteração para manter 16 bits.
Campo 54 com formato errado: o BACEN exige ponto decimal e exatamente duas casas. "49,90" e "49.9" são inválidos. Use sempre amount.toFixed(2).
URL no campo 26 com mais de 99 caracteres: o comprimento TLV é limitado a dois dígitos decimais (máximo 99). Uma URL longa demais gera comprimento de três dígitos, corrompendo o payload inteiro. Mantenha a URL abaixo de 77 caracteres para que o campo 26 completo (GUI + URL) caiba nos 99 bytes permitidos.
TXID inválido: o BACEN aceita apenas [A-Za-z0-9], 26–35 caracteres. UUID sem hífens tem 32 caracteres e atende a regra.
QR Code lido como estático: verifique se o sub-campo 25 do campo 26 contém uma URL HTTPS (não uma chave PIX), e se o campo 01 (initiation method) está ausente — ele é exclusivo do modo estático.
Segurança e boas práticas em produção
HTTPS obrigatório: a Resolução BCB nº 1, 2020 exige HTTPS na URL do campo 26. Apps de banco recusam cobranças dinâmicas com URL HTTP sem exceção.
Expiração e invalidação de TXID: armazene o TXID com status (pendente, pago, expirado). Quando o PSP confirmar via webhook, marque como pago e retorne HTTP 422 para leituras subsequentes. Isso previne double spending em falhas de rede.
Rate limiting: aplique rate limiting por IP no endpoint de geração. Sem isso, um atacante enumera TXIDs e mapeia cobranças em aberto. Use express-rate-limit com janela de 1 minuto e máximo de 20 requisições por IP.
Logs sem dados pessoais: a LGPD Art. 7º, IX exige base legal para tratar dados pessoais. Logs de aplicação raramente têm controle de acesso adequado — não inclua CPF, CNPJ ou valor de cobrança. Use o TXID opaco para rastreamento.
AVISO: Em produção, nunca retorne o payload PIX completo em logs de erro. O TXID exposto permite que atacantes consultem o status da cobrança diretamente no PSP sem autenticação.
Resumo
- O modo dinâmico exige URL HTTPS no sub-campo 25 do campo 26; o payload não carrega o valor diretamente — o app do banco busca os detalhes no seu backend.
- O CRC16-CCITT é calculado sobre a string completa incluindo o prefixo
6304; erros de encoding (acentos) e truncamento de 16 bits são as causas mais comuns de falha silenciosa. - TXID deve ter 26–35 caracteres
[A-Za-z0-9]; UUID sem hífens (32 chars) atende a regra e simplifica a conciliação por webhook. - Use o gerador de PIX e o gerador de CNPJ do FakeForge para seeds e fixtures, e valide com o validador de CNPJ antes de usar em staging — nunca use documentos reais em dev (LGPD Art. 46).
- O próximo passo natural é implementar o webhook de confirmação do PSP e usar o TXID para reconciliar pagamentos sem polling no banco de dados da cobrança.
- Referências complementares: spec EMV BR Code v2.1 (site do BACEN), sandbox de homologação do BACEN, e a documentação da API FakeForge para automação de geração de dados em CI/CD.
Perguntas frequentes
Posso reutilizar o mesmo TXID para cobranças diferentes?+
Não. O TXID é identificador único da cobrança — reutilizar permite que um atacante copie um QR pago e clique novamente. Após confirmação do PSP (webhook), marque como `pago` e rejeite re-escanagens com HTTP 422. Para nova cobrança, gere novo TXID. Use UUID sem hífens (32 chars, valida a regra 26–35 chars) ou prefixo temporal para rastreabilidade.
QRCode.toDataURL() retorna base64 e QRCode.toBuffer() retorna PNG binário — qual usar?+
Use `toDataURL()` para API JSON (embute no response com `{ qrcode: dataUrl }` — cliente desenha uma tag `<img>`). Use `toBuffer()` apenas se servir PNG direto (`Content-Type: image/png`) — útil para download ou CDN estática. DataURL é mais simples, mais lento em conexões lentas; Buffer economiza banda em alta escala.
Como validar o payload antes de gerar o QR sem decodificar manualmente?+
Escreva um decodificador TLV que lê o payload e retorna um objeto { '00': '01', '26': '...', ... }. Valide programaticamente: campo 26 contém URL HTTPS? Campo 54 tem format correto (2 decimais)? TXID no campo 62 tem 26–35 chars `[A-Za-z0-9]`? Use Jest com casos edge: TXID=25chars deve falhar, URL com 100 chars deve falhar.
Se um cliente gerar o QR, depois mudar de ideia e quiser um valor diferente, preciso gerar novo QR?+
Sim. Cada TXID corresponde a uma cobrança imutável no PSP — você não pode renegociar valor no mesmo TXID. Gere novo TXID, novo payload, novo QR. No banco de dados, marque o TXID antigo como `cancelado` ou `expirado` para auditoria. Considere TTL automático (ex: 30 minutos) antes de exigir novo QR.
O que fazer se meu endpoint do PSP (campo 26) fica indisponível e o usuário escaneia o QR?+
O app do banco tenta GET na URL e falha. Ele retorna erro `[9999] Erro ao processar sua requisição`. De lado do cliente: implemente circuit breaker no seu PSP (fail-fast, cacheia última resposta válida 5–10s). De lado do servidor: use webhook redundante com Slack/PagerDuty. Estude SLA do seu PSP antes de lançar cobranças em produção.