Webhooks
Subscribe an HTTPS endpoint to comment and thread events and get a signed request the moment they happen — no polling required. Available on the Starter plan and above.
Last updated · Manage endpoints from a site's Settings → Webhooks page in the dashboard.
Overview
A webhook endpoint is a URL on your own server that EchoThread calls with an HTTPS POST whenever a subscribed event happens on one of your sites — a new comment, a moderation decision, a new thread. Each endpoint has its own signing secret so you can verify a delivery genuinely came from EchoThread before you act on it.
Webhooks require the Starter plan or above. They're not available on the free Hobby plan. See pricing for plan details.
To create an endpoint, open a site's Settings → Webhooks page in the dashboard, add your HTTPS URL, and choose which of the five event types it should receive. We show you the signing secret exactly once at creation time — store it securely, since we can't display it again.
Event types
Every delivery is a JSON object with an event field naming which of these five events it is, plus an id and created_at that identify this delivery (not the underlying comment or thread) — use them to dedupe retried deliveries. There are five event types:
| Event | Fires when… |
|---|---|
| comment.created | A new comment is posted (pending or auto-approved). |
| comment.approved | A moderator or auto-approve approves a comment. |
| comment.rejected | A moderator rejects a comment or marks it as spam. The payload's comment.status says which. |
| comment.deleted | A comment is deleted. |
| thread.created | A page's comment thread is created (its first comment). |
comment.created
Fires when a new comment is posted — whether it lands as pending or is immediately approved by auto-approve.
{
"event": "comment.created",
"id": "evt_8f3a1c2b9d4e4f01",
"created_at": "2026-08-18T14:32:07Z",
"site_id": "site_4b2e9a1d",
"comment": {
"id": "cmt_7a1f3e9b2c4d",
"thread_id": "thr_2d8c4f1a9b3e",
"parent_id": null,
"body": "This is exactly the kind of nuance I was hoping someone would bring up.",
"author_id": "usr_9c3b7e1f2a4d",
"guest_name": null,
"status": "pending",
"reply_count": 0,
"created_at": "2026-08-18T14:32:07Z",
"updated_at": "2026-08-18T14:32:07Z"
}
}comment.approved
Fires when a moderator (or auto-approve) approves a pending comment.
{
"event": "comment.approved",
"id": "evt_1d9e4a7b2c3f5061",
"created_at": "2026-08-18T14:35:52Z",
"site_id": "site_4b2e9a1d",
"comment": {
"id": "cmt_7a1f3e9b2c4d",
"thread_id": "thr_2d8c4f1a9b3e",
"parent_id": null,
"body": "This is exactly the kind of nuance I was hoping someone would bring up.",
"author_id": "usr_9c3b7e1f2a4d",
"guest_name": null,
"status": "approved",
"reply_count": 0,
"created_at": "2026-08-18T14:32:07Z",
"updated_at": "2026-08-18T14:35:52Z"
}
}comment.rejected
Fires when a moderator rejects a comment or marks it as spam, from the dashboard or through the API. The payload's comment.status is rejected or spam, so you can tell the two apart.
{
"event": "comment.rejected",
"id": "evt_6c2b8f1a9d3e4075",
"created_at": "2026-08-18T14:36:10Z",
"site_id": "site_4b2e9a1d",
"comment": {
"id": "cmt_2e8b4d1a7c9f",
"thread_id": "thr_2d8c4f1a9b3e",
"parent_id": null,
"body": "Check out my site for amazing deals!!! www.example-spam.com",
"author_id": null,
"guest_name": "Guest",
"status": "rejected",
"reply_count": 0,
"created_at": "2026-08-18T14:33:41Z",
"updated_at": "2026-08-18T14:36:10Z"
}
}comment.deleted
Fires when a comment is deleted. The comment object reflects its last known state before deletion, with status set to deleted.
{
"event": "comment.deleted",
"id": "evt_4a7d1e9b2c8f5093",
"created_at": "2026-08-18T14:40:03Z",
"site_id": "site_4b2e9a1d",
"comment": {
"id": "cmt_7a1f3e9b2c4d",
"thread_id": "thr_2d8c4f1a9b3e",
"parent_id": null,
"body": "This is exactly the kind of nuance I was hoping someone would bring up.",
"author_id": "usr_9c3b7e1f2a4d",
"guest_name": null,
"status": "deleted",
"reply_count": 0,
"created_at": "2026-08-18T14:32:07Z",
"updated_at": "2026-08-18T14:40:03Z"
}
}thread.created
Fires the first time a page's comment thread is created — that is, when its first comment is posted.
{
"event": "thread.created",
"id": "evt_9b3e1d7a4c2f6082",
"created_at": "2026-08-18T14:32:07Z",
"site_id": "site_4b2e9a1d",
"thread": {
"id": "thr_2d8c4f1a9b3e",
"site_id": "site_4b2e9a1d",
"page_url": "https://example.com/blog/my-post",
"identifier": "blog-my-post",
"title": "My Blog Post",
"comment_count": 1,
"is_open": true,
"created_at": "2026-08-18T14:32:07Z"
}
}Verifying signatures
Every delivery carries an X-EchoThread-Signature header in the format t=<unix>,v1=<hex> so you can confirm the request came from EchoThread and the body wasn't tampered with in transit. A real header looks like this:
X-EchoThread-Signature: t=1755528727,v1=5257a869e7bfce8c1c1c1e3f2b4a6d8e9f0c1a2b3d4e5f6a7b8c9d0e1f2a3b4ct— the Unix timestamp (seconds) the request was signed at.v1— the hex-encoded HMAC-SHA256 signature.
The signed message is the ASCII string "{t}.{body}" — the literal timestamp, a period, then the raw request body exactly as sent — HMAC-SHA256'd with your endpoint's signing secret. To verify a delivery:
- Split the header into its
tandv1parts. - Recompute the HMAC-SHA256 of
"{t}.{raw body}"using your signing secret, and compare it tov1using a constant-time comparison. - Reject the request if
tis more than a few minutes from your current time, to guard against a captured request being replayed later.
Use the raw, unparsed request body. Parsing the JSON and re-serializing it before verifying will produce a different byte sequence and fail signature checks — verify first, parse second.
Node.js
const crypto = require('crypto');
function verifyEchoThreadSignature(secret, header, rawBody, toleranceSeconds = 300) {
const parts = Object.fromEntries(
header.split(',').map((kv) => kv.split('=').map((s) => s.trim()))
);
const t = parseInt(parts.t, 10);
const v1 = parts.v1;
if (!t || !v1) throw new Error('Malformed signature header');
const driftSeconds = Math.abs(Math.floor(Date.now() / 1000) - t);
if (driftSeconds > toleranceSeconds) {
throw new Error('Signature timestamp outside tolerance');
}
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
const expectedBuf = Buffer.from(expected, 'hex');
const actualBuf = Buffer.from(v1, 'hex');
if (
expectedBuf.length !== actualBuf.length ||
!crypto.timingSafeEqual(expectedBuf, actualBuf)
) {
throw new Error('Signature mismatch');
}
}
// Express example — use the raw, unparsed body, not req.body:
app.post(
'/webhooks/echothread',
express.raw({ type: 'application/json' }),
(req, res) => {
try {
verifyEchoThreadSignature(
process.env.ECHOTHREAD_WEBHOOK_SECRET,
req.header('X-EchoThread-Signature'),
req.body // Buffer, thanks to express.raw()
);
} catch (err) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body);
// ... handle event.event, dedupe on event.id ...
res.status(200).send('ok');
}
);Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"strconv"
"strings"
"time"
)
func verifyEchoThreadSignature(secret, header string, body []byte, tolerance time.Duration) error {
var t int64
var v1 string
for _, part := range strings.Split(header, ",") {
kv := strings.SplitN(part, "=", 2)
if len(kv) != 2 {
continue
}
switch kv[0] {
case "t":
t, _ = strconv.ParseInt(kv[1], 10, 64)
case "v1":
v1 = kv[1]
}
}
if t == 0 || v1 == "" {
return fmt.Errorf("malformed signature header")
}
drift := time.Since(time.Unix(t, 0))
if drift < 0 {
drift = -drift
}
if drift > tolerance {
return fmt.Errorf("signature timestamp outside tolerance")
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(strconv.FormatInt(t, 10)))
mac.Write([]byte("."))
mac.Write(body)
expected := mac.Sum(nil)
got, err := hex.DecodeString(v1)
if err != nil || !hmac.Equal(got, expected) {
return fmt.Errorf("signature mismatch")
}
return nil
}
// http.HandlerFunc example — read the raw body before parsing JSON:
func handleWebhook(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
err = verifyEchoThreadSignature(
os.Getenv("ECHOTHREAD_WEBHOOK_SECRET"),
r.Header.Get("X-EchoThread-Signature"),
body,
5*time.Minute,
)
if err != nil {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
// ... unmarshal body, handle by event, dedupe on id ...
w.WriteHeader(http.StatusOK)
}Retries & delivery guarantees
A delivery counts as successful when your endpoint responds with any 2xx status within our request timeout. Anything else — a non-2xx response, a timeout, or a connection failure — is treated as a failed attempt and retried on a fixed backoff schedule:
| Attempt | Delay since previous attempt |
|---|---|
| 1 (initial) | — |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 (final) | 6 hours |
That's an initial attempt plus five retries at 1 minute, 5 minutes, 30 minutes, 2 hours, and 6 hours. If the sixth attempt still fails, the delivery is dead-lettered — we stop retrying it rather than continuing to poll a persistently broken endpoint.
Delivery is at-least-once, not exactly-once. A retried delivery can arrive more than once at your endpoint — for example if your server processed a request successfully but the response was lost before we saw it. Your handler must be idempotent: use the delivery's id field to recognize and safely ignore a duplicate you've already processed, rather than assuming every delivery you receive is new.
You can also send a one-off test event to a new endpoint from the dashboard to confirm it's reachable and your signature verification is wired up correctly, without waiting for real traffic.