API

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

On this page

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.

  1. Open Settings in the dashboard and find the Webhook section.
  2. Paste your endpoint into the URL field — it must start with https://.
  3. Under What to send, tick which signals fire it — buyer pains, competitor mentions, self-promotions, or any mix. All three are ticked to start.
  4. 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:

HeaderWhat it is
X-Dist0-TimestampUnix seconds the signature covers
X-Dist0-SignatureThe signature, as v0=<hex>
X-Dist0-Signal-Kindpain, competitor, or self_promote
X-Dist0-Delivery-IdA 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.