DEV Community

Cover image for Pix sem gateway: BR Code, CRC16 e as 3 armadilhas que os validadores online não pegam
Victor Augusto
Victor Augusto

Posted on

Pix sem gateway: BR Code, CRC16 e as 3 armadilhas que os validadores online não pegam

Montei um gerador de "Pix copia e cola" seguindo o Manual do BR Code do Banco Central e alguns repositórios de referência. Joguei o payload em três validadores online: todos aprovaram. CRC batendo, campos no lugar, estrutura TLV correta.

Aí abri o app do banco pra ler o QR de verdade: "QR Code inválido".

Este post é sobre as duas coisas que saíram daí:

  1. O formato do Pix copia e cola é público e cabe em ~120 linhas de C#, sem gateway e sem biblioteca (só uma de QR pra virar imagem).
  2. As três coisas que passam em todo validador genérico e mesmo assim o banco recusa: a caixa da GUI br.gov.bcb.pix, o formato da chave no DICT e o campo 01. A próxima seção resolve cada uma; o resto do post é como cheguei lá.

E fica a suspeita que me trouxe até aqui: se o meu passou em três validadores e mesmo assim quebrou num banco de verdade, quantos QR Pix "quase válidos" estão circulando por aí agora?

As três armadilhas, direto ao ponto

Se você caiu aqui procurando "QR Code Pix inválido" e tem um payload que parece certo, é quase certeza que é uma destas três. O detalhamento, com código e fontes, está mais abaixo; isto aqui já desbloqueia:

  1. A caixa da GUI. O identificador do arranjo, br.gov.bcb.pix, aparece em minúsculo nos manuais do Banco Central, mas o app do meu banco só aceitava em maiúsculo, BR.GOV.BCB.PIX. O checksum fecha dos dois jeitos, então nenhum validador acusa. Regra prática: gere um copia e cola pelo app do próprio banco de quem vai receber e use exatamente a caixa que ele usa.
  2. O formato da chave. O banco de quem paga busca a chave no DICT (o diretório de chaves do BC). Se ela não estiver no formato exato (telefone como +5531999998888, e-mail em minúsculo, CPF só com os 11 dígitos), ele não encontra e recusa, mesmo com o BR Code impecável no resto.
  3. O campo 01 (Point of Initiation Method). Num QR estático, declarar 12 (o valor de QR dinâmico) quebra; 11 funciona e omitir também. Nunca mande 12 num QR reutilizável.

O resto do post: o que é o BR Code, como montar o payload do zero e como testar sem ficar abrindo o app do banco a cada mudança.

O contexto

Eu estava construindo um bot de Telegram que divide as contas de um grupo (viagem, república, happy hour) e no fim calcula quem transfere quanto pra quem. Faltava a última peça: gerar o Pix de cada transferência ali no chat. Integrar um gateway (Mercado Pago, Asaas, Toku, Gerencianet) pede conta, credencial, OAuth, webhook público e normalmente uma taxa por transação. É desproporcional pra gerar um QR estático de "me paga R$ 42,50". E o "Pix copia e cola" que o app do banco me dá pra receber é só uma string: 00020126...6304XXXX. Se é só texto, dá pra gerar.

("Pix copia e cola", BR Code e QR Code Pix são o mesmo objeto: um texto no formato EMV® MPM (Merchant-Presented Mode) do EMVCo, que o Banco Central adaptou. A diferença é só se você mostra a string ou a imagem; a imagem é a mesma string codificada num QR.)

O código completo está em RachaContaBot (RachaContas.Bot/Services/PixBrCodeGenerator.cs). É C# / .NET 10, mas o formato não tem nada de específico de linguagem.

(Testei os payloads gerados nos apps de Banco do Brasil, Bradesco, Itaú, Nubank e Inter em 19 de junho de 2026, e os cinco leram e liquidaram. Comportamento de PSP muda sem aviso; se você está lendo isto bem depois e algo não bater, o método de teste no fim do post é o que vale.)

O que é um "Pix copia e cola"

Em uma frase: é uma string no padrão BR Code (a adaptação brasileira do EMV® MPM, Merchant-Presented Mode: o comerciante apresenta o código, o cliente escaneia) que carrega, em texto, todos os dados de uma cobrança: chave, valor, nome e cidade de quem recebe, um identificador e um checksum. Nada aí é assinado ou secreto. É o mesmo padrão do QR de maquininha lá fora.

Duas documentações do Banco Central cobrem isso, e vale ter as duas abertas na hora de implementar:

  • Manual do BR Code: a adaptação brasileira do padrão EMVCo. Define a estrutura TLV, a lista de campos e o cálculo do CRC16.
  • Manual de Padrões para Iniciação do Pix (testei contra a versão 2.9.0): o perfil específico do Pix em cima do BR Code. É aqui que estão a identificação br.gov.bcb.pix, os tipos de QR (estático, dinâmico, composto), o formato das chaves e a convenção do txid.

A estrutura é um TLV (Type-Length-Value). Cada campo é:

ID (2 dígitos) + TAMANHO (2 dígitos) + VALOR
Enter fullscreen mode Exit fullscreen mode

Concatenados, sem separador. Então 5802BR quer dizer: campo 58 (país), tamanho 02, valor BR. Simples assim, e recursivo: alguns campos têm subcampos no mesmo formato dentro do valor.

Um Pix copia e cola estático decodificado fica assim:

Anatomia de um Pix copia e cola estático: cada campo do BR Code com seu ID, tamanho e valor, e os campos aninhados 26 (identidade Pix) e 62 (dados adicionais)

Em texto puro, sem os rótulos, é isso aqui concatenado:

00  02  01                              Payload Format Indicator (sempre "01")
26  58  Merchant Account Info (Pix)
        00  14  BR.GOV.BCB.PIX          identificador do arranjo Pix
        01  36  1ade9854-82d1-04cd...   a chave Pix (aqui uma aleatória / EVP)
52  04  0000                            Merchant Category Code (0000 = não informado)
53  03  986                             moeda (986 = BRL, ISO 4217)
54  05  45.00                           valor da transação
58  02  BR                              país
59  25  Maria Ferreira Silva Dias       nome de quem recebe
60  09  SAO PAULO                       cidade de quem recebe
62  14  Additional Data
        05  10  9kQSS8TQqj              txid (identificador da transação)
63  04  198F                            CRC16 do payload inteiro
Enter fullscreen mode Exit fullscreen mode

É isso. Nenhum campo aí é secreto ou precisa ser assinado por ninguém. Vale fixar dois papéis que voltam o tempo todo daqui pra frente: o PSP recebedor é a instituição de quem gera o QR (a que emite a chave); o PSP pagador é o app de quem vai pagar. A "autenticação" do Pix acontece no PSP pagador: é ele que lê a string, resolve a chave no DICT e mostra o nome do recebedor pra confirmação. O gerador só monta texto; quem aceita ou recusa é o PSP pagador.

O essencial: o payload em ~60 segundos

Esta é a mecânica de montagem, enxuta. Se você já tem um payload e só quer saber por que ele é recusado, pule pra "Válido" não é "aceito". Se quer o passeio campo a campo, o PixBrCodeGenerator.cs e o Manual do BR Code cobrem cada byte.

O coração do gerador são duas funções: uma Tlv, que formata um campo, e uma GerarPayload, que concatena os campos na ordem e fecha com o CRC.

private static string Tlv(string id, string valor)
{
    if (valor.Length > 99)
        throw new ArgumentException($"Campo {id}: valor com {valor.Length} chars estoura o tamanho de 2 dígitos do EMV.");

    return $"{id}{valor.Length:D2}{valor}";
}
Enter fullscreen mode Exit fullscreen mode
public static string GerarPayload(string chavePix, string nomeRecebedor, decimal valor, string? txId, string cidade = "SAO PAULO")
{
    var nome   = Sanitizar(nomeRecebedor, 25);
    var cidadeSanitizada = Sanitizar(cidade, 15);
    var referencia = SanitizarTxId(txId);

    // Campo 26 é aninhado: identificador do arranjo + a chave Pix
    var merchantAccountInfo = Tlv("00", "BR.GOV.BCB.PIX") + Tlv("01", chavePix);
    var additionalData      = Tlv("05", referencia);

    var semCrc =
        Tlv("00", "01") +                                              // Payload Format Indicator
        Tlv("26", merchantAccountInfo) +                               // Merchant Account Info (Pix)
        Tlv("52", "0000") +                                            // Merchant Category Code
        Tlv("53", "986") +                                             // moeda (BRL)
        Tlv("54", valor.ToString("F2", CultureInfo.InvariantCulture)) +// valor
        Tlv("58", "BR") +                                              // país
        Tlv("59", nome) +                                              // nome do recebedor
        Tlv("60", cidadeSanitizada) +                                  // cidade do recebedor
        Tlv("62", additionalData) +                                    // Additional Data (txid)
        "6304";                                                        // id + tamanho do CRC; o valor vem já já

    return semCrc + CalcularCrc16(semCrc);
}
Enter fullscreen mode Exit fullscreen mode

Esse é o maior bloco de código do post; daqui pra frente são funções de 3 a 10 linhas.

Cinco detalhes que quebram o payload sem gerar erro nenhum:

  • 54 (valor): ponto decimal e sempre 2 casas (45.00, nunca 45,00). Daí o CultureInfo.InvariantCulture: numa máquina em pt-BR, ToString("F2") sozinho devolve vírgula e o payload quebra.
  • 53 (moeda): 986, o código ISO 4217 numérico do real. Não é BRL.
  • 52 (MCC): 0000 serve quando você não é um estabelecimento categorizado.
  • 26 (o bloco Pix): o guard de 99 chars na Tlv é o limite genérico do EMV; o perfil Pix aperta o campo 26 inteiro pra 77 caracteres. GUI (14) + chave EVP (36) + overhead cabe folgado; uma chave de e-mail comprida pode estourar, então vale um teste pra esse caso.
  • "6304" no fim: campo 63 (CRC) + tamanho 04; o valor de 4 hex é calculado a seguir, incluindo esses "6304" no cálculo.

O CRC exigido pelo Manual do BR Code é o CRC-16/CCITT-FALSE: polinômio 0x1021, valor inicial 0xFFFF, sem reflexão de bits, sem XOR final, sobre o payload inteiro. É um loop bit a bit. Se a matemática não te interessa, copie a função, confirme que ela passa no vetor de teste logo abaixo e pule pra "Válido" não é "aceito":

public static string CalcularCrc16(string payload)
{
    ushort crc = 0xFFFF;
    foreach (var b in Encoding.ASCII.GetBytes(payload))
    {
        crc ^= (ushort)(b << 8);
        for (var i = 0; i < 8; i++)
            crc = (crc & 0x8000) != 0
                ? (ushort)((crc << 1) ^ 0x1021)
                : (ushort)(crc << 1);
    }
    return crc.ToString("X4"); // 4 dígitos hex, MAIÚSCULO
}
Enter fullscreen mode Exit fullscreen mode

Ele tem um vetor de teste canônico: 123456789 tem que dar 0x29B1. Se o seu bate com isso, o algoritmo está certo, seja qual for o resto do payload:

[Fact]
public void Crc16_VetorPadrao()
    => Assert.Equal("29B1", PixBrCodeGenerator.CalcularCrc16("123456789"));
Enter fullscreen mode Exit fullscreen mode

"Válido" não é "aceito": as armadilhas

O resumo das três está lá em cima. Aqui é o porquê de cada uma, com o código e as fontes. É onde eu perdi tempo, e é o motivo real deste post existir.

O que destravou foi parar de validar contra ferramentas genéricas e comparar byte a byte com um copia e cola real gerado pelo meu próprio banco. Três diferenças apareceram.

À esquerda, o app do banco recusando o QR com a mensagem Este QR Code é inválido. À direita, a tela de confirmação de pagamento de R$ 5,00 depois de ajustar a GUI e o formato da chave

Armadilha 1: a caixa da GUI do arranjo importa na prática

O campo 26, subcampo 00, é a string que identifica o arranjo de pagamento: br.gov.bcb.pix. O Manual do BR Code e o Manual de Padrões para Iniciação do Pix escrevem essa GUI em minúsculo nos exemplos, e há implementações que validam esperando exatamente minúsculo (veja a issue #189 do bacen/pix-api). O CRC não liga pra caixa: br.gov.bcb.pix e BR.GOV.BCB.PIX têm o mesmo tamanho, então o payload continua "estruturalmente válido" e o checksum fecha nos dois casos.

Só que o copia e cola que o meu banco gerava usava BR.GOV.BCB.PIX em maiúsculo, e o leitor de QR dele parecia comparar essa string de forma sensível a caixa pra reconhecer que aquilo era um Pix. Trocar pra maiúsculo foi o que fez o "QR Code inválido" virar a tela de confirmação de pagamento.

Não tenho como afirmar qual caixa é "a certa" pra todo PSP: a spec exibe minúsculo, o leitor do meu banco exigiu maiúsculo. Depois de trocar, testei o mesmo payload nos apps de Banco do Brasil, Bradesco, Itaú, Nubank e Inter: os cinco leram e pagaram. A regra que funcionou pra mim: gere um copia e cola pelo app do próprio banco do recebedor e espelhe exatamente a caixa que ele usa na GUI.

O PixBrCodeGenerator fixa BR.GOV.BCB.PIX em maiúsculo, porque foi o que funcionou aqui. Mas o teste que vale não é "tem que ser maiúsculo": é "tem que bater com o que o banco do recebedor gera". Então eu extraio a GUI de um copia e cola real do banco alvo e comparo com isso:

[Fact]
public void Gui_BateComOCopiaEColaDeReferencia()
{
    // subcampo 00 do campo 26, extraído de um "copia e cola" gerado pelo app do banco alvo
    // (id "00" + tamanho "14" + valor):
    const string guiReferencia = "0014BR.GOV.BCB.PIX";

    var p = PixBrCodeGenerator.GerarPayload("chave@exemplo.com", "Fulano", 10m, "x1");

    Assert.Contains(guiReferencia, p);
}
Enter fullscreen mode Exit fullscreen mode

Se o banco do seu recebedor gerar a GUI em minúsculo, troque a guiReferencia e o teste continua correto: ele valida consistência com a fonte real, não uma caixa fixa.

Armadilha 2: a chave precisa estar no formato exato do DICT

O subcampo 01 do campo 26 é a chave Pix. Como o PSP pagador é quem resolve a chave (ver a distinção lá em cima), é ele quem vai pegar essa string e procurar no DICT, o diretório de chaves do Banco Central. Se o formato não for exatamente o que o DICT guarda, ele não acha a chave e recusa, mesmo com o BR Code perfeito no resto.

Os formatos, que estão na seção de chaves do Manual de Padrões para Iniciação do Pix:

Tipo Formato exigido Exemplo
Telefone +55 seguido de DDD e número, sem espaço, parênteses ou traço +5531999998888
CPF só os 11 dígitos, sem pontuação 12345678901
CNPJ só os 14 dígitos, sem pontuação 12345678000199
E-mail minúsculo fulano@exemplo.com
Aleatória (EVP) o UUID como veio, minúsculo 1ade9854-82d1-...

Por isso o bot tem um ChavePixParser que normaliza o que a pessoa digita antes de salvar:

// "telefone (31) 99999-8888"  ->  "+5531999998888"
private static string? NormalizarTelefone(string valor)
{
    var digitos = new string(valor.Where(char.IsDigit).ToArray());
    return digitos.Length switch
    {
        12 or 13 when digitos.StartsWith("55") => "+" + digitos,
        10 or 11                               => "+55" + digitos,
        _                                      => null,
    };
}
Enter fullscreen mode Exit fullscreen mode

Armadilha 3: campo 01 (Point of Initiation Method), omita

O EMV tem um campo 01 no nível raiz (não confundir com o 01 de dentro do 26) chamado Point of Initiation Method: 11 = QR estático (reutilizável), 12 = dinâmico (uso único).

O Manual do BR Code trata esse campo como condicional, e pro QR estático vários geradores de referência simplesmente não colocam. Declarar 12 num QR que é estático é pedir problema. Declarar 11 explícito também é aceito: é o valor certo pra estático, só redundante. Eu omito e nunca deu ruim; se preferir ser explícito, use 11:

// Point of Initiation Method (01) fica de fora de propósito: condicional pra
// estático (11 explícito também vale), e os geradores de referência que eu
// comparei também omitem.
Enter fullscreen mode Exit fullscreen mode

Bônus: nome, cidade e txid não são texto livre

  • Nome (59) e cidade (60): ASCII imprimível, sem acento, com limite de tamanho (25 e 15). "João Cão" vira "JOAO CAO". Passar um acento aqui é outro jeito de gerar um payload que "quase" funciona.
  • txid (subcampo 05 do campo 62): só alfanumérico, até 25 chars. Quando você não tem um identificador específico pra transação, a convenção do Manual de Padrões é mandar ***.
private static string Sanitizar(string texto, int max)
{
    var d = texto.Normalize(NormalizationForm.FormD);
    var sb = new StringBuilder();
    foreach (var c in d)
        if (CharUnicodeInfo.GetUnicodeCategory(c) != UnicodeCategory.NonSpacingMark && c < 128)
            sb.Append(c);
    var r = sb.ToString().ToUpperInvariant().Trim();
    return r.Length > max ? r[..max] : r;
}
Enter fullscreen mode Exit fullscreen mode

Virando imagem

O payload é o "copia e cola". Pra virar QR Code é uma lib de imagem. Aqui, QRCoder, 100% local:

public static byte[] GerarPng(string payload)
{
    using var gen = new QRCodeGenerator();
    using var data = gen.CreateQrCode(payload, QRCodeGenerator.ECCLevel.M);
    return new PngByteQRCode(data).GetGraphic(20); // 20 px por módulo
}
Enter fullscreen mode Exit fullscreen mode

Nível de correção de erro M (~15%) já é suficiente pra Pix e é o que costuma ser recomendado. Não precisa de Q ou H, que só incham o QR.

No contexto do bot, essas duas peças (PixBrCodeGenerator e PixQrCodeImageService) são as duas últimas etapas de um fluxo que roda inteiro na sua máquina:

Fluxo do RachaContas: /fechar calcula as transferências, /pagar cria as cobranças, PixBrCodeGenerator monta o TLV e o CRC16, PixQrCodeImageService renderiza o PNG e o Telegram entrega o QR e o copia e cola, tudo local

Como testar sem ficar abrindo o app do banco

A estratégia que funcionou pra mim tem três camadas:

  1. Vetor canônico do CRC: 123456789 vira 29B1. Valida o algoritmo isolado.
  2. BR Codes reais conhecidamente válidos: existem listas públicas de payloads Pix usadas justamente pra testar validadores. Recalcular o CRC de um deles e conferir com o valor publicado valida a sua implementação contra um caso real.
  3. Um copia e cola gerado pelo seu banco: cola num teste, recalcula o CRC, confere. Esse é o que pega as armadilhas de caixa e formato, porque é a "verdade" que o leitor real aceita.
[Fact]
public void Crc_DeUmCopiaECola_Real()
{
    // 1. gere um "copia e cola" no app do seu banco
    // 2. cole aqui SEM os 4 últimos caracteres (que são o CRC)
    // 3. ponha esses 4 caracteres em crcDoBanco
    const string semCrc    = "<< seu copia e cola, menos os 4 últimos chars >>";
    const string crcDoBanco = "XXXX";

    Assert.Equal(crcDoBanco, PixBrCodeGenerator.CalcularCrc16(semCrc));
}
Enter fullscreen mode Exit fullscreen mode

E, no fim, o teste que não dá pra automatizar: gerar um payload de R$ 0,01 pra você mesmo e pagar. Uma vez. Vale o centavo.

Quando isso não é suficiente

Antes: as duas formas de QR Pix, lado a lado.

QR estático QR dinâmico
Campo 01 (Point of Initiation Method) 11 ou omitido 12
Onde o valor mora no próprio payload (campo 54) numa cobrança criada via API do PSP
O que o campo 26 carrega a chave Pix uma URL (location) que aponta pra cobrança
Precisa de PSP / credencial não sim
Confirmação de pagamento nenhuma (você não sabe se foi pago) notificação/webhook quando a cobrança liquida
txid opcional (convenção: ***) obrigatório e único por cobrança
Serve pra "me paga R$ X" fixo, P2P, portfólio e-commerce, SaaS, cobrança recorrente

Este post é só sobre o estático, o que dá pra montar sozinho.

Pix estático resolve "gerar uma cobrança". Ele não resolve "saber que foi paga". Não tem webhook, não tem conciliação: o dinheiro cai na conta e ninguém te avisa programaticamente.

No bot eu contornei com um botão "Já paguei" que marca a cobrança manualmente. Pra um grupo de amigos, tudo bem: é confiança mútua e o valor é baixo.

Se você precisa de baixa automática (um SaaS cobrando mensalidade, um e-commerce), aí a história muda:

  • Pix dinâmico via API do seu PSP: em vez da chave, o campo 26 carrega uma URL (location) que aponta pra uma cobrança que você criou na API do PSP. Como essa cobrança é um recurso com txid próprio e status no PSP, ele consegue te mandar um webhook no instante em que ela é liquidada. É isso que fecha a conciliação, e é justamente o que o estático não tem.
  • Ou um gateway que abstrai isso.

Mas pra gerar um QR estático de "me paga X", o formato é público, cabe num arquivo, e você não precisa de mais nada além da chave de quem recebe.

Perguntas frequentes

Preciso de um gateway pra gerar um Pix?
Não, pra um QR estático (valor fixo, "me paga X"). O formato é público e você monta o payload com a chave de quem recebe. Gateway ou Pix dinâmico via PSP só é necessário quando você precisa saber, programaticamente, que a cobrança foi paga.

A GUI br.gov.bcb.pix é maiúscula ou minúscula?
Os manuais do Banco Central exibem em minúsculo. Alguns leitores de QR reais comparam de forma sensível a caixa; o meu exigiu maiúsculo. A aposta segura é espelhar a caixa que o copia e cola do próprio banco do recebedor usa.

Como calcular o CRC16 do Pix?
CRC-16/CCITT-FALSE: polinômio 0x1021, valor inicial 0xFFFF, sem reflexão de bits, sem XOR final, sobre o payload inteiro incluindo os 6304 finais. Vetor de teste: 123456789 vira 29B1.

Por que meu Pix copia e cola é "válido" mas o banco recusa?
As três causas mais comuns: a caixa da GUI br.gov.bcb.pix, a chave fora do formato exato do DICT (o banco de quem paga não a encontra) e o campo 01 (Point of Initiation Method) declarado errado num QR estático.

Qual a diferença entre Pix copia e cola estático e dinâmico?
O estático carrega a chave e (opcionalmente) o valor no próprio texto, não precisa de PSP e não avisa quando é pago. O dinâmico carrega uma URL (location) pra uma cobrança criada via API do PSP, exige credencial e devolve notificação na liquidação. Comparação campo a campo na seção "Quando isso não é suficiente".

Qual o formato da chave de telefone no payload?
+55 seguido de DDD e número, sem espaço, parênteses ou traço: +5531999998888.

Qual valor usar no txid quando não tenho um identificador?
***, pela convenção do Manual de Padrões para Iniciação do Pix.

Referências


A lição que fica: "válido" e "aceito" são coisas diferentes quando tem um banco de verdade lendo. O CRC e a estrutura TLV você fecha com o manual. A caixa da GUI, o formato da chave no DICT e o campo 01 só aparecem quando você compara byte a byte com um copia e cola gerado pelo app do banco do recebedor. Esse é o teste que importa, e é grátis.

Você já viu um Pix "válido" ser recusado? Qual banco? Comenta aí; quero mapear se a caixa da GUI foi o meu banco sendo específico ou um padrão.

Código completo, com os testes: github.com/victoraugustovalle/RachaContaBot, arquivo RachaContas.Bot/Services/PixBrCodeGenerator.cs.

Esse projeto faz parte do meu portfólio. Se quiser ver os outros (autenticação feita a sério, tempo real com SignalR, integrações de pagamento), está tudo em victoraugusto.dev. Comentário e crítica são bem-vindos.

Testado contra Banco do Brasil, Bradesco, Itaú, Nubank e Inter em 19 de junho de 2026. Regras de PSP mudam sem aviso. Se você está lendo isto muito depois, revalide com o método da seção "Como testar".

Top comments (0)