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.


