Porque os testes comparavam o código com ele mesmo, e não com o mundo real. Em 8 de setembro de 2026, o primeiro pagamento real de um SaaS que estou construindo entrou (R$ 39 no Mercado Pago) e o plano não subiu, com 997 testes verdes. Os quatro bugs que encontrei nasceram de suposições que os testes repetiam: um formato de tempo, um tipo de dado, um campo do payload e uma coluna. Conferi as fontes em 10 de outubro de 2026.
O que a documentação diz
Webhook é o aviso que o gateway de pagamento manda para o seu sistema quando algo acontece. A documentação do Mercado Pago diz que a notificação traz um cabeçalho x-signature com um valor ts e um v1, e que o seu endpoint deve responder 200 ou 201. Se não houver resposta em 22 segundos, a notificação é tratada como não entregue e o Mercado Pago reenvia a cada 15 minutos, com intervalos crescentes depois da terceira tentativa. Sobre o ts, a página mostra um exemplo e não diz a unidade. Isso importa no primeiro bug.
Para assinaturas, a documentação lista o tópico subscription_authorized_payment, descrito como pagamento recorrente de uma assinatura. A página que consultei não detalha os campos nem os status. Isso importa no terceiro bug.
Os quatro bugs
1. O ts vinha em segundos. O código supunha milissegundos e comparava o relógio local multiplicado por 1.000 com um número de 10 dígitos. A diferença era de cerca de 1,79 trilhão contra um limite de 300 mil. Todo webhook legítimo voltava 401, e o Mercado Pago entregou cinco vezes. Como a documentação não fixa a unidade, o certo é aceitar as duas: 10 dígitos são segundos, 13 são milissegundos.
2. Texto numa coluna de data. O driver de banco que uso (asyncpg) infere o tipo pela consulta, e a documentação mapeia o date do PostgreSQL para datetime.date. Mandei texto, com .isoformat(), e ele recusou com "'str' object has no attribute 'toordinal'". Converter para texto parece certo e não é.
3. O status do topo era do ciclo, não do dinheiro. No pagamento autorizado de uma assinatura, o status do nível de cima vinha processed. O resultado real estava em payment.status, com approved. Ler o de cima fazia o sistema ignorar cobranças aprovadas. Na prática, toda renovação mensal seria ignorada e o plano venceria com o cliente pagando. Era o mais caro a longo prazo e o único que só apareceria no segundo mês. Não vi essa diferença entre os dois campos explicada na documentação que consultei: ela veio do payload real.
4. Uma coluna que nunca existiu. Uma consulta fazia JOIN users u ON u.id = w.owner_id, e workspaces.owner_id não estava em nenhuma migration. Era a única linha do código que usava essa coluna, e outro módulo já buscava o dono pelo caminho certo. A escrita do plano vinha antes do envio do e-mail, então o erro de coluna derrubava a requisição com o plano já aplicado. O gateway lia 500, tratava a entrega como falha e continuava reenviando, com intervalo que cresce depois da terceira tentativa.
Por que 997 testes verdes não pegaram nada
Cada um mediu o proxy, não o efeito.
- O teste fabricava o
tsem milissegundos, o mesmo formato errado que o código esperava. Teste e código concordavam entre si e discordavam do mundo. - O banco falso aceitava qualquer tipo, então texto numa coluna de data passava.
- O banco falso respondia por correspondência de texto, sem schema, então uma coluna inventada devolvia o mesmo dicionário que uma coluna real.
- Ninguém tinha o payload real (o corpo da notificação, como o gateway manda de verdade) para saber que o
statusde cima não era o do dinheiro.
Além dos testes, passaram sete jobs de CI (a esteira que roda os testes a cada mudança) e uma validação em sandbox (o ambiente de teste do gateway, com dinheiro de mentira). O sandbox cobria a API de pagamento avulso, e o produto cobra por assinatura.
O que fazer
- Guarde o payload literal da primeira resposta real de qualquer gateway e use como fixture, o dado fixo que os testes leem. Fabricar de novo reintroduz o erro.
- Faça o banco falso reprovar tipo errado. Um pool que exigisse
datetime.datenuma colunadateteria pego o bug 2 antes. - Escreva uma checagem que leia o schema das migrations e confira contra o SQL do código. Ela acha coluna inexistente sem precisar de banco.
- Aceite as duas unidades de tempo em timestamps de terceiros.
- Reprocessar um webhook perdido é legítimo quando o evento é real e o segredo é seu. Não precisa esperar a reentrega do gateway.
- Trate o primeiro pagamento real como o único teste do caminho principal, e não como cerimônia de lançamento. Faça-o cedo, com valor baixo.
Para ver como eu monto cobrança e assinatura em um produto, veja sistemas e SaaS.