Elquer Carlos

ZapSign: webhook lia campo errado, contratos expiravam há semanas e a migration nunca tinha aplicado

Três commits para fechar um bug em cascata no webhook do ZapSign: campo errado, invariante furado e migration MySQL que nunca aplicou.

A noite começou com uma reclamação interna: convidado assinou o contrato, mas o sistema não marcou como ASSINADO. O cron passava, via que ainda estava ENVIADO, e expirava. Fazia semanas que isso rodava em silêncio.

Webhook lendo o campo errado

Mandei o Claude Code puxar o log de produção e cruzar com a documentação oficial da ZapSign. O diagnóstico apareceu rápido.

O handler estava lendo:

$event['document']['token']

Mas a ZapSign manda o token do documento no nível raiz do payload:

$event['token']

Todo evento doc_signed caía em “Payload sem document.token”. O contrato nunca avançava de status. O cron então expirava corretamente do ponto de vista do sistema — incorretamente do ponto de vista do contrato real, que já havia sido assinado.

Primeiro commit (fc2da6bb):

  • Lê o token da raiz com fallback pro campo aninhado
  • Extrai lógica pós-assinatura para zapsign_aplicar_assinatura() — fonte única compartilhada entre webhook e cron pra evitar drift futuro
  • Adiciona cron semanal de reconciliação (cron_zapsign_reconciliar.php) como rede de segurança pra webhooks perdidos

”Tem certeza que cobriste tudo?”

Depois do primeiro commit, cobrei: “essas correções estão de acordo com a documentação oficial do ZapSign? Tem certeza? Todas as possibilidades foram cobertas?”

A pesquisa cruzada com a doc oficial e com mais logs de produção revelou um buraco mais sério. Existia o caminho onde o contrato virava ASSINADO sem o PDF baixado. O invariante implícito “status ASSINADO ⇒ possuímos o PDF assinado” estava furado.

Segundo commit (3babd694):

  • zapsign_aplicar_assinatura agora baixa o PDF antes de marcar ASSINADO. Se o download falha, o status vira ASSINADO_SEM_ARQUIVO. Nunca mais “Assinado” falso sem arquivo.
  • Webhook ganhou guard status === 'signed' — porque doc_signed dispara por signatário; assinatura parcial chega como pending.
  • doc_refused vira CANCELADO.
  • Ramos mortos removidos: signer.signed, document.signed, doc.signed não existem na API ZapSign e estavam acumulando código morto.
  • Migration MySQL idempotente nova pras colunas download_tentativas, ultimo_erro, proxima_tentativa.

Nesse ponto apareceu um bug em cima de bug: a migration antiga (2026_03_06) usava ADD COLUMN IF NOT EXISTS em sintaxe MariaDB. Em MySQL puro isso falha com #1064 e nunca tinha aplicado. Resultado: retry e downgrade quebravam em produção com #1054 (coluna inexistente). A migration nova resolve sem depender de sintaxe proprietária.

Por último: o enum status não listava EXPIRADO, mas o código já gravava esse valor. Adicionado no mesmo commit.

O edge case que faltou na reconciliação

Depois do fix de integridade, o Claude Code levantou: a query de reconciliação cobria ENVIADO, EXPIRADO e ASSINADO sem pdf — mas não ASSINADO_SEM_ARQUIVO, que acabava de ser criado como novo status.

Terceiro commit (0752a179): incluiu ASSINADO_SEM_ARQUIVO na varredura. Curto, mas necessário. Sem isso os registros recém-criados nesse status ficavam presos fora da varredura de recuperação indefinidamente.

No início da noite, antes do bug do ZapSign aparecer, o ponto de partida foi verificar se havia tutorial ou FAQ público cobrindo o fluxo de assinatura pro usuário final. Ficou claro que o link de assinatura pública está redirecionando o convidado pra login — mas ele precisa preencher e assinar sem ter conta.

Não virou código. Virou diagnóstico mapeado pro próximo ciclo.

Auditoria do prompt do Grok para títulos de produções

Manhã e tarde foram numa frente paralela: o admin de títulos que está nascendo no kmaroteApp, que vai usar Grok via API pra gerar título com CTR/SEO em cima dos dados da produção. O prompt estava comprido; fui revisar campo por campo.

Comecei pedindo ao Claude Code uma lista markdown com todas as perguntas, respostas e dados de participantes que entram no prompt — pra validar com o próprio Grok antes de codar qualquer coisa. A primeira análise marcou vários campos como “desnecessários” ou “faltando”. Devolvi:

“Boa parte desses já está fora do prompt — me mostra como ele realmente sai.”

Refizemos o documento antes de mandar pro Grok.

O Grok devolveu pontos sobre formato de entrega (System + User + JSON Schema), atributos de aparência e palavras proibidas. Uma sutileza importante emergiu: palavras proibidas no nosso caso não são ações ou contextos — são palavras específicas que plataformas não aceitam. A abordagem tem que forçar sinônimos, não remover contexto.

Decisões tomadas:

  • Manter biotipo e atributos de aparência como whitelist explícita
  • As 3 primeiras palavras do título não podem parecer sem sentido pro humano que lê — CTR/SEO importam, mas só se o título for crível
  • foto_perfil, rg_numero, estado_nascimento, estado_ci saem do prompt (não entram no texto do título)
  • Algumas opções entram como opcional para testar, não como default
  • Grupos aprovados → plano 3.0 como v2.1.1 antes de codar

O admin de títulos ainda está nascendo. Esta sessão fechou o desenho do prompt e da validação. Codar vem no próximo ciclo.


Estatísticas do dia:

Atividade no PC:

  • Tempo ativo: 8h31min
  • AFK: 30h48min
  • Janela total monitorada: 39h19min

Por categoria (do que ficou ativo):

  • Coding: 2h41min
  • Uncategorized: 2h34min
  • AI Chat: 1h16min
  • Larissa Project: 57min
  • Communication: 38min
  • Browsing: 13min
  • Reading: 8min

Top apps: Chrome (4h39min) · Antigravity IDE (2h41min) · WhatsApp (38min)

Top sites navegados: <PRIVATE-HOST> · chatgpt.com · github.com

Trabalho com IA:

  • Conversas claude.ai: 0
  • Sessões Claude Code: 2 (kmaroteApp — títulos/Grok manhã e tarde; ZapSign noite)

Código produzido:

  • Commits: 4 (3 kmaroteApp · 1 elquercarlos)

Devlog do dia:

  • 1 draft consolidado (2026-06-11-2330.md)
Fim do ato