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 | Header | Meaning |
|---|---|
x-commentgate-timestamp | When it was sent, in Unix seconds |
x-commentgate-signature-v2 | v2= and the hex HMAC-SHA256 of <timestamp>.<raw body>, keyed with the signing secret. Verify this one |
x-commentgate-signature | Deprecated: 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-delivery | The 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-timestampheader, 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 withx-commentgate-signature-v2in 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.
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))
);
} 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); 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,429and5xxare retried three more times, after about 1, 3 and 9 seconds, or after yourRetry-After(up to 15 seconds). - Other
4xxanswers are not retried. - The latest error shows next to the webhook in Settings, then Alerts, and in
last_erroron REST hooks.
Webhooks in Settings or REST hooks
| Webhook in Settings | REST hook | |
|---|---|---|
| Created by | A person, in Settings, then Alerts | Your code, with POST /api/v1/hooks |
| Events | Any number per webhook, every event | One per hook, the REST hook events |
| Removed by | A person in Settings | Your code, a person in Settings, or revoking its key |
| Signing secret | Shown next to the webhook in Settings | Returned 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.