DocsAPI & MCP

Webhooks and signature verification

The cycle-completion webhook, the X-MentionFlow-Signature envelope, and copy-paste receiver code to verify deliveries and reject replays with your workspace signing secret.

MentionFlow sends one webhook event today: cycle.completed — a per-project summary posted after each daily collection cycle, to the webhook URL configured in that project's notification settings. There are no other event types yet; this page will list them as they ship.

The delivery

A delivery is an HTTP POST with Content-Type: application/json and this body shape:

{
  "event": "cycle.completed",
  "brand": "ClickUp",
  "date": "2026-07-24",
  "runs_ok": 42,
  "runs_failed": 2,
  "engines": ["chatgpt", "perplexity"],
  "alerts": [{ "metric": "presence_lost", "message": "…" }],
  "dashboard_url": "https://…/overview"
}

When the week's numbers could be built, the body also carries an additive digest object: days, answers, visibility, share_of_voice, named_in_answers, avg_position, sentiment (each { "value", "prev" } for the last 7 days and the 7 before), standings (brand and tracked competitors by share of voice, with rank and prev_rank), engines (share of answers naming the brand per engine), top_sources, and low_confidence. The Send test sample carries one too.

Deliveries are fire-and-forget with a 10-second timeout: a failed post is logged on MentionFlow's side and not retried. Slack notification posts (the separate Slack URL) are never signed — Slack's own model.

What your endpoint has to be

Every delivery goes out over a pinned egress path, and it will refuse to post to a URL that doesn't satisfy all of this:

  • https only. A plain http endpoint is refused.
  • A public hostname, checked again at connect time against the address actually dialled — not just the one in the URL. An endpoint that resolves to a loopback, link-local or private-range address is refused.
  • No redirects. A 3xx is not followed, even to another https URL. Give us the endpoint's canonical URL rather than one that redirects to it.

When a save or a test send is refused for one of these reasons, the message says so specifically — "it redirected, or it resolves to a non-public address" — because that is a different problem from your endpoint returning an error, and it needs a different fix.

Send test (Settings → Notifications) posts a sample of the real message to every destination. Slack and Discord get the daily summary with the project's own name, engines and prompts and sample numbers, marked as a sample. A JSON endpoint gets {"event":"test","brand":"…","sample":{…}}, where sample is a complete cycle.completed body, so you can map fields before the first cycle runs.

The signature envelope

Once your workspace has a signing secret (Settings → Workspace → Webhook signing, owner/admin only — the secret is shown exactly once when you generate or rotate it), every delivery carries:

X-MentionFlow-Signature: t=1753350000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77777e77d1a4a839e04086a35
  • t — the delivery's Unix timestamp in seconds, chosen by MentionFlow at send time.
  • v1HMAC-SHA256(secret, "<t>.<raw request body>"), hex-encoded. The signed string is the timestamp, a literal ., then the raw body bytes exactly as received — verify before parsing, never re-serialize.

During the 24 hours after a secret rotation the header carries two v1 entries — one per secret version — so a receiver holding either secret verifies. Accept the delivery if any v1 entry matches.

Watch out

Without a workspace signing secret, deliveries carry a legacy X-MentionFlow-Signature: sha256=… header signed with an operator-side key. You cannot verify that form — it exists for MentionFlow's own tooling. Generate a workspace secret to get the verifiable envelope above.

Verifying a delivery

  1. Read the raw request body before any JSON parsing.
  2. Parse t and every v1 value from the header.
  3. Reject if |now − t| > 300 seconds — the replay window. The timestamp is inside the signed string, so it can't be forged onto a captured body.
  4. Compute HMAC-SHA256(secret, t + "." + rawBody) and compare (constant-time) against each v1. Accept on any match.

Node

const { createHmac, timingSafeEqual } = require("node:crypto");

const TOLERANCE_SECONDS = 300;

function verifyMentionFlowSignature(header, rawBody, secret) {
  if (!header) return false;
  let t = null;
  const candidates = [];
  for (const part of header.split(",")) {
    const [k, v] = part.split("=", 2);
    if (k?.trim() === "t" && v && /^\d+$/.test(v)) t = Number(v);
    if (k?.trim() === "v1" && v) candidates.push(v);
  }
  if (t === null || candidates.length === 0) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > TOLERANCE_SECONDS) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const expectedBuf = Buffer.from(expected);
  return candidates.some((c) => {
    const buf = Buffer.from(c);
    return buf.length === expectedBuf.length && timingSafeEqual(buf, expectedBuf);
  });
}

// Express example — note express.raw(), NOT express.json(): the raw bytes
// must reach the verifier untouched.
// app.post("/hooks/mentionflow", express.raw({ type: "application/json" }), (req, res) => {
//   const ok = verifyMentionFlowSignature(
//     req.header("X-MentionFlow-Signature"),
//     req.body.toString("utf8"),
//     process.env.MENTIONFLOW_WEBHOOK_SECRET,
//   );
//   if (!ok) return res.status(400).send("bad signature");
//   const event = JSON.parse(req.body);
//   res.sendStatus(200);
// });

Python

import hashlib, hmac, time

TOLERANCE_SECONDS = 300

def verify_mentionflow_signature(header: str | None, raw_body: bytes, secret: str) -> bool:
    if not header:
        return False
    t, candidates = None, []
    for part in header.split(","):
        k, _, v = part.partition("=")
        if k.strip() == "t" and v.isdigit():
            t = int(v)
        elif k.strip() == "v1" and v:
            candidates.append(v)
    if t is None or not candidates:
        return False
    if abs(time.time() - t) > TOLERANCE_SECONDS:
        return False
    signed = f"{t}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, c) for c in candidates)

Rotating the secret

Rotate under Settings → Workspace → Webhook signing. The new secret is shown once; for the next 24 hours deliveries are signed with both the new and the previous secret, so you can swap the secret in your receiver at any point inside the window with zero rejected deliveries. After the window, only the new secret verifies. Generation and rotation are recorded in the workspace audit log (version numbers only — secret values are never logged).

  • API keys — the inbound credential story (expiry, rotation, IP allowlists).
  • Errors — the REST error shapes.