Disfora
Docs menu

Webhooks

Webhooks send comments, ratings and form submissions to your own server as they happen, as signed JSON POST requests. Handle them in your own code, or point them at Zapier or Make. They're free on every plan.

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 https and 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.test event 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:

comment.created
{
  "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
  }
}
  • project is your project's public key, so one endpoint can tell several projects apart.
  • parent_id is the comment being replied to, or null for a top-level comment.
  • text is the comment as written, in Markdown.
  • status is approved, pending (waiting in your moderation queue) or rejected.
  • comment.status_changed adds previous_status. comment.deleted adds deleted_by: author or moderator.
  • 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 author is { "username": null, "name": "Priya" }.
rating.submitted: data
{
  "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.

form.submitted: data
{
  "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"
}
  • fields is in form order, with every field present: value is null when it was left empty, and a list for checkboxes. Answers are sent as the person typed them.
  • Map fields by key, not label. A field's key never changes, even if you rename or move the field.
  • submitter is { "username": … } when the person was signed in to Disfora, otherwise null.

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:

  1. Read t and v1 from X-Disfora-Signature.
  2. Compute an HMAC-SHA256 of t, a full stop, and the raw request body, with your signing secret as the key, in hex.
  3. Compare it to v1 with a constant-time comparison, and reject a t more 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.

Node.js (Express)
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);
});
Python
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 id you've already handled. Events can also arrive out of order, so use created_at when 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