Quando a API do parceiro mente: validação defensiva de payload em integração externa

Existe uma frase que todo dev de backend deveria tatuar no antebraço: “a API do parceiro vai te mandar lixo, e vai ser num dia de sexta-feira.”

Não é maldade do outro lado. É a vida. Sistemas externos têm bug, fazem deploy no pior horário, mudam contrato sem avisar e, de vez em quando, mandam um pedido com o valor total errado bem na hora do almoço — justo quando o restaurante está lotado e ninguém tem tempo de investigar.

Esse artigo nasceu de um caso real e dolorosamente comum: pedidos chegando de uma integração de delivery com valores divergentes do esperado. O total não batia com a soma dos itens. E quando isso acontece, a pergunta que importa não é “de quem é a culpa?” e sim, “por que meu sistema aceitou isso?”.

A resposta, quase sempre, é a mesma: a gente confiou no payload. E confiar cegamente em dado externo é o caminho mais curto pra um incidente em produção.

Contexto: a API externa não é sua amiga

Quando você consome dado que você mesmo gerou, dá pra relaxar um pouco — você conhece as regras, controla o formato, sabe o que esperar. Já quando o dado vem de fora, todas as garantias evaporam.

O parceiro pode mandar:

  • um campo que mudou de nome no último deploy dele;
  • um total que não corresponde à soma dos itens;
  • um null onde você jurava que sempre viria número;
  • o mesmo pedido duas vezes (oi, retry mal configurado);
  • um valor zerado porque um cupom bugou no lado deles.

Nada disso é culpa sua. Mas tudo isso vira problema seu no segundo em que você persiste no banco. O dado externo é como comida de procedência duvidosa: pode até estar ótima, mas você cheira antes de comer.

O problema real: confiar no total que veio pronto

O padrão ingênuo (que todos nós já escrevemos, sem vergonha de admitir) é mais ou menos assim:

public function receberPedido(array $payload): Pedido
{
    $pedido = new Pedido();
    $pedido->externalId = $payload['order_id'];
    $pedido->total      = $payload['total']; // confiança cega 🙈
    $pedido->itens      = $payload['items'];

    $this->repository->save($pedido); // e reza

    return $pedido;
}

Funciona lindamente na demo. Aí chega o dia em que total vem como R$ 0,00, ou não bate com a soma dos itens, e você descobre o problema pela pior fonte possível: o cliente reclamando que pagou errado. O $payload['total'] que você aceitou de olhos fechados virou prejuízo.

A solução: desconfiar com método

A boa notícia é que blindar isso não exige nenhuma mágica — só disciplina em quatro frentes.

1. Guard clauses no boundary

O ponto onde o dado externo entra no seu sistema é uma fronteira. Trate como alfândega: nada passa sem revista. Antes de qualquer coisa, valide a estrutura.

final class PayloadPedidoValidator
{
    public function validar(array $payload): void
    {
        foreach (['order_id', 'total', 'items'] as $campo) {
            if (!array_key_exists($campo, $payload)) {
                throw new PayloadInvalidoException("Campo obrigatório ausente: {$campo}");
            }
        }

        if (!is_array($payload['items']) || $payload['items'] === []) {
            throw new PayloadInvalidoException('Pedido sem itens');
        }
    }
}

Parece óbvio? É. Mas “óbvio” e “implementado” são dois estados bem diferentes na vida de um sistema legado.

2. Validar invariantes (as regras que NUNCA podem ser violadas)

Aqui mora o ouro. Estrutura certa não significa dado correto. O caso dos valores divergentes é exatamente isso: o JSON estava perfeitamente formado, mas a regra de negócio estava quebrada. A soma dos itens tem que bater com o total. O total não pode ser zero. São invariantes — verdades que a sua aplicação se recusa a violar.

public function validarInvariantes(array $payload): void
{
    $somaItens = array_sum(
        array_map(
            fn(array $item) => $item['price'] * $item['quantity'],
            $payload['items']
        )
    );

    // Comparar dinheiro com tolerância de centavo (arredondamento existe)
    if (abs($somaItens - $payload['total']) > 0.01) {
        throw new InvarianteVioladaException(
            "Total divergente: payload={$payload['total']}, calculado={$somaItens}"
        );
    }

    if ($payload['total'] <= 0) {
        throw new InvarianteVioladaException('Total deve ser maior que zero');
    }
}

Dica de quem já se queimou: trabalhe com dinheiro em centavos inteiros sempre que puder. Comparar float de dinheiro com == é um esporte radical que ninguém deveria praticar em produção.

3. Idempotência: porque o parceiro VAI mandar o mesmo pedido duas vezes

Retry é uma boa prática — do lado de quem chama. Do seu lado, significa que o mesmo pedido pode bater na sua porta múltiplas vezes. Sem proteção, você duplica venda, duplica cobrança, duplica dor de cabeça.

A solução é a velha e boa idempotência por chave natural (o order_id do parceiro). É o mesmo princípio que o Stripe usa com Idempotency-Key: a primeira vez processa, as próximas devolvem o resultado já existente.

public function receberPedido(array $payload): Pedido
{
    $existente = $this->repository->buscarPorExternalId($payload['order_id']);

    if ($existente !== null) {
        // Já vimos esse pedido. Devolve o que já temos, sem reprocessar.
        return $existente;
    }

    $this->validator->validar($payload);
    $this->validator->validarInvariantes($payload);

    // ... persiste com unique constraint em external_id no banco
}

E um detalhe que separa o código que funciona na demo do código que aguenta produção: coloque uma unique constraint em external_id no banco. Race condition entre dois requests simultâneos do mesmo pedido é real, e o banco é o seu último juiz — quem chegar em segundo toma o erro de constraint, e você trata com elegância em vez de duplicar.

4. Log do payload bruto: seu seguro contra “mas eu não mandei isso”

Quando der ruim — e vai dar —, você precisa do payload exatamente como chegou, antes de qualquer transformação. É o que transforma uma discussão de “a culpa é sua / não, é sua” numa conversa baseada em evidência. Além de permitir reprocessar (replay) o pedido depois que o bug for corrigido.

$this->logger->info('payload.pedido.recebido', [
    'external_id' => $payload['order_id'] ?? null,
    'parceiro'    => 'delivery-x',
    'raw'         => json_encode($payload), // o crú, sem maquiagem
    'recebido_em' => now()->toIso8601String(),
]);

Junte as quatro peças e o resultado é uma camada de integração que falha cedo, falha alto e falha com contexto — em vez de engolir lixo silenciosamente e explodir três dias depois num relatório financeiro.

Vale o aviso pra não cair no extremo oposto: validação defensiva não é desconfiar de tudo a ponto de rejeitar pedido legítimo. É proteger as invariantes que, se violadas, causam prejuízo real. Total que não bate, valor zerado, pedido duplicado — esses doem no caixa. Um campo opcional ausente, nem sempre. Saber a diferença é o que separa robustez de paranoia.

E tem o ganho silencioso: quando o pedido divergente é barrado no boundary, com log do payload bruto e exceção clara, o seu time descobre o problema do parceiro antes do cliente. Isso muda completamente a conversa com quem está do outro lado da integração.

A regra de ouro de integração com terceiro cabe em uma linha: confie, mas verifique — e logue tudo. A API do parceiro não é inimiga, mas também não é sua amiga; é uma fonte de dados que você não controla, e isso já é motivo suficiente pra tratar cada payload como suspeito até prova em contrário.

As quatro frentes — guard clauses, invariantes, idempotência e log do cru — não são arquitetura sofisticada. São higiene básica de backend que integra com o mundo real. O caso dos valores divergentes só virou incidente porque faltou a segunda: ninguém perguntou ao payload se a soma batia com o total.

Da próxima vez que você for plugar uma API externa, lembre da tatuagem imaginária no antebraço. O parceiro vai mandar lixo. A única pergunta que você controla é se o seu sistema vai aceitar.