Skip to content

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:

EventFires when…
comment.createdA new comment is posted (pending or auto-approved).
comment.approvedA moderator or auto-approve approves a comment.
comment.rejectedA moderator rejects a comment or marks it as spam. The payload's comment.status says which.
comment.deletedA comment is deleted.
thread.createdA 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=5257a869e7bfce8c1c1c1e3f2b4a6d8e9f0c1a2b3d4e5f6a7b8c9d0e1f2a3b4c
  • t — 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:

  1. Split the header into its t and v1 parts.
  2. Recompute the HMAC-SHA256 of "{t}.{raw body}" using your signing secret, and compare it to v1 using a constant-time comparison.
  3. Reject the request if t is 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:

AttemptDelay since previous attempt
1 (initial)—
21 minute
35 minutes
430 minutes
52 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.