Webhooks
Avisos que o Qorum manda para o seu servidor quando algo acontece nos comentários. Use para dar pontos a quem comenta, notificar respostas no seu site, avisar a equipe sobre denúncias ou guardar um registro.
Configurar
- Crie a rota no seu servidor
Uma rota que aceita
POSTcom JSON, comohttps://meusite.com/qorum/webhook. Ela precisa ser pública e responder em até 10 segundos. - Informe o endereço no painel
Em Configurações → Webhook, ou com
PUT /v1/site/settingse{"webhookUrl": "..."}. - Pegue o segredo
Em Configurações → Chaves, o "Segredo do webhook" (
wh_...). Guarde no servidor, como a chave secreta. - Teste
Em Testar conexão → Webhook, clique em Enviar ping. O resultado mostra o código que o seu servidor respondeu e quanto tempo levou.
Como chega
POST /qorum/webhook
Content-Type: application/json
User-Agent: Qorum-Webhook/1
X-Qorum-Event: comment.created
X-Qorum-Delivery: 1532
X-Qorum-Timestamp: 1794171698
X-Qorum-Signature: sha256=5d41402abc4b2a76b9719d911017c592...
{ "event": "comment.created", "site": "meu-blog", "sentAt": "2026-11-08T21:00:00Z", ... }
| Cabeçalho | O que é |
|---|---|
X-Qorum-Event | O tipo do aviso, o mesmo do campo event. |
X-Qorum-Delivery | O número do aviso. Se o mesmo número chegar duas vezes (num reenvio), ignore o segundo. |
X-Qorum-Timestamp | Quando foi enviado, em segundos desde 1970. |
X-Qorum-Signature | A assinatura, para você conferir que veio do Qorum. Veja abaixo. |
Eventos
comment.created
Um comentário passou a aparecer: na hora em que foi escrito, ou quando foi aprovado na fila.
{
"event": "comment.created", "site": "meu-blog", "sentAt": "2026-11-08T21:00:00Z",
"comment": { "id": 82, "parentId": 81, "content": "Concordo!", "html": "Concordo!", "images": null, "createdAt": "..." }, // images: lista quando tem imagem
"thread": { "ref": "post-42", "title": "Meu post", "url": "https://meusite.com/post-42" },
"userId": "123", // quem escreveu (id no seu site, ou q:N)
"isReply": true,
"replyToUserId": "77", // só em respostas: quem foi respondido
"replyToCommentId": 81, // só em respostas: o comentário que foi respondido
"notifyUserId": "77", // quem você deve notificar. Nunca é o próprio autor.
"preview": "Concordo!" // até 100 caracteres
}
notification.created
Alguém recebeu uma resposta, foi marcado com @ ou tem comentário novo numa conversa que segue (veja o campo type: reply, mention ou follow). Chega junto com o comment.created, com a notificação pronta para mostrar, no mesmo formato da API. Veja Notificações.
{ "event": "notification.created", "site": "meu-blog", "sentAt": "...",
"notification": { "id": 318, "type": "reply", "userId": "77", "actor": { ... }, "comment": { ... },
"inReplyTo": { "id": 81, "preview": "..." }, "thread": { ... }, "link": "https://...#qorum-82" } }
comment.deleted
Um comentário publicado foi apagado. Comentários recusados na fila não geram este aviso, porque nunca chegaram a aparecer.
{ "event": "comment.deleted", "site": "meu-blog", "sentAt": "...",
"commentId": 81, "parentId": null, "userId": "123",
"byModerator": true, "moderatorId": "q:5",
"removedReplies": 2 } // respostas apagadas junto
comment.restored
Um comentário apagado voltou (pelo painel ou pela API de gerência). Se você desconta algo no comment.deleted, devolva aqui.
{ "event": "comment.restored", "site": "meu-blog", "sentAt": "...",
"commentId": 81, "parentId": null, "userId": "123",
"restoredReplies": 2 } // respostas que voltaram junto
comment.reported
{ "event": "comment.reported", "site": "meu-blog", "sentAt": "...",
"reportId": 5, "commentId": 81, "reason": "Spam ou propaganda",
"reporterId": "77", "authorId": "123", "preview": "texto denunciado", "threadRef": "post-42" }
ping
Só o botão de teste manda este. Responda 200 e pronto.
{ "event": "ping", "site": "meu-blog", "sentAt": "..." }
Conferir a assinatura
Qualquer um pode mandar um POST para a sua rota. A assinatura prova que o aviso veio do Qorum e não foi alterado:
- Leia o corpo cru
Exatamente os bytes recebidos, antes de converter o JSON. Converter e montar de novo muda os bytes e a assinatura não bate.
- Calcule o HMAC
HMAC-SHA256 com o segredo do webhook, sobre
timestamp + "." + corpo. Escreva em hexadecimal e coloquesha256=na frente. - Compare
Com o cabeçalho
X-Qorum-Signature, usando comparação de tempo constante. - Confira a hora
Recuse se o
X-Qorum-Timestamptiver mais de 5 minutos de diferença do seu relógio. Isso impede que alguém reenvie um aviso antigo.
const crypto = require('crypto');
app.post('/qorum/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const ts = req.get('X-Qorum-Timestamp') || '';
const esperado = 'sha256=' + crypto.createHmac('sha256', process.env.QORUM_WEBHOOK_SECRET)
.update(ts + '.').update(req.body).digest('hex');
const recebido = req.get('X-Qorum-Signature') || '';
const ok = recebido.length === esperado.length &&
crypto.timingSafeEqual(Buffer.from(recebido), Buffer.from(esperado)) &&
Math.abs(Date.now() / 1000 - Number(ts)) <= 300;
if (!ok) return res.sendStatus(401);
const aviso = JSON.parse(req.body);
res.sendStatus(200); // responda logo e processe depois
processar(aviso).catch(console.error);
});
<?php
$corpo = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_QORUM_TIMESTAMP'] ?? '';
$esperado = 'sha256=' . hash_hmac('sha256', $ts . '.' . $corpo, getenv('QORUM_WEBHOOK_SECRET'));
$recebido = $_SERVER['HTTP_X_QORUM_SIGNATURE'] ?? '';
if (!hash_equals($esperado, $recebido) || abs(time() - (int) $ts) > 300) {
http_response_code(401);
exit;
}
$aviso = json_decode($corpo, true);
http_response_code(200);
// processe $aviso aqui (ou coloque numa fila)
import hmac, hashlib, os, time
from flask import request
SEGREDO = os.environ["QORUM_WEBHOOK_SECRET"].encode()
@app.post("/qorum/webhook")
def qorum_webhook():
corpo = request.get_data() # bytes crus
ts = request.headers.get("X-Qorum-Timestamp", "")
esperado = "sha256=" + hmac.new(SEGREDO, ts.encode() + b"." + corpo, hashlib.sha256).hexdigest()
recebido = request.headers.get("X-Qorum-Signature", "")
if not hmac.compare_digest(esperado, recebido) or not ts.isdigit() or abs(time.time() - int(ts)) > 300:
return "", 401
aviso = request.get_json()
# processe aviso aqui (ou coloque numa fila)
return "", 200
func qorumWebhook(w http.ResponseWriter, r *http.Request) {
corpo, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
ts := r.Header.Get("X-Qorum-Timestamp")
m := hmac.New(sha256.New, []byte(os.Getenv("QORUM_WEBHOOK_SECRET")))
m.Write([]byte(ts + "."))
m.Write(corpo)
esperado := "sha256=" + hex.EncodeToString(m.Sum(nil))
n, _ := strconv.ParseInt(ts, 10, 64)
if !hmac.Equal([]byte(esperado), []byte(r.Header.Get("X-Qorum-Signature"))) || math.Abs(float64(time.Now().Unix()-n)) > 300 {
w.WriteHeader(http.StatusUnauthorized)
return
}
var aviso map[string]any
_ = json.Unmarshal(corpo, &aviso)
w.WriteHeader(http.StatusOK)
go processar(aviso)
}
Respostas, demora e reenvio
- Responda qualquer código
2xxpara dizer que recebeu. Qualquer outro código, ou mais de 10 segundos sem resposta, conta como falha. - Em caso de falha, o Qorum tenta de novo depois de 30 segundos, 2 minutos, 10 minutos, 30 minutos, 1 hora, 3 horas, 6 horas, 12 horas e 24 horas, até 12 tentativas no total. Depois disso, desiste daquele aviso.
- Os avisos ficam guardados antes de serem enviados, então nada se perde se o Qorum reiniciar.
- A ordem de chegada não é garantida: use
sentAte os ids se precisar ordenar. - O painel mostra, em Visão geral, quantos avisos estão na fila e quantos falharam nos últimos 7 dias.
Exemplo: pontos e notificação
async function processar(aviso) {
if (await jaProcessado(aviso)) return; // guarde o X-Qorum-Delivery ou o id do comentário
if (aviso.event === 'comment.created') {
await darPontos(aviso.userId, 5); // quem comentou ganha pontos no seu site
if (aviso.notifyUserId) {
await notificar(aviso.notifyUserId, `Responderam você em "${aviso.thread.title}"`,
aviso.thread.url + '#qorum-' + aviso.comment.id);
}
}
if (aviso.event === 'comment.deleted') {
await tirarPontos(aviso.userId, 5);
}
if (aviso.event === 'comment.reported') {
await avisarEquipe(`Denúncia: ${aviso.reason} em ${aviso.threadRef}`);
}
}
Lembre: userId é o id que o seu site mandou no login. Para contas Qorum, começa com q: e não existe no seu banco.
Quando não chega
- Use Enviar ping no painel: ele mostra o código ou o erro exato da sua rota.
401ou403no ping: a conferência da assinatura está recusando. Veja se está usando o corpo cru e o segredo atual (ele muda quando você troca as chaves).- Erro de conexão: a rota precisa ser acessível pela internet, com certificado HTTPS válido. Firewall e proteção contra robôs podem bloquear o Qorum: libere o User-Agent
Qorum-Webhook/1. - Demorando: responda primeiro e processe depois. O limite é de 10 segundos.