Docs menu
Webhooks
Add an endpoint
In your project, open Webhooks (under Integrations in the sidebar) and click Add endpoint. Enter the URL that should receive events and pick the events it gets.
- The URL must use
httpsand be reachable from the internet. Redirects aren't followed, so use the final URL. -
Each endpoint gets its own Signing secret, starting
whsec_, shown on the endpoint's page. Rotate secret replaces it, and the old one stops working straight away. - Send test event sends a signed
webhook.testevent and shows your server's response. - A project can have up to 10 endpoints. Project owners and managers can set them up.
Events
| Event | Sent when |
|---|---|
comment.created |
A comment or reply is posted, whether it's approved, held for review or rejected |
comment.updated |
The author edits a comment |
comment.status_changed |
A moderator approves, rejects or reopens a comment |
comment.deleted |
The author or a moderator deletes a comment |
rating.submitted |
A reader rates a page, or changes their rating |
form.submitted |
Someone submits a form. Spam isn't sent. |
Removing a rating doesn't send an event, and neither do comments brought in by the Disqus import.
The payload
Every event has the same envelope, with the details in data:
{
"id": "evt_3kQx9TfZ2mWb7LpR4nVc8A",
"type": "comment.created",
"created_at": "2026-10-15T09:12:44.512031+00:00",
"project": "proj_Xc2Lq8vN0aTz5RkW1yHm7dGs3pFe9bUj",
"data": {
"id": "6f1c2d4e-8a3b-4c5d-9e7f-0a1b2c3d4e5f",
"page_url": "https://yoursite.com/blog/hello-world",
"parent_id": null,
"text": "Great post. **Bookmarked.**",
"status": "approved",
"author": { "username": "sam" },
"created_at": "2026-10-15T09:12:44.398127+00:00",
"edited_at": null
}
}
projectis your project's public key, so one endpoint can tell several projects apart.parent_idis the comment being replied to, ornullfor a top-level comment.textis the comment as written, in Markdown.statusisapproved,pending(waiting in your moderation queue) orrejected.comment.status_changedaddsprevious_status.comment.deletedaddsdeleted_by:authorormoderator.-
People appear only by
username, the same as in the widget. Webhooks never include a reader's email address or account id. -
A comment from a guest, or one you imported, has a name but no account: its
authoris{ "username": null, "name": "Priya" }.
{
"id": "0b9d7e1a-5c2f-4e8b-a1d3-6f4e2c9b8a70",
"page_url": "https://yoursite.com/blog/hello-world",
"rating": 4,
"previous_rating": null,
"author": { "username": "sam" },
"created_at": "2026-10-15T09:14:02.118270+00:00",
"updated_at": "2026-10-15T09:14:02.118270+00:00"
}
rating is 1 to 5. previous_rating is null for a first rating, or the old value when a reader changes theirs.
{
"id": "c4a8e2f1-7b3d-4f6a-9c1e-2d5b8a7f3e60",
"form": { "id": "9e3f6a2b-1c4d-4e8f-b7a0-5d2c9e1f8b34", "title": "Contact us" },
"page_url": "https://yoursite.com/contact",
"fields": [
{ "key": "k7mq2xpa", "label": "Name", "value": "Alex Morgan" },
{ "key": "r4tn8wce", "label": "Email", "value": "[email protected]" },
{ "key": "h2vb6dsj", "label": "Topics", "value": ["Pricing", "Support"] },
{ "key": "p9fz3gyk", "label": "Message", "value": null }
],
"submitter": null,
"created_at": "2026-10-15T09:20:31.904512+00:00"
}
-
fieldsis in form order, with every field present:valueisnullwhen it was left empty, and a list for checkboxes. Answers are sent as the person typed them. -
Map fields by
key, notlabel. A field's key never changes, even if you rename or move the field. submitteris{ "username": … }when the person was signed in to Disfora, otherwisenull.
Verify the signature
Each request carries these headers:
| Header | Value |
|---|---|
X-Disfora-Event |
The event type, like comment.created |
X-Disfora-Delivery |
The event id. Retries and re-sends repeat it. |
X-Disfora-Signature |
t=1760519564,v1=… (a Unix timestamp and an HMAC-SHA256) |
User-Agent |
Disfora-Webhooks/1.0 |
To check a request came from Disfora:
- Read
tandv1fromX-Disfora-Signature. - Compute an HMAC-SHA256 of
t, a full stop, and the raw request body, with your signing secret as the key, in hex. - Compare it to
v1with a constant-time comparison, and reject atmore than 5 minutes from now.
Use the raw body exactly as it arrived. Parsing the JSON and serialising it again changes the bytes, and the signature won't match.
import crypto from 'node:crypto';
import express from 'express';
function verifyDisfora(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(',').map((part) => part.split('=')));
if (!parts.t || !parts.v1 || Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.`).update(rawBody).digest('hex');
return expected.length === parts.v1.length
&& crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
const app = express();
app.post('/webhooks/disfora', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.get('X-Disfora-Signature') || '';
if (!verifyDisfora(req.body, signature, process.env.DISFORA_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body);
// Handle event.type here, or queue it and handle it after responding.
res.sendStatus(200);
});
import hashlib
import hmac
import time
def verify_disfora(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(part.split("=", 1) for part in header.split(",") if "=" in part)
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not t.isdigit() or abs(time.time() - int(t)) > 300:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
In Flask the raw body is request.get_data(); in Django, request.body; in FastAPI,
await request.body().
Responding and retries
- Reply with any 2xx status within 5 seconds. Do slow work, like calling another API, after you've responded.
- Anything else counts as a failure: another status, a redirect, a timeout or no connection. Disfora tries again after 1 minute, 10 minutes, 1 hour and 6 hours, then gives up on that event.
-
The same event can arrive more than once, after a retry or a re-send from the log. Skip events whose
idyou've already handled. Events can also arrive out of order, so usecreated_atwhen order matters. - If an endpoint fails for a whole day, Disfora turns it off and notifies your project's owners and managers. Fix it, send a test event, then click Resume.
- Pause stops sending to an endpoint on purpose. Events that happen while an endpoint is paused or turned off aren't sent later.
The delivery log
Click View log on an endpoint to see its events from the last 30 days, each with its result and every attempt, including your server's status code and how long it took. Search by event id, and click Send again to re-send one. If recent events didn't arrive while your server was down, the banner at the top offers to retry all of them from the last 7 days at once.
Testing on your computer
Disfora can't reach localhost. To test a receiver running on your computer, expose it with a tunnel
such as ngrok or
Cloudflare Tunnel,
add the https address it gives you as an endpoint, and click Send test event.
Ask a question
Something missing or unclear? Ask here and we'll answer in the thread.
Updated October 2026