Webhooks
Get an HTTP call on every comment event — signed deliveries, automatic retries, a delivery journal and a replay button.
GraphComment can notify your server of comment activity as it happens: each event is delivered as a signed HTTP POST to an endpoint you control. Feed your own database, trigger a custom moderation pipeline, plug comments into your analytics — anything that starts with "when a comment happens, tell my system".
One webhook endpoint per site. Deliveries are signed (HMAC-SHA256), retried automatically on failure, and every attempt is kept in a delivery journal you can inspect and replay.
The four events
| Event | Fires when |
|---|---|
comment.created | A new comment is submitted |
comment.updated | A comment's content is edited |
comment.approved | A moderator approves a comment |
comment.deleted | A comment is deleted |
There is nocomment.refusedeventWhen a moderator refuses a comment, the delivery is a
comment.deletedevent — refusal and deletion are the same outcome from your system's point of view (the comment's publicstatusisdeletedin both cases).
Set up your endpoint
Your receiving endpoint must be a public HTTPS URL that answers directly — redirections (30x) are treated as failures, so point at the final URL.
Configure the webhook with POST /webhook/website/{website_id}/setup, passing your URL and the events you want:
{
"url": "https://your-site.com/webhooks/graphcomment",
"events": ["comment.created", "comment.updated", "comment.approved", "comment.deleted"]
}The response contains your webhook configuration, including a server-generated secret — you don't choose it, GraphComment generates it for you. Store it: it is the key you'll verify every delivery with. You can read it again later via GET /webhook/website/{website_id}/config.
Calling setup again replaces the existing configuration (a site has one webhook endpoint). DELETE /webhook/website/{website_id}/disable turns it off.
Route parameters, authentication options (account token or server-to-server site-key signature) and response schemas are documented in the API Reference.
website_idis your site's internal IDThese routes take your site's internal identifier — not your shortname. It is the 24-hexadecimal segment in your back-office URL while you manage the site (
…/website/<website_id>/…). Don't swap one for the other.
Verify every delivery
Each delivery carries these headers:
| Header | Content |
|---|---|
X-Webhook-Signature | HMAC-SHA256, hex-encoded (64 chars), of the string "<timestamp>.<raw body>", keyed with your webhook secret |
X-Webhook-Timestamp | Epoch in milliseconds — the timestamp the signature was computed with |
X-Webhook-Delivery-Id | Unique delivery ID (your idempotency key) |
X-Webhook-Retry-Count | Number of attempts already made (0 = first) |
X-Webhook-First-Sent-At | ISO 8601 date of the very first attempt |
User-Agent | GraphComment-Webhook/1.0 |
Verification, in four steps:
- Read the raw request body — before any JSON parsing.
- Read
X-Webhook-Timestampand check it is within ±5 minutes (anti-replay). - Compute
HMAC-SHA256(secret, timestamp + "." + rawBody). - Compare with
X-Webhook-Signaturein constant time (timingSafeEqual,hash_equals).
Sign the timestamp AND the bodyThe signature covers
timestamp + "." + rawBody— not the body alone. Skipping the timestamp prefix is the single most common cause of "invalid signature". The second most common: verifying against a re-serialized body instead of the exact bytes received.
Node.js (Express)
const express = require('express');
const bodyParser = require('body-parser');
const crypto = require('node:crypto');
const app = express();
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET; // from the setup response
// Capture the raw body on your webhook route
function rawSaver(req, res, buf) { req.rawBody = Buffer.from(buf); }
app.use('/webhooks/graphcomment', bodyParser.json({ verify: rawSaver }));
app.post('/webhooks/graphcomment', (req, res) => {
const sigHex = req.get('X-Webhook-Signature');
const tsHdr = req.get('X-Webhook-Timestamp');
if (!sigHex || !tsHdr) return res.status(401).send('Missing headers');
// Anti-replay window (±5 min)
const ts = Number(tsHdr);
if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > 5 * 60 * 1000) {
return res.status(401).send('Timestamp invalid or expired');
}
// HMAC(secret, "timestamp.rawBody"), constant-time compare
const message = Buffer.concat([Buffer.from(String(ts)), Buffer.from('.'), req.rawBody]);
const expected = crypto.createHmac('sha256', WEBHOOK_SECRET).update(message).digest();
const provided = Buffer.from(sigHex, 'hex');
if (expected.length !== provided.length ||
!crypto.timingSafeEqual(expected, provided)) {
return res.status(401).send('Invalid signature');
}
// Optional idempotency via X-Webhook-Delivery-Id
// if (alreadySeen(req.get('X-Webhook-Delivery-Id'))) return res.status(200).send('OK');
const evt = req.body;
switch (evt.event_type) {
case 'comment.created': /* enqueue a job — reply fast */ break;
case 'comment.updated': break;
case 'comment.approved': break;
case 'comment.deleted': break;
}
return res.status(200).send('OK');
});
app.listen(3000);PHP
<?php
$secret = getenv('WEBHOOK_SECRET'); // from the setup response
$payload = file_get_contents('php://input'); // raw body, before any parsing
$sigHex = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$tsHeader = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
if (!$sigHex || !$tsHeader) {
http_response_code(401); echo 'Missing headers'; exit;
}
// Anti-replay window (±5 min)
$ts = intval($tsHeader);
if ($ts <= 0 || abs((int)(microtime(true) * 1000) - $ts) > 5 * 60 * 1000) {
http_response_code(401); echo 'Timestamp invalid or expired'; exit;
}
// HMAC(secret, "timestamp.rawBody"), constant-time compare
$expected = hash_hmac('sha256', $ts . '.' . $payload, $secret);
if (!hash_equals($expected, $sigHex)) {
http_response_code(401); echo 'Invalid signature'; exit;
}
$data = json_decode($payload, true);
switch ($data['event_type'] ?? '') {
case 'comment.created': /* enqueue a job — reply fast */ break;
default: break;
}
http_response_code(200);
echo 'OK';The payload
{
"event_type": "comment.created",
"timestamp": 1753271727235,
"website_id": "5f7b1e5a2d3a4c0012345678",
"data": {
"comment": {
"id": "60a2e5c31d7b3c0015abcdef",
"external_id": "ext-123",
"content": "The comment text",
"created_at": "2026-07-23T14:35:27.235Z",
"updated_at": "2026-07-23T14:35:27.235Z",
"thread_id": "60a2e5c31d7b3c0015fedcba",
"external_thread_id": "ext-thread-456",
"parent_id": null,
"external_parent_id": null,
"status": "approved",
"author": {
"id": "5f7b1e5a2d3a4c0012987654",
"external_id": null,
"guest": false,
"name": "John Doe",
"email": "[email protected]",
"avatar": "https://graphcomment.com/assets/avatar.png"
}
},
"thread": {
"id": "60a2e5c31d7b3c0015fedcba",
"page_title": "The page title",
"created_at": "2026-07-23T14:35:27.235Z",
"updated_at": "2026-07-23T14:35:27.235Z",
"external_id": "ext-thread-456"
}
}
}The root timestamp is an integer in epoch milliseconds; dates inside data.* are ISO 8601. For SSO users, author.external_id carries the id you provided through Single Sign-On.
Retries and the delivery journal
Answer with a 2xx quickly — verify the signature, enqueue the work, respond. Anything else triggers the retry machinery:
| Attempt | Delay |
|---|---|
| 1st | Immediately |
| 2nd | After 1 minute |
| 3rd | After 5 minutes |
| 4th | After 15 minutes |
| 5th | After 1 hour |
| 6th | After 6 hours |
- Retried: network errors and timeouts,
5xxresponses,429. - Not retried: "logical"
4xxresponses (400,401,403,404,410,422…) — the delivery is marked failed. - If your server returns
Retry-After(seconds or HTTP date), GraphComment honors it, rounded up to the next retry slot.
Overloaded? Say soUnder load, answer
429with aRetry-Afterheader instead of timing out — GraphComment backs off and comes back when you asked it to.
Every attempt is recorded: GET /webhook/website/{website_id}/calls lists recent deliveries with their status, response code and retry count. A failed delivery can be replayed (POST /webhook/call/{id}/replay) once your endpoint is fixed — the replay is a fresh delivery with a new X-Webhook-Delivery-Id.
Check your signature code
Before wiring the real thing, you can validate your signature computation against your site's actual secret: POST /webhook/website/{website_id}/verify takes the same X-Webhook-Signature / X-Webhook-Timestamp headers and body as a real delivery, and tells you whether your signature matches what GraphComment would compute. It never calls your endpoint — it answers one question: "did I implement the formula correctly?"
Troubleshooting
Nothing arrives.
- Is the URL reachable in HTTPS from the public internet (firewall, WAF, valid certificate)?
- Any redirection? A
30xcounts as a failure — configure the final URL. - Check the delivery journal (
GET .../calls): if attempts are there, GraphComment is sending — the problem is on the receiving path.
"Invalid signature".
- Sign
timestamp + "." + rawBody— not the body alone. - Verify against the exact bytes received: any middleware that parses and re-serializes JSON before your verification breaks the signature (in Express, use
verify/raw-body capture as in the snippet). - Is the secret the one from the latest
setupcall? Re-runningsetupregenerates it. - Check the ±5 minute window on
X-Webhook-Timestamp(server clock drift counts).
Configuration calls seem to succeed but nothing is configured.
- On unauthenticated calls, the webhook configuration routes can answer
200with an error body{ "gcCode": 410, ... }instead of a401. Always check the response body for agcCodefield.
Events seem to be missing.
- Check the journal for
faileddeliveries, fix the receiving side, then replay them. - Duplicate handling: keep the
X-Webhook-Delivery-Idof processed deliveries (a short TTL is enough) and skip IDs you've already seen.
Next steps
- Getting Started — put the widget on your pages first.
- API Reference — full request/response schemas for the webhook configuration routes.
- Single Sign-On —
author.external_idin payloads carries your SSO user IDs.
Updated 19 days ago
