Skip to content
Developer docs Signing and retries

Webhooks

Webhooks send CommentGate events to your own HTTPS URL within seconds. Add one in Settings, then Alerts, and pick its events, or subscribe from code with REST hooks. Both are signed, retried and deduplicated the same way.

Updated September 26, 2026

The request

Every delivery is a POST with a JSON body and these headers:

POST /your/endpoint HTTP/1.1
content-type: application/json
user-agent: CommentGate-Webhooks/1
x-commentgate-timestamp: 1790128208
x-commentgate-signature-v2: v2=9a41c3e07b...
x-commentgate-signature: sha256=5d7c0e1f9a...
x-commentgate-delivery: 7f0c7a2e-5a1e-4a0c-9d6e-0f7c2b1e9a11
HeaderMeaning
x-commentgate-timestampWhen it was sent, in Unix seconds
x-commentgate-signature-v2v2= and the hex HMAC-SHA256 of <timestamp>.<raw body>, keyed with the signing secret. Verify this one
x-commentgate-signatureDeprecated: sha256= and the hex HMAC-SHA256 of the raw body alone. It does not cover the timestamp, so it cannot stop a replay. Sent for one more release
x-commentgate-deliveryThe delivery id, the same as id in the body. A retry reuses it

The body always has id, event, created_at, workspace_id and url (where to look in the app), plus the event's own fields. Every event and its body are in the event catalogue. A test sent from Settings has "test": true.

Verifying signatures

  • Join the x-commentgate-timestamp header, a dot and the raw request body: 1790128208.{"id":...}.
  • Compute the HMAC-SHA256 of that string with the webhook's signing secret (shown next to the webhook in Settings, or once in the REST hook subscribe response), prefix v2=, and compare it with x-commentgate-signature-v2 in constant time.
  • Reject any delivery whose timestamp is more than 5 minutes old. Because the timestamp is signed, a captured delivery cannot be sent again later with a new one.

Still checking x-commentgate-signature? It keeps arriving for one more release. Switch to the samples below before then: it signs the body alone, so it cannot tell a replay from a fresh delivery.

Node.js (verify.js)
import { createHmac, timingSafeEqual } from 'node:crypto';

const MAX_AGE_SECONDS = 5 * 60; // reject deliveries older than 5 minutes

// rawBody: the exact bytes you received (a Buffer or string), before JSON.parse.
// headers: the request headers, with lowercase names.
export function verifyCommentGate(rawBody, headers, secret) {
  const timestamp = String(headers['x-commentgate-timestamp'] ?? '');
  const signature = String(headers['x-commentgate-signature-v2'] ?? '');
  if (!/^\d+$/.test(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > MAX_AGE_SECONDS) return false;

  // The signature covers "<timestamp>.<raw body>", so an old delivery
  // cannot be replayed with a new timestamp.
  const expected =
    'v2=' + createHmac('sha256', secret).update(timestamp + '.').update(rawBody).digest('hex');
  return (
    signature.length === expected.length &&
    timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
  );
}
An Express receiver
import express from 'express';
import { verifyCommentGate } from './verify.js';

const app = express();
const seen = new Set(); // use your database in production

// express.raw keeps the body as bytes, so the signature can be checked
app.post('/commentgate', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verifyCommentGate(req.body, req.headers, process.env.COMMENTGATE_SIGNING_SECRET)) {
    return res.sendStatus(401);
  }
  const delivery = JSON.parse(req.body.toString('utf8'));
  if (seen.has(delivery.id)) return res.sendStatus(200); // a retry: already handled
  seen.add(delivery.id);

  // Answer fast, then do the work
  res.sendStatus(200);
  console.log(delivery.event, delivery.comment?.text);
});

app.listen(3000);
Python
import hashlib, hmac, time

MAX_AGE_SECONDS = 5 * 60  # reject deliveries older than 5 minutes

def verify_commentgate(raw_body: bytes, headers, secret: str) -> bool:
    timestamp = headers.get("x-commentgate-timestamp", "")
    signature = headers.get("x-commentgate-signature-v2", "")
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > MAX_AGE_SECONDS:
        return False
    # The signature covers "<timestamp>.<raw body>", so an old delivery
    # cannot be replayed with a new timestamp.
    signed = timestamp.encode() + b"." + raw_body
    expected = "v2=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature, expected)

Keep the raw body

The signature covers the exact bytes sent. Most frameworks parse JSON before your handler runs, and re-serializing it changes the bytes, so read the unparsed body: express.raw({ type: "application/json" }) in Express, await request.text() with the Fetch API (Workers, Deno, Bun, Next.js route handlers).

Deduplicate

A delivery can arrive more than once (a retry after a timeout that actually succeeded). Store each id you have handled and ignore repeats. When one comment matches several events, a webhook subscribed to only some of them gets one delivery, with the most specific of those as event: hidden, then review, then needs reply, then rule matched, then received.

Retries

  • Answer with any 2xx within 8 seconds. Do slow work after answering.
  • Network errors, timeouts, 408, 429 and 5xx are retried three more times, after about 1, 3 and 9 seconds, or after your Retry-After (up to 15 seconds).
  • Other 4xx answers are not retried.
  • The latest error shows next to the webhook in Settings, then Alerts, and in last_error on REST hooks.

Webhooks in Settings or REST hooks

Webhook in SettingsREST hook
Created byA person, in Settings, then AlertsYour code, with POST /api/v1/hooks
EventsAny number per webhook, every eventOne per hook, the REST hook events
Removed byA person in SettingsYour code, a person in Settings, or revoking its key
Signing secretShown next to the webhook in SettingsReturned once when subscribing

In the help center

  • Get alerts in Slack, Discord or email

    Choose where CommentGate tells you about comments that need you, a sudden rush of harmful comments, new leads and more, and exactly which events each place hears about.

  • Connect Zapier and Make

    Start a Zap or a Make scenario when a comment arrives, is hidden, needs you or turns into a lead, and hide, reply or record a sale from any other app.

  • Every step, and where it works

    What each campaign step does, and which of Facebook, Instagram, TikTok, YouTube, Threads and WhatsApp allow it, with the reason when one does not.