O que é
O webhook é um destino de publicação, como o WordPress, o Webflow, o Wix ou a Shopify, só que sem uma plataforma fixa do outro lado. Você aponta a URL do seu sistema (um CMS próprio, um Next.js, o n8n, o Make, o HubSpot, o que for) e a ClickHunt envia cada artigo lá, assinado, no formato que você escolher.
Disponível no plano Pro. Cada projeto tem um único webhook. Se você precisa mandar para dois sistemas diferentes, coloque um roteador do seu lado.
Como conectar
- Abra Integrações no projeto e escolha Webhook, ou selecione "Meu próprio sistema (webhook)" no wizard de criação do projeto.
- Cole a URL do seu endpoint. Ela precisa começar com
https://e ser pública (osafeFetchrecusa IP privado, localhost e esquemas diferentes de https). - Se o seu endpoint exigir algum cabeçalho fixo (por exemplo
Authorization: Bearer …), digite um por linha no formatoNome: valor. Até 10 cabeçalhos. Os nomesHost,Content-Length,Content-Type,User-Agente qualquer coisa começando comX-ClickHunt-são reservados e não podem ser usados aqui, porque a entrega já os define sozinha. - Marque os eventos que você quer receber: publicado, atualizado, arquivado, excluído. Todos vêm marcados por padrão.
- Marque os formatos do corpo do artigo que você quer no payload: HTML, Blocos (o artigo inteiro estruturado, em JSON) e Markdown. Pode marcar mais de um ao mesmo tempo. HTML vem marcado por padrão.
- Clique em conectar. A ClickHunt envia um
pingreal para o seu endpoint; se ele responder 2xx, a conexão fica pronta.
A URL e os cabeçalhos só são definidos nesse momento. Para trocar a URL depois, desconecte e conecte de novo. Eventos e formatos você pode mudar quando quiser, sem reconectar.
O segredo
Ao conectar, a ClickHunt gera um segredo de assinatura (whsec_…) e mostra ele uma única vez, na tela, com um botão de copiar. Guarde em um lugar seguro: ele não aparece de novo depois de fechar o painel.
Esse segredo é o que garante que uma entrega realmente veio da ClickHunt, e não de alguém se passando por ela. Veja a seção "Verificar a assinatura" abaixo para o código que confere isso do seu lado.
Se você desconfiar que o segredo vazou, use Regenerar segredo na tela de configuração. O segredo antigo para de funcionar na hora e um novo é mostrado, também uma única vez.
Cabeçalhos enviados
Toda entrega (artigo, ping ou teste) chega com estes cabeçalhos:
| Cabeçalho | O que traz |
|---|---|
Content-Type |
sempre application/json |
User-Agent |
ClickHunt-Webhook/1 |
X-ClickHunt-Event |
o evento desta entrega (article.published, article.updated, article.archived, article.deleted, article.test ou ping) |
X-ClickHunt-Delivery-Id |
um UUID que identifica o episódio de entrega. Numa retentativa do mesmo episódio, o id se repete, para você deduplicar do seu lado |
X-ClickHunt-Api-Version |
a versão do formato do payload, hoje 2026-09-11 |
X-ClickHunt-Signature |
a assinatura HMAC, no formato t=<timestamp unix>,v1=<hex> |
Além destes, chegam também os cabeçalhos fixos que você configurou (se houver).
Verificar a assinatura
A assinatura segue o mesmo formato do Stripe: t=<timestamp>,v1=<hmac sha256 hex>, calculado sobre "${t}.${corpo bruto da requisição}" com o seu segredo. Compare com timingSafeEqual (ou equivalente), nunca com ==, e rejeite timestamps fora de uma janela de tolerância (5 minutos é razoável) para evitar replay.
Node.js:
const crypto = require("node:crypto");
function verificar(segredo, corpoBruto, header, agoraSegundos, tolerancia = 300) {
const m = /^t=(\d+),v1=([a-f0-9]{64})$/.exec(header);
if (!m) return false;
const t = Number(m[1]);
const v1 = m[2];
if (!Number.isFinite(t) || Math.abs(agoraSegundos - t) > tolerancia) return false;
const esperado = crypto.createHmac("sha256", segredo).update(`${t}.${corpoBruto}`).digest("hex");
const a = Buffer.from(esperado, "hex");
const b = Buffer.from(v1, "hex");
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
// Exemplo de uso num handler Express/Next:
// const ok = verificar(process.env.CLICKHUNT_WEBHOOK_SECRET, corpoBruto, req.headers["x-clickhunt-signature"], Math.floor(Date.now() / 1000));
Python:
import hashlib
import hmac
import re
import time
def verificar(segredo: str, corpo_bruto: str, header: str, agora_segundos: int, tolerancia: int = 300) -> bool:
m = re.match(r"^t=(\d+),v1=([a-f0-9]{64})$", header)
if not m:
return False
t = int(m.group(1))
v1 = m.group(2)
if abs(agora_segundos - t) > tolerancia:
return False
mensagem = f"{t}.{corpo_bruto}".encode("utf-8")
esperado = hmac.new(segredo.encode("utf-8"), mensagem, hashlib.sha256).hexdigest()
return hmac.compare_digest(esperado, v1)
# ok = verificar(SEGREDO, corpo_bruto, request.headers["X-ClickHunt-Signature"], int(time.time()))
Importante: use o corpo bruto da requisição (os bytes exatos que chegaram), antes de qualquer JSON.parse. Um framework que já reserializa o corpo antes de chegar no seu handler vai quebrar a verificação.
Payload
Este é o payload completo de um article.published, com os três formatos (HTML, Blocos e Markdown) ligados, gerado pelo mesmo artigo de exemplo que o botão "Enviar artigo de teste" usa:
{
"event": "article.published",
"apiVersion": "2026-09-11",
"deliveryId": "5f0c5c0e-4b1c-4d0e-9a2b-7c3d1e2f3a4b",
"sentAt": "2026-09-11T12:00:00.000Z",
"test": false,
"project": {
"id": "00000000-0000-4000-8000-000000000002",
"name": "Projeto de exemplo",
"brandName": "ClickHunt",
"website": "https://clickhunt.ai",
"contentLanguage": "pt-br"
},
"article": {
"id": "00000000-0000-4000-8000-000000000001",
"externalId": null,
"title": "Guia",
"slug": "guia-exemplo",
"previousSlugs": [],
"status": "published",
"contentType": "guide",
"contentLanguage": "pt-br",
"publishedAt": "2026-09-01T12:00:00Z",
"updatedAt": "2026-09-05T09:00:00Z",
"createdAt": "2026-08-28T09:00:00Z",
"excerpt": "Desc",
"body": {
"html": "<p>Intro.</p>\n<blockquote><p><strong>Resposta rápida</strong></p><p>Resposta.</p></blockquote>\n<h2>Resumo</h2><ul><li>um</li><li>dois</li></ul>\n<img src=\"https:…",
"blocks": {
"meta": {
"slug": "guia",
"contentType": "guide",
"contentLanguage": "pt-br",
"title": "Guia",
"metaTitle": "Guia SEO",
"metaDescription": "Desc"
},
"authority": {
"authorName": "Ana"
},
"entities": [],
"references": [
{
"id": "r1",
"url": "https://fonte.example/x",
"title": "Fonte X"
}
],
"evidence": [],
"body": [
{
"id": "b1",
"type": "intro",
"text": "Intro."
},
{
"id": "b2",
"type": "quick_answer",
"text": "Resposta."
}
]
},
"markdown": "Intro.\n\n> **Resposta rápida**\n>\n> Resposta.\n\n## Resumo\n\n- um\n- dois\n\n## Seção\n\nPrimeiro.\n\nVeja a [fonte](https://fonte.example/x) aqui.\n\n### Sub\n\n1. a\n2. b\n\n> C…"
},
"seo": {
"metaTitle": "Guia SEO",
"metaDescription": "Desc",
"canonical": null,
"redirect": null,
"keyword": "guia de exemplo",
"keywords": [
"guia de exemplo"
],
"jsonLd": [
{
"@type": "Organization",
"@id": "https://clickhunt.ai/#organization",
"name": "ClickHunt",
"url": "https://clickhunt.ai"
},
{
"@type": "BlogPosting",
"headline": "Guia",
"description": "Desc",
"image": [
"https://app.clickhunt.ai/logos/platforms/clickhunt.png"
],
"datePublished": "2026-09-01T12:00:00Z",
"dateModified": "2026-09-05T09:00:00Z",
"inLanguage": "pt-br",
"articleSection": [
"exemplo"
],
"keywords": "guia de exemplo, exemplo",
"citation": [
{
"@type": "CreativeWork",
"name": "Fonte X",
"url": "https://fonte.example/x"
}
],
"author": {
"@type": "Person",
"name": "Ana Souza",
"jobTitle": "Editora",
"description": "Escreve sobre visibilidade em IA e SEO na ClickHunt.",
"image": "https://app.clickhunt.ai/logos/platforms/clickhunt.png",
"sameAs": [
"https://www.linkedin.com/company/clickhunt"
]
},
"publisher": {
"@id": "https://clickhunt.ai/#organization"
}
}
]
},
"taxonomy": {
"categories": [
"Guia"
],
"tags": [
"exemplo"
]
},
"author": {
"name": "Ana Souza",
"role": "Editora",
"bio": "Escreve sobre visibilidade em IA e SEO na ClickHunt.",
"avatar": "https://app.clickhunt.ai/logos/platforms/clickhunt.png",
"social": {
"linkedin": "https://www.linkedin.com/company/clickhunt"
}
},
"createdBy": {
"name": "Ana Souza",
"avatar": "https://app.clickhunt.ai/logos/platforms/clickhunt.png"
},
"cover": {
"url": "https://app.clickhunt.ai/logos/platforms/clickhunt.png",
"alt": "Capa do artigo"
},
"images": [
{
"url": "https://app.clickhunt.ai/logos/platforms/clickhunt.png",
"alt": "Capa do artigo",
"caption": null,
"width": null,
"height": null,
"kind": "cover",
"position": -1
},
{
"url": "https://cdn.example/a.webp",
"alt": "Alt A",
"caption": "Legenda",
"width": 800,
"height": 600,
"kind": "stock",
"position": 3
}
],
"cta": {
"id": "cta-exemplo",
"headline": "Quer rastrear sua marca nas IAs?",
"subheadline": "Veja como a ClickHunt monitora ChatGPT, Claude, Gemini e Perplexity.",
"buttonLabel": "Conhecer a ClickHunt",
"buttonLink": "https://clickhunt.ai",
"utm": {
"source": "clickhunt",
"medium": "webhook-example",
"campaign": "guia-exemplo",
"content": "",
"term": ""
}
},
"generation": {
"origin": "manual",
"objective": null,
"goal": null,
"length": null,
"country": null,
"keyword": "guia de exemplo",
"keywords": [
"guia de exemplo"
],
"imageStyle": null,
"imagePalette": null,
"autoLink": null,
"materials": [],
"references": []
}
}
}
Exemplo abreviado: html, markdown, blocks.body e images foram encurtados para caber aqui. O botão Enviar artigo de teste entrega o payload completo no seu endpoint.
Alguns pontos sobre este payload que valem a pena notar:
bodysó traz as chaves dos formatos que você marcou na configuração (html,blocks,markdown). Se você desmarcar todos menos um, os outros dois somem do corpo, não vêm vazios. Quando o artigo foi editado à mão no editor,htmlemarkdownrefletem o texto editado eblocksmantém a estrutura gerada.article.deletedmanda um corpo bem mais enxuto: só{ id, externalId, slug, previousSlugs }, sem o resto do artigo, porque nessa hora o conteúdo pode já ter sido apagado daqui também.cta.utmpode conter os textos literais{project}e{article}emmedium/campaign/contentquando o CTA usa o padrão da plataforma. Isso é um modelo, não um valor pronto: substitua{project}pelo nome (ou id) do projeto e{article}pelo identificador do artigo antes de montar o link final do seu lado.article.generation.goalearticle.generation.lengthvêm semprenullhoje. O produto ainda não coleta esses dois campos na criação do artigo; quando passar a coletar, eles deixam de vir vazios, sem mudar o formato do payload.- Um artigo editado várias vezes seguidas em menos de 15 segundos gera um único
article.updatedno final da rajada, não um por edição salva.
Eventos e quando disparam
O evento que sai depende do estado atual do artigo aqui e do que o seu sistema já recebeu antes:
| Situação aqui | O que o seu sistema já tinha | Evento enviado |
|---|---|---|
| Artigo publicado | Ainda não tinha esse artigo | article.published |
| Artigo publicado (edição) | Já tinha o artigo publicado | article.updated |
| Artigo arquivado ou volta para rascunho | Tinha o artigo publicado | article.archived (o payload leva o status real) |
| Artigo arquivado ou rascunho | Nunca chegou a receber o artigo | nenhum evento (nada a derrubar) |
| Artigo excluído aqui | Tinha o artigo publicado | article.deleted |
| Artigo excluído aqui | Nunca recebeu esse artigo | nenhum evento |
Rascunho nunca é enviado como novidade. Mas se um artigo que já está no seu sistema volta para rascunho por aqui, você recebe um article.archived avisando, porque do contrário as duas pontas ficariam divergentes em silêncio.
Se você desligar um evento na configuração (por exemplo, desligar "publicado"), esse artigo específico nunca dispara aquele evento, e como consequência os eventos seguintes dele também ficam presos: sem article.published, o article.updated daquele mesmo artigo nunca vai sair, porque para o sistema o artigo nunca chegou a ser publicado do seu lado.
Além dos quatro eventos de artigo, existem dois auxiliares: ping (o teste de conexão, sem article no corpo) e article.test (o "Enviar artigo de teste" do painel, com test: true e a fixture de exemplo).
O que devolver
Basta responder com um status 2xx. Redirecionamentos (3xx) são tratados como falha: a URL configurada precisa responder diretamente, sem redirecionar para outro endereço. O corpo é opcional; se você quiser, devolva um JSON com { "id": "...", "url": "..." } (ou { "data": { "id": "...", "url": "..." } }), e a ClickHunt guarda esse id como o identificador do artigo no seu sistema e essa url como o link público dele. Qualquer outro formato de resposta é ignorado, sem erro.
Fora da faixa 2xx, o que sai muda o que a ClickHunt registra:
| Situação | Código interno |
|---|---|
| Timeout, DNS, falha de conexão | network |
| URL barrada por segurança (IP privado, esquema inválido) | blocked_url |
| 401, 403 ou outro 4xx | endpoint_rejected |
| 404 | not_found |
| 410 | endpoint_gone |
| 429 | rate_limited |
| 5xx | endpoint_error |
O prazo de resposta é de 15 segundos. Se o seu endpoint só enfileira a requisição (por exemplo, um serverless atrás de uma fila), responda 2xx assim que enfileirar, sem esperar o processamento terminar.
Retentativas
Uma entrega que falhar é tentada de novo, com um intervalo que cresce a cada tentativa: 5 minutos depois da 1ª falha, 15 minutos depois da 2ª, 1 hora depois da 3ª, 6 horas depois da 4ª. São 5 tentativas ao todo (a original mais 4 retentativas), o que leva cerca de 7 horas e 20 minutos entre a 1ª e a 5ª falha. Na 5ª falha, a ClickHunt para de tentar sozinha, sem chegar ao quinto degrau do backoff que outras plataformas continuam usando.
Depois de parar, o artigo fica com o estado de falha visível na tela, e só volta a tentar quando você clicar em Reenviar, seja na tela do artigo ou na aba Histórico.
Histórico
A aba Histórico do painel do webhook mostra as últimas 50 entregas: evento, artigo, código HTTP, duração, número da tentativa e data. Clicar numa linha expande a resposta que o seu endpoint devolveu.
Cada linha tem um botão de Reenviar, quando faz sentido: para article.published, article.updated e article.archived, ele limpa o erro e enfileira o artigo de novo; para ping, ele testa a conexão de novo; para article.test, ele reenvia o mesmo artigo de exemplo. article.deleted não tem botão de reenvio.
O histórico é mantido por 30 dias; entradas mais antigas são removidas automaticamente.
Perguntas frequentes
Um artigo em rascunho é enviado? Não. Rascunho nunca sai pelo webhook. Só publicado, atualizado, arquivado e excluído disparam entrega, e mesmo assim só os eventos que você deixou marcados.
Como testo num ambiente local, antes de subir meu endpoint? Use um túnel como o ngrok (ou similar) para expor a sua máquina numa URL https pública temporária, cole essa URL na configuração do webhook e use o botão "Enviar artigo de teste" para validar a assinatura e o formato sem publicar nada de verdade.
Se eu conectar o webhook hoje, os artigos que já publiquei antes são reenviados? Não. O webhook só entrega artigos novos a partir do momento em que ele é conectado. Não existe reenvio automático do que já estava publicado no destino anterior (blog nativo ou outra plataforma).