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

EventFires when
comment.createdA new comment is submitted
comment.updatedA comment's content is edited
comment.approvedA moderator approves a comment
comment.deletedA comment is deleted
📘

There is no comment.refused event

When a moderator refuses a comment, the delivery is a comment.deleted event — refusal and deletion are the same outcome from your system's point of view (the comment's public status is deleted in 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_id is your site's internal ID

These 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:

HeaderContent
X-Webhook-SignatureHMAC-SHA256, hex-encoded (64 chars), of the string "<timestamp>.<raw body>", keyed with your webhook secret
X-Webhook-TimestampEpoch in milliseconds — the timestamp the signature was computed with
X-Webhook-Delivery-IdUnique delivery ID (your idempotency key)
X-Webhook-Retry-CountNumber of attempts already made (0 = first)
X-Webhook-First-Sent-AtISO 8601 date of the very first attempt
User-AgentGraphComment-Webhook/1.0

Verification, in four steps:

  1. Read the raw request body — before any JSON parsing.
  2. Read X-Webhook-Timestamp and check it is within ±5 minutes (anti-replay).
  3. Compute HMAC-SHA256(secret, timestamp + "." + rawBody).
  4. Compare with X-Webhook-Signature in constant time (timingSafeEqual, hash_equals).

Sign the timestamp AND the body

The 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:

AttemptDelay
1stImmediately
2ndAfter 1 minute
3rdAfter 5 minutes
4thAfter 15 minutes
5thAfter 1 hour
6thAfter 6 hours
  • Retried: network errors and timeouts, 5xx responses, 429.
  • Not retried: "logical" 4xx responses (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 so

Under load, answer 429 with a Retry-After header 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 30x counts 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 setup call? Re-running setup regenerates 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 200 with an error body { "gcCode": 410, ... } instead of a 401. Always check the response body for a gcCode field.

Events seem to be missing.

  • Check the journal for failed deliveries, fix the receiving side, then replay them.
  • Duplicate handling: keep the X-Webhook-Delivery-Id of 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-Onauthor.external_id in payloads carries your SSO user IDs.

Did this page help you?