Copiei o XML igualzinho e mesmo assim deu erro”: o pesadelo das integrações SOAP/governamentais

Toda integração com órgão público tem um momento que é quase ritual de passagem. Você pega o exemplo da documentação, monta seu XML, confere campo por campo, bate o olho e jura: “está idêntico”. Aí você envia. E o webservice cospe um erro genérico, daqueles que não explicam nada — Erro ao processar, Schema inválido, ou o clássico HTTP 500 sem corpo. Você confere de novo. Continua idêntico. E perde dias nisso.

Recentemente acompanhei um caso assim numa integração de nota fiscal de serviço com uma prefeitura (vou manter tudo anonimizado, porque o valor está no problema técnico, não em quem passou pelo perrengue). O dev tinha replicado o XML exatamente como no manual. Visualmente, idêntico. E nada funcionava. Depois de dias, o culpado apareceu: o cabeçalho SOAP estava sendo montado errado e, de brinde, havia quebras de linha invisíveis (\r\n) sujando o envelope.

Esse artigo é sobre por que “está igualzinho” é a frase mais perigosa de uma integração — e sobre o kit de sobrevivência pra não cair nessa.

O contexto: por que SOAP/XML governamental é traiçoeiro

REST com JSON perdoa muita coisa. Um espaço a mais, uma ordem diferente de chave, um campo extra — geralmente passa. SOAP com XML validado por XSD não perdoa nada. A ordem dos elementos importa. O namespace importa. O SOAPAction no header importa. O Content-Type importa. E o webservice do outro lado, em geral, foi escrito há muitos anos, roda numa stack que você não controla, e devolve mensagens de erro que parecem charada.

O detalhe cruel é que a diferença que quebra tudo costuma ser invisível no editor. Você abre o XML no VS Code, está bonito, indentado, “igual”. Mas o que sai pela rede é uma sequência de bytes — e é nela que mora o problema.

O problema real: o que estava acontecendo

Reconstruindo o caso, eram dois problemas somados.

1. O cabeçalho SOAP errado. Muitos webservices de prefeitura exigem o SOAPAction específico e um Content-Type exato (text/xml; charset=utf-8 em SOAP 1.1, ou application/soap+xml em SOAP 1.2). Mandar o header errado faz o servidor recusar antes mesmo de olhar o XML. O erro que volta? Geralmente algo que não tem nada a ver com header, te jogando na pista errada.

2. As quebras de linha no envelope. O XML tinha sido montado por concatenação de string, com \r\n no meio — às vezes herdado de um copia-e-cola, às vezes de um heredoc mal formatado. Alguns parsers de XSD reclamam de whitespace inesperado entre elementos, especialmente em campos que esperam conteúdo específico ou em assinaturas digitais (onde qualquer byte a mais invalida o digest).

O ponto comum entre os dois: você não consegue ver nenhum dos dois “lendo” o XML. Precisa olhar o byte que sai.

A solução: pare de confiar no que você acha que está enviando

A primeira regra de integração externa é: logue o payload bruto que realmente sai da sua aplicação. Não o array antes de serializar. Não a variável que você acha que virou o XML. O bytes-on-the-wire.

Se você usa a extensão SOAP do PHP, ela te dá isso de graça com trace:

$client = new SoapClient($wsdl, [
    'trace'      => 1,            // habilita __getLastRequest()
    'exceptions' => true,
    'soap_version' => SOAP_1_1,
]);

try {
    $resposta = $client->RecepcionarLoteRps($params);
} catch (SoapFault $e) {
    // O OURO está aqui:
    error_log('REQUEST  >>> ' . $client->__getLastRequest());
    error_log('HEADERS  >>> ' . $client->__getLastRequestHeaders());
    error_log('RESPONSE <<< ' . $client->__getLastResponse());
    throw $e;
}

Esses três __getLast* resolvem 80% dos casos sozinhos. Você vê o envelope exato, o header exato e a resposta exata. Na hora, o SOAPAction errado salta aos olhos.

Se você monta o request na mão com cURL (comum quando o WSDL da prefeitura é instável), o cuidado é o mesmo — capture o que vai:

$ch = curl_init($endpoint);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => $envelope,
    CURLOPT_HTTPHEADER     => [
        'Content-Type: text/xml; charset=utf-8',
        'SOAPAction: "' . $soapAction . '"', // aspas fazem parte do valor!
        'Content-Length: ' . strlen($envelope),
    ],
    CURLOPT_VERBOSE        => true,
]);

// Loga o envelope byte a byte ANTES de enviar
error_log('ENVELOPE BYTES >>> ' . bin2hex(substr($envelope, 0, 200)));

Aquele bin2hex parece exagero, mas é o que revela o 0d0a (que é \r\n) escondido onde não devia.

Comparando “igualzinho” de verdade

Quando você jura que está igual ao exemplo, prove. Não com o olho — com o shell:

# normaliza os dois e compara
diff <(xmllint --c14n meu.xml) <(xmllint --c14n exemplo.xml)

O --c14n (canonicalização) coloca os dois XMLs na mesma forma canônica. Se sobrar diferença, é diferença de verdade, não de formatação. Foi assim que, no caso, ficou claro que o “idêntico” não era idêntico.

Normalizando o XML antes de enviar

Em vez de montar XML por concatenação de string (a fonte de metade dos \r\n), construa com DOMDocument. Ele cuida de encoding e estrutura:

$dom = new DOMDocument('1.0', 'UTF-8');
$dom->preserveWhiteSpace = false; // mata whitespace acidental
$dom->formatOutput = false;       // sem indentação boba no output

$root = $dom->createElementNS($namespace, 'EnviarLoteRpsEnvio');
$dom->appendChild($root);
// ... monta os filhos na ORDEM que o XSD exige ...

$xml = $dom->saveXML($dom->documentElement); // sem declaração extra

// Cinto e suspensório: remove qualquer \r que tenha sobrado
$xml = str_replace(["\r\n", "\r"], "\n", $xml);

E, se você tem o XSD em mãos (em integração séria, exija o XSD), valide antes de mandar pela rede:

libxml_use_internal_errors(true);

$dom = new DOMDocument();
$dom->loadXML($xml);

if (!$dom->schemaValidate(__DIR__ . '/schemas/nfse.xsd')) {
    $erros = array_map(
        fn(LibXMLError $e) => trim($e->message) . " (linha {$e->line})",
        libxml_get_errors()
    );
    libxml_clear_errors();

    throw new PayloadInvalidoException(
        'XML reprovado no schema antes do envio: ' . implode(' | ', $erros)
    );
}

A diferença é brutal: em vez de um Erro ao processar genérico vindo da prefeitura depois de dois dias, você recebe Elemento 'Cnpj' inesperado na linha 14 na sua própria máquina, em milissegundos.

O passo de arquitetura: blinde a borda

Resolver o caso pontual é bom. Não passar por ele de novo é melhor. Duas práticas pagam o investimento:

Validação defensiva na entrada. Nunca confie no payload que chega de fora (nem no que vai pra fora). Valide tipo, formato e obrigatoriedade antes de montar o envelope. Um CPF com máscara onde o XSD espera só dígitos é um dia perdido esperando pra acontecer.

Anti-Corruption Layer (ACL). Em vez de espalhar a lógica da API da prefeitura pelo seu domínio, isole tudo atrás de uma camada de tradução. Seu sistema fala a SUA linguagem; a ACL traduz pro dialeto esquisito do webservice e vice-versa. Quando a prefeitura mudar o WSDL (e vai mudar), você troca um adaptador, não meio sistema.

interface EmissorNotaFiscal
{
    public function emitir(NotaFiscal $nota): ProtocoloEmissao;
}

// O resto do sistema só conhece a interface acima.
// Toda a sujeira de SOAP/XML/namespace fica AQUI dentro:
final class EmissorNfsePrefeituraX implements EmissorNotaFiscal
{
    public function emitir(NotaFiscal $nota): ProtocoloEmissao
    {
        $envelope = $this->montarEnvelope($nota); // DOMDocument + validação XSD
        $resposta = $this->enviar($envelope);      // SOAP com trace + log bruto
        return $this->traduzirResposta($resposta); // erro deles -> erro seu
    }
}

Impacto técnico

Isso não é firula de over-engineering. É a diferença entre:

  • Debugar em segundos (validação local) versus em dias (erro remoto genérico).
  • Saber exatamente o que saiu (log bruto + __getLastRequest) versus adivinhar.
  • Trocar de prefeitura/parceiro mexendo num adaptador versus reescrever regra espalhada.

Integração externa é onde o backend mais sofre, justamente porque metade do problema está do outro lado do fio, fora do seu controle. O que você controla é a sua borda — e é nela que vale colocar log, validação e tradução.

“Está igualzinho” é uma armadilha porque o olho humano lê o XML formatado, e o webservice lê os bytes. Da próxima vez que uma integração SOAP te der um erro genérico e você jurar que copiou tudo certo, faça três coisas antes de surtar: logue o request bruto (__getLastRequest / bin2hex), compare canonicalizado (xmllint --c14n + diff) e valide contra o XSD localmente. Na maioria das vezes, o vilão é um header errado ou um \r\n que você não estava enxergando.

E quando passar a dor, transforme a gambiarra de debug em arquitetura: validação defensiva na borda e uma Anti-Corruption Layer separando o seu domínio do dialeto alheio. Da próxima integração, você agradece.