---
updatedAt: 2026-07-31T12:15:31.000Z
agentTools:
  projectIndex: https://developers.graphcomment.com/llms.txt
---

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

```json
{
  "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:

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

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)

```javascript
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
<?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

```json
{
  "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":       "john.doe@example.com",
        "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](./authentication-sso).

## 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, `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](./getting-started)** — put the widget on your pages first.
* **API Reference** — full request/response schemas for the webhook configuration routes.
* **[Single Sign-On](./authentication-sso)** — `author.external_id` in payloads carries your SSO user IDs.