← Voltar ao blog

QR Code PIX Dinâmico (EMV BR Code) em Node.js

·14 min de leitura

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érioEstáticoDinâmico
Valor fixo no QR CodeSimNão (busca na URL)
Controle de expiraçãoNãoSim
Conciliação por TXIDLimitadoCompleta
Requer backend próprioNãoSim
Caso de uso típicoDoações, gorjetasE-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

IDNomeExemplo
00Payload Format Indicator01
26Merchant Account InformationURL do PSP
52Merchant Category Code0000
53Transaction Currency986 (BRL)
54Transaction Amount10.00
58Country CodeBR
59Merchant NameFakeForge Ltda
60Merchant CitySao Paulo
62Additional Data FieldTXID
63CRC16calculado 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 --init

Estrutura:

src/
  pix/
    payload.ts   # builder + CRC
    types.ts     # interfaces TypeScript
  server.ts      # endpoint Express

TypeScript 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 string withoutCrc termina 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.