Erro de webhook no WordPress e o que dizem 401, 404 e 429

Notebook aberto sobre mesa de madeira clara, com celular apoiado ao lado exibindo uma notificação, caderno, caneta e xícara ao redor, sob luz de fim de tarde

{"message": "Unknown Webhook", "code": 10015} foi o que o servidor guardou na madrugada em que os avisos de artigo novo pararam de chegar no canal da equipe. Ninguém viu na hora, porque o disparo do webhook acontece fora da tela: o artigo entra no ar, a página fica no ar, e a única coisa que falta é a mensagem que deveria ter aparecido no Discord. Quando alguém estranha o silêncio, já passaram três publicações.

O caminho curto é apagar o webhook, criar outro e colar a URL nova. Funciona parte das vezes, e é por isso que ele se repete: quando volta a funcionar ninguém descobriu a causa, e o mesmo erro reaparece um mês depois. O número que veio na resposta diz o que aconteceu, e são poucos números.

404 com o código 10015: o endereço não existe mais

A documentação do Discord define o 404 como o recurso no endereço informado não existir. Toda resposta de erro da API traz uma chave code própria e uma message mais legível, e o par que interessa aqui é 10015, descrito como Unknown webhook. A URL de disparo tem o formato POST /webhooks/{webhook.id}/{webhook.token}, e o 404 está dizendo que o pedaço do meio, o id, não corresponde mais a webhook nenhum.

Alguém apagou o webhook do outro lado, quase sempre sem má intenção: cada canal aceita no máximo quinze webhooks, e a API tem código próprio para o teto, o 30007, descrito como Maximum number of webhooks reached (15). Quando um servidor com muitas integrações chega perto disso, alguém limpa os que parecem sem uso. O do blog é sempre candidato, porque dispara uma vez por dia e não tem cara de nada na lista.

Faça um GET na mesma URL, sem corpo nenhum: esses endpoints respondem a GET devolvendo o objeto do webhook quando ele existe. Se voltar o mesmo 404 com o mesmo 10015, o problema está no endereço e não no que você manda, porque sem corpo não há payload para culpar. Se o objeto vier normalmente, a causa é outra.

401 com o código 50027: a URL está certa pela metade

O 401 tem uma descrição oficial que atrapalha aqui: o cabeçalho Authorization estar ausente ou inválido. Só que a URL do webhook carrega a credencial no próprio caminho, depois da última barra. Não existe cabeçalho a acrescentar. O que se lê é o código do corpo, 50027, descrito como Invalid webhook token provided.

A diferença entre este caso e o anterior é cirúrgica: no 404 o id não resolve, no 401 o id resolve e o token está errado. Ou o token foi regenerado do outro lado, o que troca a URL inteira e derruba a antiga na hora, sem convivência entre as duas; ou a URL foi cortada na cópia, causa mais chata de achar porque ninguém agiu. Campo de configuração com limite de caracteres, quebra de linha em arquivo, variável de ambiente truncada numa migração de servidor.

Distinguir as duas é uma questão de contar. Copie a URL fresca em Configurações do Servidor, integrações, e compare com a gravada no site, olhando só o trecho depois da última barra. Tamanhos diferentes indicam corte na cópia; mesmo tamanho com texto diferente indica token regenerado.

Existe um caso em que esse raciocínio não vale. Uma questão aberta no repositório de documentação da API relata que certos tokens malformados nesses endpoints devolvem 500 Internal Server Error em vez do 404 ou do 401 esperados. Um 500 ocasional, portanto, nem sempre é o Discord com problema.

400: o pedido chegou, foi entendido e foi recusado

Endereço certo, token certo, conteúdo recusado. O disparo precisa trazer valor em pelo menos um entre content, embeds, components, file e poll. Um plugin que monta a mensagem a partir do resumo do artigo produz corpo vazio quando o artigo foi publicado sem resumo, e o corpo vazio tem código próprio, o 50006, descrito como Cannot send an empty message. Como ele só aparece naquele artigo, o sintoma parece intermitente sendo determinístico.

Os limites de tamanho produzem o outro código frequente. O campo content aceita até 2000 caracteres e o campo embeds aceita um array de até 10 objetos. Estourar qualquer um dos dois cai no 50035, descrito como corpo de formulário inválido, devolvido tanto para application/json quanto para multipart/form-data, ou Content-Type inválido. O mesmo 50035 responde quando o site manda os dados como formulário em vez de JSON, depois de alguém reescrever a chamada e esquecer do cabeçalho.

Aqui a resposta ajuda: no 50035, a mensagem aponta o campo que falhou. Reproduza a chamada pelo terminal e leia o texto inteiro que volta, não só o número. Mensagem citando um campo é problema de conteúdo; mensagem falando do webhook devolve você aos dois casos anteriores.

429: a resposta já diz quantos segundos esperar

O 429 significa limite de requisições atingido. A resposta traz message, retry_after e global no corpo, mais os cabeçalhos X-RateLimit-*, entre eles X-RateLimit-Remaining, X-RateLimit-Reset-After e X-RateLimit-Scope. O retry_after é documentado como o número de segundos a esperar antes de mandar outra requisição.

Um blog que publica um artigo por dia não chega perto desse limite sozinho. Quando o 429 aparece, quase sempre há um segundo ator: outra integração no mesmo canal consumindo a mesma cota, ou uma operação em massa no próprio site. Reimportar um backup, republicar uma categoria inteira depois de reorganizá-la: cada post desses dispara o gancho de publicação, e duzentos disparos em um minuto encontram o limite que uma publicação por dia jamais encontraria.

A parte cara não é a espera. Respostas 401, 403 e 429 contam como requisições inválidas, e IPs que fazem inválidas demais ficam temporária e automaticamente restritos de acessar a API, com limiar documentado de 10.000 requisições inválidas em 10 minutos. Um plugin que tenta de novo em laço contra um webhook morto acumula 401 em velocidade de máquina, e a restrição cai sobre o IP do servidor, dividido com outros sites em hospedagem compartilhada. A documentação abre uma exceção: respostas 429 que vêm com X-RateLimit-Scope: shared não são contadas contra você.

Olhe então retry_after e X-RateLimit-Reset-After. Valores de poucos segundos indicam rajada, e o que você procura deixa de estar no plugin: está no registro do site, no mesmo horário, na forma de uma operação em lote que ninguém associou ao Discord.

O 204 que parece sucesso e o erro que nunca virou número

As duas falhas mais difíceis de achar não têm código de erro nenhum.

A primeira é o 204 No Content, resposta padrão de sucesso do disparo. O parâmetro wait vem como false, e a documentação é explícita sobre o que isso custa: quando ele é false, uma mensagem que não é salva não retorna erro. O 204 confirma que o Discord aceitou o pedido, não que a mensagem apareceu para alguém, e um monitoramento que só verifica se o status ficou abaixo de 400 marca como saudável um webhook que não entrega nada. Passando ?wait=true na URL, a resposta devolve o corpo da mensagem criada, que é a prova que faltava. Se o disparo aponta para um tópico, olhe também o thread_id: a mensagem vai para o tópico indicado e o desarquiva automaticamente, então uma entrega bem-sucedida pode estar num lugar que ninguém abre.

A segunda é do lado do WordPress, e engana quem lê log. A camada HTTP mantém a conexão aberta por um tempo definido em segundos, com padrão 5, e devolve um objeto de erro em caso de falha, não um código de status. Se o Discord demorar seis segundos, ou se o servidor estiver sob carga, o disparo falha sem produzir número nenhum para o log guardar. Existe ainda o argumento que define se quem chamou precisa do resultado: desligado, a requisição sai e o código segue sem saber se deu certo.

Nos dois casos você olha a ausência, não o conteúdo. Repita o disparo com wait=true e veja se volta corpo de mensagem; depois procure no registro do site o horário exato da publicação. Nenhuma linha ali, em vez de uma linha com código, é tempo limite estourado, não recusa do Discord.

No Telegram os mesmos problemas têm outro vocabulário

Quem avisa a equipe nos dois canais diagnostica duas gramáticas. O Telegram não conversa por status HTTP: a resposta malsucedida é um JSON com ok em falso, um error_code inteiro e uma description legível, mais um campo opcional que carrega retry_after, os segundos a esperar antes de tentar de novo, e migrate_to_chat_id, o identificador a usar nas próximas requisições quando o grupo migrou.

O migrate_to_chat_id é o equivalente do 404 do Discord, com uma diferença: nada foi apagado. O grupo virou supergrupo, o identificador mudou, e o site continua mandando para o antigo. O aviso para de chegar sem que ninguém tenha tocado na configuração, e a resposta entrega o número novo. Basta lê-la.

Nos limites de volume, os números públicos do Telegram são mais concretos. Um bot não transmite mais que cerca de 30 mensagens por segundo, a não ser que ative transmissões pagas, e dentro de um grupo o teto é de 20 por minuto; passando disso começam a chegar erros 429. Um blog diário não encosta nesses números, um laço de repetição encosta em menos de um minuto. O token também mora na URL, no formato https://api.telegram.org/bot<token>/METHOD_NAME, então o corte na cópia produz aqui o mesmo sintoma do Discord.

Nenhum desses casos é evitável para sempre. Tokens são regenerados, canais são limpos, servidores ficam lentos na hora errada. O que dá para encurtar é a distância entre a falha e alguém saber dela, e é ela que decide se o problema custa dois minutos ou três artigos publicados no silêncio. Por isso o Post Ping não se contenta com o status do disparo: no dia seguinte ele volta e pergunta quais publicações ficaram sem notificação, o que pega inclusive as falhas sem código. Quem ainda nem criou o webhook encontra o passo a passo em webhook do Discord no WordPress, e quem chegou aqui sem saber o que é um endereço que aceita POST começa por o que é um webhook.

Para separar os casos de uma vez, sem recriar nada: dispare um GET na URL, sem corpo, e depois um POST com ?wait=true. O GET responde se o endereço existe, o wait=true responde se o pedido aceito virou mensagem, e o que sobrar entre os dois é conteúdo recusado, limite de taxa ou tempo limite do seu servidor. Se os dois voltarem limpos e o canal seguir silencioso, o problema nunca foi o webhook: é o gancho do WordPress que não dispara.

Este blog publica sozinho nas redes

Cada artigo daqui vira post no Instagram e no Facebook automaticamente: legenda escrita por IA, card visual gerado na hora. Quem faz isso é o Post Ping, o mesmo plugin que você pode instalar no seu WordPress.

Ver planos do Post Ping