A loja avisa o site que saiu um pedido, o site responde 200 — e mesmo assim o cliente recebe dois e-mails, ou um pedido nunca aparece. As duas falhas vêm da mesma característica do mecanismo: webhook é entrega de melhor esforço. Pode chegar duas vezes, chegar fora de ordem, ou não chegar. Este é o esqueleto que aguenta isso num WordPress, e as armadilhas que estão no código do core.
Regra 1 — Valide a assinatura sobre o corpo cru
A plataforma assina os bytes que mandou. Se você decodificar o JSON e serializar de novo para conferir, muda espaço, ordem de chave e escape de barra — a assinatura não bate mais, e o erro parece segredo errado.
No WordPress o corpo cru já vem no request: o servidor REST grava nele o que leu de php://input antes de despachar a rota (class-wp-rest-server.php, $request->set_body( self::get_raw_data() )). Então get_body() é o texto original, e get_json_params() não serve para conferir assinatura.
Três detalhes que economizam uma tarde: permission_callback é obrigatório desde o WordPress 5.5, e aqui ele tem o papel certo — a assinatura é a permissão, então rota sem assinatura válida nem chega ao callback; a comparação é hash_equals(), não ===; e o segredo mora numa constante do wp-config.php, nunca no banco nem no repositório.
Regra 2 — Responda 200 antes de processar
A plataforma corta a conexão em poucos segundos e, sem 200, reenvia o mesmo evento. Se o seu handler cria post, emite nota e chama uma segunda API dentro da requisição, qualquer pico de lentidão vira reentrega — e reentrega, sem a Regra 3, vira pedido duplicado. O endpoint faz duas coisas: confere a assinatura e guarda o evento. O trabalho é depois.
VARCHAR(190) na chave não é frescura: em utf8mb4 o índice de uma coluna só vai até 191 caracteres. E guarde o payload inteiro — é o que permite reprocessar sem pedir o dado de novo.
Regra 3 — Duplicata se evita no banco, não no if
“Se já existe pedido 1234, não cria” é corrida, não proteção: duas entregas simultâneas passam as duas pelo SELECT antes de qualquer INSERT. Quem decide é a chave única — a segunda gravação bate na restrição e você responde 200 na mesma hora.
Nunca use só o número do pedido como chave: o mesmo pedido gera vários eventos (criado, pago, enviado, cancelado) e você descartaria todos menos o primeiro.
Regra 4 — O cron do WordPress engole agendamento repetido
Esta é a que morde calado. wp_schedule_single_event() recusa evento idêntico — mesmo gancho e mesmos argumentos — a menos de dez minutos de um já agendado, e devolve false sem dizer nada (wp-includes/cron.php, no bloco “Don’t schedule an event if there’s already an identical event within 10 minutes”). Dez webhooks numa manhã movimentada: um agendamento sai, nove somem.
Por isso o gancho acima não leva argumento nenhum e o worker varre a fila inteira. Se você preferir um agendamento por evento, passe a chave como argumento — argumentos diferentes contam como eventos diferentes. Varrer a fila é mais robusto: mesmo que todo agendamento seja perdido, a próxima execução pega o que ficou para trás.
Dois lembretes sobre o cron do WordPress: ele só roda quando alguém visita o site — em site de pouco tráfego o pedido dorme na fila. Em loja de verdade, desligue com define( 'DISABLE_WP_CRON', true ) e chame wp cron event run --due-now pelo cron do sistema, de minuto em minuto.
Regra 5 — A ordem de chegada não é a ordem dos fatos
O evento de atualização pode chegar antes do de criação, e uma reentrega antiga pode chegar depois de uma mudança mais nova. Se o seu código sempre sobrescreve com o que acabou de chegar, um pedido cancelado volta a “pago”.
A defesa é o carimbo do payload — nunca o relógio da sua máquina, que é outro relógio. Guarde o carimbo junto do registro e descarte o evento mais velho do que o estado já gravado.
Regra 6 — O que não chegou, ninguém reenvia para sempre
Toda plataforma desiste em algum momento: acabam as tentativas, o site ficou fora do ar na janela inteira, alguém trocou o segredo e a assinatura passou a falhar. Aí o evento não existe em lugar nenhum do seu lado, e webhook nenhum vai trazer de volta.
O conserto é uma tarefa diária que pergunta à API da loja os pedidos das últimas 48 horas e joga na mesma fila o que não tiver chave gravada. Como o processamento é idempotente, o que já passou é ignorado sozinho. Essa mesma rotina resolve a primeira carga, a migração e a semana em que o certificado venceu.
O fluxo completo
Juntando as seis regras, do POST da loja ao pedido no ar: