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. It is a per-project summary, posted after each daily collection cycle to the webhook URL in that project's notification settings. There are no other event types yet.
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. It holds days, answers, visibility, share_of_voice, named_in_answers, avg_position and sentiment, each as { "value", "prev" } for the last 7 days and the 7 before. Then standings (your 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 our side and not retried. Slack notification posts, on the separate Slack URL, are never signed. That is Slack's own model.
What your endpoint has to be
Every delivery goes out over a pinned egress path. It refuses to post to a URL that fails any of these checks:
httpsonly. A plainhttpendpoint is refused.- A public hostname. We check the address we dial, 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
3xxis not followed, even to anotherhttpsURL. Give us the endpoint's canonical URL, not one that redirects to it.
When a save or a test send is refused for one of these reasons, the message says so exactly ("it redirected, or it resolves to a non-public address"). That is a different problem from your endpoint returning an error, and needs a different fix.
Send test (on 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, plus 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, every delivery carries this header:
X-MentionFlow-Signature: t=1753350000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77777e77d1a4a839e04086a35
Generate the secret on Webhook signing. Owners and admins only, and it is shown exactly once when you generate or rotate it.
tis the delivery's Unix timestamp in seconds, chosen at send time.v1isHMAC-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 you parse. Never re-serialize.
For 24 hours after a rotation the header carries two v1 entries, one per secret version, so a receiver holding either secret still verifies. Accept the delivery if any v1 entry matches.
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
- Read the raw request body before any JSON parsing.
- Parse
tand everyv1value from the header. - Reject if
|now − t| > 300seconds. That is the replay window. The timestamp is inside the signed string, so it cannot be forged onto a captured body. - Compute
HMAC-SHA256(secret, t + "." + rawBody)and compare it, constant-time, against eachv1. 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 on Webhook signing. The new secret is shown once. For the next 24 hours deliveries are signed with both the new secret and the old one, so you can swap it in your receiver at any point inside that window and lose no deliveries. After the window, only the new secret verifies. Generation and rotation are recorded in the workspace audit log, as version numbers only. Secret values are never logged.