Webhooks
Comentario can notify your own services of events on a domain
A webhook makes Comentario notify a service of yours about events occurring on a domain: whenever something happens, Comentario sends an HTTP request to a URL you nominate, describing what happened.
This turns Comentario into an event source for the rest of your stack. A new comment can land in a chat channel, kick off a static site rebuild, feed a moderation queue of your own, or update a search index — without anyone polling the API for changes.
Registering a webhook
Webhooks are registered per domain, and only a domain owner or a superuser can see or manage them: a webhook is a standing export channel for everything happening on the domain.
In the Administration UI they can be accessed via the Webhooks button in the properties of the selected domain. The list shows every webhook registered on the domain along with its health at a glance: whether it’s delivering, how long a failure streak is running, or why it was switched off.
A webhook consists of:
- Target URL — where the payload is
POSTed. It has to be an absolutehttps://URL (see Target validation below). - Name — an optional label, for your own bookkeeping.
- Events — the event types the webhook is subscribed to. At least one is required, otherwise the webhook would never fire.
- Enabled — whether deliveries are actually attempted. A disabled webhook keeps its settings and its secret, but receives nothing.
Upon registration, Comentario generates a signing secret for the webhook and returns it once. Store it right away: it’s never disclosed again, and it’s what your endpoint needs to verify the signature of incoming deliveries. You can always generate a new secret for an existing webhook, which invalidates the previous one immediately.
The number of webhooks per domain is capped (10 by default, configurable with --wh.max-per-domain).
Test delivery
A registered webhook can be sent a test, or ping, delivery. It’s delivered synchronously, and the endpoint’s response — status code, round-trip duration, and the first 1000 bytes of the response body — is reported back to you.
A ping carries no event data beyond the envelope; its data is an empty object. Its purpose is to answer “is my endpoint reachable, and does it accept what Comentario sends,” not to exercise a specific event type. It’s neither retried nor recorded.
Events
| Event type | Occurs when |
|---|---|
comment.created | A new comment is posted on the domain. It fires regardless of whether the comment is approved or awaits moderation: the payload says which it is. |
comment.updated | An existing comment’s text is edited, by its author or by a moderator. If the edit cost the comment its approval, the payload says so via isPending and pendingReason. |
comment.deleted | A comment is deleted, by its author or by a moderator. A deleted comment keeps its identity but loses its text, so markdown and html in the payload are empty. |
comment.approved | A moderator approves a comment, whether it was awaiting moderation or had previously been rejected. |
comment.rejected | A moderator rejects a comment. |
ping is an event type, too, but it’s reserved for test deliveries and cannot be subscribed to.
Moving a comment back into the moderation queue is neither an approval nor a rejection, and emits no event.
All five comment events carry the same data — the comment, its page, and its author, the commenter — so a
handler written for one works for the others. Note that the commenter is always the comment’s author, never the
moderator who acted on it: who edited, deleted or moderated the comment is in comment.userEdited,
comment.userDeleted and comment.userModerated respectively.
Payload
The request body is a JSON object with a stable envelope and an event-type-specific data part. Your handler switches on eventType and reads the property of data it cares about; that way new event types and new properties can be added without breaking it.
{
"specVersion": "1.0",
"eventId": "0f8a3d7e-4b2c-4d1e-9a55-6b1c2f0a77e1",
"eventType": "comment.created",
"eventTime": "2026-08-26T09:41:07Z",
"webhookId": "b21c9a04-7f3d-4a11-8c2e-52d0f7a91b33",
"domain": {
"id": "3c5f1a88-0c8e-4a2f-9b6d-11e2c7a45f90",
"host": "example.com",
"name": "Example Blog"
},
"data": {
"comment": {
"id": "9d4b2c11-8e77-4f30-a1c5-6b0d3e9f2a48",
"pageId": "77c1e0b5-3a9f-4c62-b8d1-2e5a904f7c31",
"markdown": "First!",
"html": "<p>First!</p>",
"score": 0,
"isApproved": false,
"isPending": true,
"isDeleted": false,
"isSticky": false,
"pendingReason": "Anonymous comment",
"createdTime": "2026-08-26T09:41:07Z",
"url": "https://example.com/blog/hello#comentario-9d4b2c11-8e77-4f30-a1c5-6b0d3e9f2a48",
"authorName": "Alice"
},
"page": {
"id": "77c1e0b5-3a9f-4c62-b8d1-2e5a904f7c31",
"domainId": "3c5f1a88-0c8e-4a2f-9b6d-11e2c7a45f90",
"path": "/blog/hello",
"title": "Hello, world",
"isReadonly": false
},
"commenter": {
"id": "00000000-0000-0000-0000-000000000000",
"name": "Anonymous",
"hasAvatar": false,
"isCommenter": true,
"isModerator": false,
"colourIndex": 0
}
}
}
The envelope properties are:
| Property | Meaning |
|---|---|
specVersion | Version of the payload specification. It’s bumped only if the envelope changes in an incompatible way. |
eventId | ID of the event. The same for every webhook receiving this event, and for every delivery attempt of it. |
eventType | What happened, e.g. comment.created. |
eventTime | When it happened (not when the delivery was attempted). |
webhookId | ID of the webhook this payload is being delivered to. |
domain | The domain the event occurred on: id, host, and name. |
data | Event-type-specific data. For the comment.* events: the comment, its page, and its author, the commenter. |
The comment, page, and commenter objects follow the corresponding definitions of the Comentario API, so they carry the same properties you’d get from it, with the exception described below.
An all-zero commenter.id means the comment was posted anonymously, in which case commenter describes the generic anonymous user rather than a registered, real one. The name an unregistered commenter gave for themselves travels on the comment, as comment.authorName, and it’s the one to display.
A webhook payload never contains personal data. The comment author’s IP address and country, and the commenter’s email address are stripped from every payload, whichever event type it belongs to.
A webhook is a standing export to a third party that no commenter has consented to, so this data doesn’t leave the server this way. If your integration needs it, fetch it via the API, under the permissions of a specific user.
Request headers
Deliveries are HTTP POST requests with Content-Type: application/json; charset=utf-8, carrying the following headers:
| Header | Value |
|---|---|
X-Comentario-Event | Type of the event, e.g. comment.created. The same value as eventType in the payload. |
X-Comentario-Event-Id | ID of the event. The same for every webhook receiving it, and for every attempt, so deduplicate on this. |
X-Comentario-Delivery | ID of the delivery, i.e. of this event to this specific webhook. Stable across the retries of that delivery. |
X-Comentario-Attempt | 1-based number of the delivery attempt. |
X-Comentario-Timestamp | Time the payload was signed, in Unix seconds. Part of the signed string. |
X-Comentario-Signature | sha256=<hex>, see below. |
User-Agent | Comentario/<version>. |
Verifying the signature
Every delivery is signed with the webhook’s secret, so that your endpoint can tell a genuine Comentario request from anything else that finds its way to the same URL.
The signature is an HMAC-SHA256 over the string <timestamp>.<raw body>, hex-encoded and prefixed with sha256=. The timestamp is the value of the X-Comentario-Timestamp header. Including it in the signed string is what stops a captured delivery from being replayed later.
The HMAC key is the signing secret. The secret is handed to you as a hex string, and it’s the bytes it decodes to that are the key, not the characters of the string itself: decode it from hex before keying the HMAC, otherwise every signature you compute will differ from the one that arrives.
To verify a delivery:
- Read the raw request body, before any JSON parsing, since re-serialising the payload changes the bytes, and with them the signature.
- Decode the signing secret from hex to obtain the HMAC key.
- Compute the HMAC and compare it with
X-Comentario-Signature, using a constant-time comparison. - Reject the request if
X-Comentario-Timestampis too far in the past. Five minutes is a reasonable window.
An example in Go:
// The secret is a hex string: its decoded bytes are the key
key, err := hex.DecodeString(secret)
if err != nil {
return err
}
mac := hmac.New(sha256.New, key)
fmt.Fprintf(mac, "%s.%s", r.Header.Get("X-Comentario-Timestamp"), body)
ok := hmac.Equal(
[]byte(r.Header.Get("X-Comentario-Signature")),
[]byte("sha256="+hex.EncodeToString(mac.Sum(nil))))
In Node.js:
// The secret is a hex string: its decoded bytes are the key
const expected = Buffer.from('sha256=' + crypto
.createHmac('sha256', Buffer.from(secret, 'hex'))
.update(`${req.get('X-Comentario-Timestamp')}.${rawBody}`)
.digest('hex'));
// timingSafeEqual() throws on a length mismatch, so the lengths are compared first
const received = Buffer.from(req.get('X-Comentario-Signature') ?? '');
const ok = received.length === expected.length && crypto.timingSafeEqual(expected, received);
Retries and duplicates
A delivery is considered successful when the endpoint answers with a 2xx status code. Anything else is a failure, and how it’s treated depends on what came back:
| Outcome | Retried | Treatment |
|---|---|---|
2xx | No | Success. Nothing else happens. |
5xx, 408 Request Timeout, 429 Too Many Requests | Yes | For 408 and 429, a Retry-After header is honoured, within the schedule below. |
Other 4xx | No | The request itself is what the endpoint objects to, and repeating it won’t help. |
3xx | No | Redirects are deliberately not followed, see Target validation. |
| Timeout, DNS, TLS, or connection failure | Yes | Delivery is retried. |
| Target address blocked by the guard | No | Dropped, never retried. |
| Per-domain rate limit exceeded | No | Dropped, never retried. |
A failing delivery is attempted up to five times in total, spread over roughly an hour:
| Attempt | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| Delay after previous | — | 30 s | 2 min | 10 min | 45 min |
| Elapsed since the event | 0 | 30 s | 2½ min | 12½ min | 57½ min |
Each delay carries a ±20% jitter, so that an endpoint coming back online doesn’t get a synchronised burst of retries from every domain at once. Beyond that hour the delivery is marked failed and abandoned: at that point the consumer is down rather than slow.
Delivery is at-least-once. An endpoint that times out after having successfully processed an event will see that event again, and a queue recovered after a server crash may re-attempt one, too.
Make your handler idempotent, deduplicating on the X-Comentario-Event-Id header (or the equivalent eventId payload property).
Your endpoint should answer quickly: an attempt is given 10 seconds by default (--wh.timeout), after which it’s abandoned and retried. Do the actual work asynchronously and answer 2xx as soon as you’ve accepted the event. Response bodies are read up to 1000 bytes and the rest is discarded, so a chatty endpoint can’t hold up a delivery worker.
Delivery log
Every delivery is recorded: the payload that was sent, the status code and the first 1000 bytes of the response, the round-trip duration, and the number of attempts made. A delivery is in one of the following states:
| Status | Meaning |
|---|---|
pending | Queued, waiting for its first attempt or for the next retry. |
sending | Currently in flight. |
success | Accepted by the endpoint. |
failed | Rejected by the endpoint, or abandoned after the attempts were exhausted. |
dropped | Never sent: the target was blocked, the rate limit was hit, or the webhook was switched off. |
The log of a webhook is shown on its properties page in the Administration UI, filterable by status; opening a single delivery shows the payload that was sent in full, next to the response that came back.
A logged delivery can be sent again, which queues the stored payload as a new delivery. The payload is re-sent byte-for-byte and keeps its original eventId, so a consumer that already processed it will deduplicate it; the X-Comentario-Delivery header, on the other hand, is new.
Deliveries are kept for 30 days by default (--wh.log-retention-days) and removed by a daily cleanup routine after that.
Delivery rate
The number of deliveries made on behalf of a single domain is capped at 120 per minute by default (--wh.max-per-minute). Events beyond that are recorded as dropped, with the reason, rather than being silently lost.
The limit applies per Comentario instance: in a multi-instance deployment, the effective rate is the configured number times the number of instances.
When a webhook is switched off automatically
A dead endpoint would otherwise generate retry traffic forever, so Comentario stops delivering to a webhook that has been failing consistently. It’s switched off when either of the following becomes true:
- it has accumulated 20 consecutive failed deliveries (
--wh.max-fail-streak), or - it has been failing without interruption for 7 days (
--wh.max-fail-days).
Both rules are needed because a count alone never trips on a quiet site: twenty failures at one comment a week is four months of silent breakage.
When this happens, the webhook’s Enabled flag is cleared, the reason is recorded, and every owner of the domain receives an email about it. Events that occur while the webhook is off aren’t queued for it, and any delivery still in the queue is dropped rather than sent.
Switching the webhook back on is a manual step, which also clears the failure streak and the recorded reason. A single successful delivery resets the streak, too, so an endpoint that recovers on its own is never switched off.
Security
Target validation
A webhook makes the Comentario server issue an HTTP request to an address that a user chooses, which needs care. Two controls apply:
- Firstly, the target URL has to use the
httpsscheme. Payloads are signed but not encrypted, and a plainhttptarget exposes the comment text and its metadata to anything on the path. Should you need a plain-HTTP target, e.g. inside a trusted network, the operator can allow it with--wh.allow-insecure. - Secondly, immediately before a connection is made, Comentario checks the address it’s about to connect to, and refuses to proceed if it’s a loopback, private, link-local, multicast, unspecified, or CGNAT (
100.64.0.0/10) address. The check happens at connection time rather than at registration time, because a hostname that resolves to a legal address today can be re-pointed at an internal one tomorrow. For the same reason, redirects are never followed: an endpoint answering3xxcould otherwise hand Comentario an address the check has already passed.
This means that, out of the box, a webhook cannot reach anything on the server’s own network. If Comentario is supposed to talk to an internal service, and you’re aware that this makes the address of every such service reachable to anyone who can register a webhook, the operator can lift the restriction with --wh.allow-private-targets.
Secrets
Signing secrets are stored on the server; they are different for each webhook and never returned by the API after the moment they’re generated. If a secret leaks, generate a new one: the old one stops being accepted at once, and deliveries in flight with the old signature will simply fail your verification.
Configuration
Webhooks are switched on out of the box. There are two ways to switch them off instance-wide:
- The dynamic configuration parameter
integrations.webhooks.enabled, which a superuser can toggle in the Administration UI. - The
--wh.disablestatic configuration option, which takes precedence over the above and is the operator’s own switch.
While webhooks are off, no event is queued and nothing is delivered; existing webhooks stay in place and resume when the feature is switched back on, without replaying what they missed in the meantime.
The remaining knobs (the number of delivery workers, the attempt timeout, the number of attempts, the poll interval, the per-domain webhook limit, the delivery rate limit, the auto-disable thresholds, and the delivery log retention) are described in Static configuration under Webhook options.
