Webhooks
Notices Qorum sends to your server when something happens in the comments. Use them to award points to commenters, notify replies on your site, alert your team about reports or keep a record.
Set up
- Create the route on your server
A route that accepts
POSTwith JSON, likehttps://mysite.com/qorum/webhook. It must be public and answer within 10 seconds. - Enter the address in the dashboard
In Settings → Webhook, or with
PUT /v1/site/settingsand{"webhookUrl": "..."}. - Get the secret
In Settings → Keys, the "Webhook secret" (
wh_...). Keep it on the server, like the secret key. - Test
In Test connection → Webhook, click Send ping. The result shows the code your server answered and how long it took.
How it arrives
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": "my-blog", "sentAt": "2026-11-08T21:00:00Z", ... }
| Header | What it is |
|---|---|
X-Qorum-Event | The kind of notice, same as the event field. |
X-Qorum-Delivery | The notice's number. If the same number arrives twice (on a retry), ignore the second one. |
X-Qorum-Timestamp | When it was sent, in seconds since 1970. |
X-Qorum-Signature | The signature, so you can check it came from Qorum. See below. |
Events
comment.created
A comment started showing up: the moment it was written, or when it was approved from the queue.
{
"event": "comment.created", "site": "my-blog", "sentAt": "2026-11-08T21:00:00Z",
"comment": { "id": 82, "parentId": 81, "content": "Agreed!", "html": "Agreed!", "images": null, "createdAt": "..." }, // images: a list when there are any
"thread": { "ref": "post-42", "title": "My post", "url": "https://mysite.com/post-42" },
"userId": "123", // who wrote it (id on your site, or q:N)
"isReply": true,
"replyToUserId": "77", // replies only: who was answered
"replyToCommentId": 81, // replies only: the comment that was answered
"notifyUserId": "77", // who you should notify. Never the author themselves.
"preview": "Agreed!" // up to 100 characters
}
notification.created
Someone got a reply, was tagged with @ or has a new comment on a conversation they follow (see the type field: reply, mention or follow). It arrives together with the comment.created, with the notification ready to show, in the same format as the API. See Notifications.
{ "event": "notification.created", "site": "my-blog", "sentAt": "...",
"notification": { "id": 318, "type": "reply", "userId": "77", "actor": { ... }, "comment": { ... },
"inReplyTo": { "id": 81, "preview": "..." }, "thread": { ... }, "link": "https://...#qorum-82" } }
comment.deleted
A published comment was deleted. Comments rejected from the queue don't create this notice, because they never showed up.
{ "event": "comment.deleted", "site": "my-blog", "sentAt": "...",
"commentId": 81, "parentId": null, "userId": "123",
"byModerator": true, "moderatorId": "q:5",
"removedReplies": 2 } // replies deleted along with it
comment.restored
A deleted comment came back (from the dashboard or the management API). If you take something away on comment.deleted, give it back here.
{ "event": "comment.restored", "site": "my-blog", "sentAt": "...",
"commentId": 81, "parentId": null, "userId": "123",
"restoredReplies": 2 } // replies that came back with it
comment.reported
{ "event": "comment.reported", "site": "my-blog", "sentAt": "...",
"reportId": 5, "commentId": 81, "reason": "Spam or advertising",
"reporterId": "77", "authorId": "123", "preview": "reported text", "threadRef": "post-42" }
ping
Only the test button sends this one. Answer 200 and you're done.
{ "event": "ping", "site": "my-blog", "sentAt": "..." }
Checking the signature
Anyone can send a POST to your route. The signature proves the notice came from Qorum and wasn't changed:
- Read the raw body
Exactly the bytes received, before parsing the JSON. Parsing and rebuilding it changes the bytes and the signature won't match.
- Compute the HMAC
HMAC-SHA256 with the webhook secret, over
timestamp + "." + body. Write it in hexadecimal and putsha256=in front. - Compare
With the
X-Qorum-Signatureheader, using a constant-time comparison. - Check the time
Refuse it if
X-Qorum-Timestampis more than 5 minutes off from your clock. This stops anyone from replaying an old notice.
const crypto = require('crypto');
app.post('/qorum/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const ts = req.get('X-Qorum-Timestamp') || '';
const expected = 'sha256=' + crypto.createHmac('sha256', process.env.QORUM_WEBHOOK_SECRET)
.update(ts + '.').update(req.body).digest('hex');
const received = req.get('X-Qorum-Signature') || '';
const ok = received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected)) &&
Math.abs(Date.now() / 1000 - Number(ts)) <= 300;
if (!ok) return res.sendStatus(401);
const notice = JSON.parse(req.body);
res.sendStatus(200); // answer right away and process later
handle(notice).catch(console.error);
});
<?php
$body = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_QORUM_TIMESTAMP'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $body, getenv('QORUM_WEBHOOK_SECRET'));
$received = $_SERVER['HTTP_X_QORUM_SIGNATURE'] ?? '';
if (!hash_equals($expected, $received) || abs(time() - (int) $ts) > 300) {
http_response_code(401);
exit;
}
$notice = json_decode($body, true);
http_response_code(200);
// handle $notice here (or put it in a queue)
import hmac, hashlib, os, time
from flask import request
SECRET = os.environ["QORUM_WEBHOOK_SECRET"].encode()
@app.post("/qorum/webhook")
def qorum_webhook():
body = request.get_data() # raw bytes
ts = request.headers.get("X-Qorum-Timestamp", "")
expected = "sha256=" + hmac.new(SECRET, ts.encode() + b"." + body, hashlib.sha256).hexdigest()
received = request.headers.get("X-Qorum-Signature", "")
if not hmac.compare_digest(expected, received) or not ts.isdigit() or abs(time.time() - int(ts)) > 300:
return "", 401
notice = request.get_json()
# handle notice here (or put it in a queue)
return "", 200
func qorumWebhook(w http.ResponseWriter, r *http.Request) {
body, 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(body)
expected := "sha256=" + hex.EncodeToString(m.Sum(nil))
n, _ := strconv.ParseInt(ts, 10, 64)
if !hmac.Equal([]byte(expected), []byte(r.Header.Get("X-Qorum-Signature"))) || math.Abs(float64(time.Now().Unix()-n)) > 300 {
w.WriteHeader(http.StatusUnauthorized)
return
}
var notice map[string]any
_ = json.Unmarshal(body, ¬ice)
w.WriteHeader(http.StatusOK)
go handle(notice)
}
Answers, delays and retries
- Answer any
2xxcode to say you got it. Any other code, or more than 10 seconds without an answer, counts as a failure. - On failure, Qorum tries again after 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, 3 hours, 6 hours, 12 hours and 24 hours, up to 12 attempts in total. After that, it gives up on that notice.
- Notices are stored before being sent, so nothing is lost if Qorum restarts.
- The order of arrival isn't guaranteed: use
sentAtand the ids if you need to sort. - The dashboard shows, in Overview, how many notices are in the queue and how many failed in the last 7 days.
Example: points and notification
async function handle(notice) {
if (await alreadyHandled(notice)) return; // store X-Qorum-Delivery or the comment id
if (notice.event === 'comment.created') {
await givePoints(notice.userId, 5); // the commenter earns points on your site
if (notice.notifyUserId) {
await notify(notice.notifyUserId, `Someone replied to you on "${notice.thread.title}"`,
notice.thread.url + '#qorum-' + notice.comment.id);
}
}
if (notice.event === 'comment.deleted') {
await takePoints(notice.userId, 5);
}
if (notice.event === 'comment.reported') {
await alertTeam(`Report: ${notice.reason} on ${notice.threadRef}`);
}
}
Remember: userId is the id your site sent at login. For Qorum accounts, it starts with q: and doesn't exist in your database.
When it doesn't arrive
- Use Send ping in the dashboard: it shows the exact code or error from your route.
401or403on the ping: the signature check is refusing it. Make sure you're using the raw body and the current secret (it changes when you rotate the keys).- Connection error: the route must be reachable from the internet, with a valid HTTPS certificate. Firewalls and bot protection can block Qorum: allow the User-Agent
Qorum-Webhook/1. - Taking too long: answer first and process later. The limit is 10 seconds.