pular para o conteúdo

Webhook de pedido no WordPress: não perder e não duplicar

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.

				
					add_action( 'rest_api_init', function () {
    register_rest_route( 'loja/v1', '/webhook', array(
        'methods'             => 'POST',
        'callback'            => 'loja_receber_webhook',
        'permission_callback' => 'loja_assinatura_confere',
    ) );
} );

function loja_assinatura_confere( WP_REST_Request $req ) {
    // o nome do header varia por plataforma
    $enviada = $req->get_header( 'X-Loja-Assinatura' );

    if ( ! $enviada ) {
        return false;
    }

    $minha = base64_encode( hash_hmac(
        'sha256', $req->get_body(), LOJA_WEBHOOK_SEGREDO, true
    ) );

    return hash_equals( $minha, $enviada );
}
				
			

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.

				
					CREATE TABLE wp_loja_eventos (
  id          BIGINT UNSIGNED  NOT NULL AUTO_INCREMENT,
  chave       VARCHAR(190)     NOT NULL,
  topico      VARCHAR(100)     NOT NULL,
  payload     LONGTEXT         NOT NULL,
  carimbo     DATETIME         NOT NULL,
  estado      VARCHAR(20)      NOT NULL DEFAULT 'pendente',
  tentativas  SMALLINT UNSIGNED NOT NULL DEFAULT 0,
  criado_em   DATETIME         NOT NULL,
  PRIMARY KEY (id),
  UNIQUE KEY chave (chave),
  KEY estado (estado)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
				
			

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.

				
					function loja_receber_webhook( WP_REST_Request $req ) {
    global $wpdb;

    $corpo   = json_decode( $req->get_body(), true );
    $topico  = (string) $req->get_header( 'X-Loja-Topico' );
    $quando  = $corpo['updated_at'] ?? null;
    $carimbo = $quando
        ? gmdate( 'Y-m-d H:i:s', strtotime( $quando ) )
        : current_time( 'mysql', true );

    // id do evento é o melhor; sem ele, monte algo
    // que mude a cada fato novo
    $pedido = $corpo['id'] ?? '';
    $chave  = $req->get_header( 'X-Loja-Evento-Id' )
        ?: $topico . ':' . $pedido . ':' . $carimbo;

    $gravou = $wpdb->query( $wpdb->prepare(
        "INSERT IGNORE INTO {$wpdb->prefix}loja_eventos
           (chave, topico, payload, carimbo, estado, criado_em)
         VALUES (%s, %s, %s, %s, 'pendente', %s)",
        $chave,
        $topico,
        $req->get_body(),
        $carimbo,
        current_time( 'mysql', true )
    ) );

    if ( $gravou ) {
        wp_schedule_single_event( time() + 5, 'loja_fila' );
    }

    return new WP_REST_Response( array( 'ok' => true ), 200 );
}
				
			
Repetição não é erro: é o jeito de a plataforma garantir entrega. Seu endpoint responder 200 a um evento que já viu é o comportamento correto — o que não pode é processar de novo.

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.

				
					add_action( 'loja_fila', 'loja_processar_fila' );

function loja_processar_fila() {
    global $wpdb;
    $tabela = $wpdb->prefix . 'loja_eventos';

    $pendentes = $wpdb->get_results(
        "SELECT * FROM {$tabela}
          WHERE estado = 'pendente' ORDER BY id LIMIT 20"
    );

    foreach ( $pendentes as $evento ) {
        // marcar antes de trabalhar: duas execuções
        // simultâneas não pegam a mesma linha
        $peguei = $wpdb->query( $wpdb->prepare(
            "UPDATE {$tabela}
                SET estado = 'processando',
                    tentativas = tentativas + 1
              WHERE id = %d AND estado = 'pendente'",
            $evento->id
        ) );

        if ( ! $peguei ) {
            continue;
        }

        try {
            $pedido = json_decode( $evento->payload, true );
            loja_aplicar_evento( $pedido, $evento );
            $estado = 'feito';
        } catch ( Exception $e ) {
            $estado = $evento->tentativas >= 5
                ? 'falhou'
                : 'pendente';
            $erro = $e->getMessage();
            error_log( "[loja] {$evento->chave}: {$erro}" );
        }

        $wpdb->update(
            $tabela,
            array( 'estado' => $estado ),
            array( 'id' => $evento->id )
        );
    }
}
				
			

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.

				
					function loja_aplicar_evento( array $pedido, $evento ) {
    $post_id = loja_achar_post_do_pedido( $pedido['id'] );

    if ( $post_id ) {
        $gravado = get_post_meta( $post_id, 'loja_ts', true );
        if ( $gravado && $gravado >= $evento->carimbo ) {
            // chegou fora de ordem: o banco tem algo mais novo
            return;
        }
    }

    $post_id = loja_criar_ou_atualizar( $post_id, $pedido );
    update_post_meta( $post_id, 'loja_ts', $evento->carimbo );
}
				
			

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.

Webhook é o caminho rápido; a varredura periódica é a rede de segurança. Um sem o outro sempre perde alguma coisa.

O fluxo completo

Juntando as seis regras, do POST da loja ao pedido no ar:

  1. Chega o POST. O permission_callback confere o HMAC sobre o corpo cru.
  2. O handler monta a chave do evento e faz INSERT IGNORE na fila.
  3. Se a linha entrou, agenda o worker; se era repetida, não faz nada.
  4. Responde 200 — em milissegundos, sem tocar em regra de negócio.
  5. O worker pega a linha com UPDATE ... WHERE estado = 'pendente', que é o cadeado.
  6. Compara o carimbo do evento com o que está gravado e descarta o que for mais velho.
  7. Aplica o efeito, marca feito. Deu erro, volta para pendente até cinco tentativas.
  8. Uma vez por dia, a varredura pela API enfileira o que nunca chegou.

Utilidades

  • Testar o endpoint com assinatura de verdade, sem depender da loja: ASSINATURA=$(printf '%s' "$CORPO" | openssl dgst -sha256 -hmac "$SEGREDO" -binary | base64) e mandar com curl --data-raw "$CORPO"--data-raw, porque -d mexe no conteúdo.
  • Rode o mesmo curl duas vezes: a segunda tem que responder 200 e não inserir linha. É o teste da Regra 3.
  • Ver a fila de relance: wp db query "SELECT estado, COUNT(*) FROM wp_loja_eventos GROUP BY estado".
  • Reprocessar um evento: UPDATE wp_loja_eventos SET estado='pendente', tentativas=0 WHERE chave='...'. O payload cru está ali para isso.
  • Conferir se o agendamento existe mesmo: wp cron event list. Forçar: wp cron event run loja_fila.
  • Log com a chave do evento na primeira coluna. Sem ela, investigar duplicata é adivinhação.
  • Limpe a fila com mais de 90 dias — payload de loja movimentada cresce rápido.

Do mesmo assunto

Capa: Tema filho, a armadilha do index.php

Tutoriais, WordPress

20 ago 2026

Tema filho no WordPress: por que o index.php pode deixar sua página em branco

Um tema filho com um index.php genérico pode devolver 200
Capa: Revisor automático de PR com IA

Tutoriais

30 jul 2026

Revisor automático de PR com IA, sem depender de máquina ligada

Como tirei um revisor de PR com IA da minha
Capa: Deploy de WordPress com rsync sem sustos

Tutoriais, WordPress

23 jul 2026

Deploy de WordPress com rsync e GitHub Actions sem sustos

Como automatizei o deploy de um WordPress numa VPS com

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *