Webhooks
Instead of asking the API for new signals, have dist0 push each one to a URL you choose as it's found.
Last updated July 3, 2026
What a webhook does
The read API answers when you ask it. A webhook is the other way around: you give dist0 a URL, and when a new signal shows up for your project we send it there. That's what no-code tools like Zapier, Make, and n8n plug into as a trigger — and you can point it at your own app too.
Set it up
Webhooks are part of the Pro plan.
- Open Settings in the dashboard and find the Webhook section.
- Paste your endpoint into the URL field — it must start with
https://. - Under What to send, tick which signals fire it — buyer pains, competitor mentions, self-promotions, or any mix. All three are ticked to start.
- Click Save.
You can turn the URL off without deleting it, and edit the URL or the signal types.
What we send
Each new signal is one POST to your URL with a JSON body:
{
"type": "signal.created",
"project_id": "prj_9f2a",
"signal": {
"id": "sig_7Qd1",
"kind": "pain",
"quote": "I keep losing track of which subreddit a lead came from.",
"author": "some_redditor"
},
"post": {
"id": "post_3xB8",
"permalink": "https://reddit.com/r/…",
"title": "How do you track where leads come from?"
},
"sov": {
"id": "sov_5kR2",
"title": "Tracking where leads come from",
"summary": "People struggle to trace which subreddit or channel a lead started in."
}
}kind is pain, competitor, or self_promote. The signal.id and post.id
are the same durable ids the read API uses, so a pushed signal and one you later
fetch line up.
A buyer pain also carries a sov object — the theme it was grouped into. Every
pain belongs to exactly one theme, and pains that share a theme carry the same
sov.id. Key on sov.id to keep one record per theme on your side — the first
pain of a theme creates it, and each later pain with the same sov.id adds to it.
Because we group a pain into its theme before sending, a pain's webhook arrives a
little after the post itself is analyzed. Competitor and self-promotion signals
have no theme, so they carry no sov.
We also set these headers on every send:
| Header | What it is |
|---|---|
X-Dist0-Timestamp | Unix seconds the signature covers |
X-Dist0-Signature | The signature, as v0=<hex> |
X-Dist0-Signal-Kind | pain, competitor, or self_promote |
X-Dist0-Delivery-Id | A unique id for this send |
Check the signature
Every send is signed with your webhook's signing secret so you can be sure it's from dist0 and wasn't changed on the way. The signature is an HMAC-SHA256 over the timestamp and the exact request body:
signature = "v0=" + HMAC_SHA256(signing_secret, "v0:" + timestamp + ":" + raw_body)Compute the same value on your side and compare it to the X-Dist0-Signature
header. Use the raw request body — parsing and re-encoding the JSON first
will change the bytes and break the check.
import crypto from "crypto";
function verify(rawBody, timestamp, signature, signingSecret) {
const expected =
"v0=" +
crypto
.createHmac("sha256", signingSecret)
.update(`v0:${timestamp}:${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature),
);
}To block replays, also reject a send whose X-Dist0-Timestamp is more than a few
minutes off from your own clock.
Retries and failed sends
Your endpoint should answer with a 2xx status once it has the signal. If it
returns anything else, or doesn't answer in time, dist0 treats the send as failed
and tries again with growing gaps between attempts. After several failed
attempts we stop and mark the send as given up.
The Recent failed sends list in Settings → Webhook shows your most recent failed sends with the time and the reason, so you can spot a broken endpoint and fix it.
