Skip to content

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

  1. Create the route on your server

    A route that accepts POST with JSON, like https://mysite.com/qorum/webhook. It must be public and answer within 10 seconds.

  2. Enter the address in the dashboard

    In Settings → Webhook, or with PUT /v1/site/settings and {"webhookUrl": "..."}.

  3. Get the secret

    In Settings → Keys, the "Webhook secret" (wh_...). Keep it on the server, like the secret key.

  4. 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", ... }
HeaderWhat it is
X-Qorum-EventThe kind of notice, same as the event field.
X-Qorum-DeliveryThe notice's number. If the same number arrives twice (on a retry), ignore the second one.
X-Qorum-TimestampWhen it was sent, in seconds since 1970.
X-Qorum-SignatureThe 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:

  1. 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.

  2. Compute the HMAC

    HMAC-SHA256 with the webhook secret, over timestamp + "." + body. Write it in hexadecimal and put sha256= in front.

  3. Compare

    With the X-Qorum-Signature header, using a constant-time comparison.

  4. Check the time

    Refuse it if X-Qorum-Timestamp is 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);
});

Answers, delays and retries

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