Your brand's alerts, wherever your team or your agent lives
A webhook is a URL of yours that ViralHunt calls the moment something happens, instead of you asking every few minutes. Today it fires for the brand watch: when a discussion that mentions one of your brand's keywords is gaining comments fast, the alert is POSTed to your URL within the same minute the cron finds it. Point it at a Discord or Slack channel and the team sees it there with no code; point it at n8n, Make or Zapier and anything can follow; point it at your own agent and it can read the thread, draft the comment and leave it on the board before anyone opens the app.
The whole picture: the networks are searched for your keywords, the climbing threads become an alert every 30 minutes, and the alert goes wherever you pointed the webhook.
1. What fires, and when
| Event | When | How often | What it carries |
|---|---|---|---|
brand.alerts |
A post that mentions one of the brand's keywords (on Reddit, Quora, X, Instagram, TikTok or Bluesky) gained 10 or more comments in the last 8 hours, or was born with 50 or more. Each thread is reported once per brand. | The alert cron runs every 30 minutes. One POST per run per brand, with every new alert of that run inside (one to ten). | The threads: title, link, author or community, comments before and now, the gain, the hours, whether it was born hot, and a link to your radar filtered by the brand. |
brand.test |
You pressed Send a test (or called webhook_test). |
Once, right then. | One sample alert, same shape. Your code should recognise event: brand.test and do nothing with it. |
The same alerts also go by email and push to the owners and admins who keep "Brand alerts" on in Notifications, and a daily digest (the most commented threads of the day) arrives at 8:00 in the project's timezone. The webhook adds the machine side: your channel or your agent gets them at the same moment. Nothing else fires a webhook yet: not a published post, not a new draft. When that ships it will be a second event in this table, with the same headers and signature.
2. Set it up
In the app (two minutes)
- Projects β your brand β the keywords button ("N keywords tracked" or "Add the keywords this brand owns").
- Write the keywords (one per line;
#hashtagsallowed) and tick the networks. - In Webhook for this brand's agent paste the URL. The format selector reads a Discord or Slack URL by itself ("Auto"); for your own server or an automation leave Auto or pick JSON.
- Save keywords. The secret appears under the field: copy it into your server's configuration (you never need it for Discord or Slack).
- Send a test. Green means the receiver answered 2xx. Amber or red says what it answered and what to check.
Only an owner or an admin can set or change a webhook; members see the field read-only. One webhook per brand (project): an agency with five brands has five URLs, or one URL that reads X-VH-Project and routes.
From the API (for an agent that sets itself up)
The same two operations exist as API calls, with an owner or admin token (API Access). format is json, discord, slack or auto; url: null clears the webhook.
POST https://viralhunt.io/tool/api/v1/brand.php
Authorization: Bearer vhk_your_owner_or_admin_token
Content-Type: application/json
{"action": "webhook", "project_id": 7, "url": "https://your-server.example.com/viralhunt", "format": "json"}
β {"success": true, "data": {"url": "β¦", "format": "json", "secret": "a1b2β¦", "formats": {β¦}, "delivery": {β¦}}}POST https://viralhunt.io/tool/api/v1/brand.php
{"action": "webhook_test", "project_id": 7}
β {"success": true, "data": {"ok": true, "code": 204, "format": "discord", "hint": "Delivered: the receiver answered 204."}}Agents using the ViralHunt skill or the MCP server have this as viralhunt_brand_webhook: say "set my BrandiZone webhook to this Discord URL and send a test" and it does both.
3. Where to send it
Discord
A Discord channel receives messages through an incoming webhook URL. ViralHunt formats the alert as a Discord message (one embed per thread, with the link, the community, the comment gain and a colour per network) so nothing sits in between.
- In Discord, open the channel where the alerts should land (make one:
#brand-alerts). Channel settings (the gear) β Integrations β Webhooks β New Webhook. - Name it "ViralHunt", check it points at that channel, Copy Webhook URL. It looks like
https://discord.com/api/webhooks/1234β¦/AbCdβ¦. Treat it as a password: anyone with it can post in that channel. - Paste it in Projects β your brand β Webhook. The format selector switches to Discord by itself. Save, then Send a test: a "Test from ViralHunt" message appears in the channel within a second.
What the channel gets: a line "π₯ Brand: 3 discussions heating up right now", then up to nine embeds, each the thread's title linked, "r/community Β· 12 β 31 comments (+19 in 8 h)", the network in the footer, and a last embed "Open the radar" to your brand filter. A Discord message takes ten embeds at most; when a run has more, the last one says "N more in the radar".
Tips. Mention a role when it matters: Discord webhooks cannot ping on their own, so if you want "@community-team" on every alert, route through n8n (next section) and prepend the mention. Mute the channel for people who do not comment. If the test answers 400, the format is not Discord: pick it in the selector and save again. If it answers 404, the webhook was deleted in Discord: make a new one.
Slack
- Go to api.slack.com/apps β Create New App β From scratch β name "ViralHunt", pick your workspace.
- Incoming Webhooks β turn it On β Add New Webhook to Workspace β choose the channel β Allow. Copy the URL:
https://hooks.slack.com/services/Tβ¦/Bβ¦/β¦. - Paste it in Projects β your brand β Webhook (format switches to Slack), Save, Send a test.
What the channel gets: a header line, one section per thread (title linked, community, comment gain, network) and a context line with the radar link. Slack unfurls the first link into a preview of the thread; turn unfurling off in the app's settings if the channel gets noisy.
n8n, Make, Zapier (and any automation tool)
These tools give you a URL that waits for a POST and starts a flow with its body. Keep the format JSON: every field of the alert becomes a variable you can map into any step: a Telegram message, a WhatsApp template, an email, a Google Sheet row, a Notion page, a Jira ticket, a call to OpenAI or Claude with your brand's voice, or a call back to the ViralHunt API.
- n8n: add a Webhook node (HTTP method POST, path
viralhunt, Respond: Immediately). Copy its Production URL, paste it in Projects, Save, Send a test: the node shows the sample body, and every later node sees{{ $json.body.alerts }}. Activate the workflow (the Test URL only listens while you watch). - Make: scenario β Webhooks β Custom webhook β copy the address β paste in Projects β Send a test so Make learns the structure β add the modules (Telegram Bot, Gmail, Google Sheetsβ¦) and map the fields. Run the scenario on "immediately".
- Zapier: trigger Webhooks by Zapier β Catch Hook β copy the URL β paste β Send a test β "Test trigger" finds it β add the actions.
Verify the signature there too when the flow does anything that costs money or posts in public: a Crypto node (n8n) or a Code step computing sha256 HMAC(raw body, secret) and comparing it with the X-VH-Signature header. For a channel that only reads, the obscurity of the URL is usually enough.
Alerts split into one item per thread: the body carries an alerts array (one to ten). In n8n add a Split Out node on body.alerts; in Make an Iterator; in Zapier a Looping step. Then each message, row or ticket is one thread.
Telegram, WhatsApp, Teams, email
These have no plain incoming webhook that takes our JSON, so one hop in between is needed: the fastest is n8n or Make (previous section: Webhook node β Telegram node, five minutes, no code). Microsoft Teams has incoming webhooks through Power Automate ("When a Teams webhook request is received"), which also takes any JSON. Email you already have, from ViralHunt itself (Notifications β Brand alerts).
If you run your own server anyway, a Telegram relay is a dozen lines: a bot token from @BotFather, your chat id, and one call per alert.
// after verifying the signature (see "Your own server" above):
for (const a of event.alerts) {
const text = `π₯ <b>${event.project.name}</b>\n<a href="${a.url}">${a.title}</a>\n${a.author} Β· ${a.comments_from} β ${a.comments_to} comments (+${a.delta} in ${a.hours} h)`;
await fetch(`https://api.telegram.org/bot${process.env.TG_TOKEN}/sendMessage`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chat_id: process.env.TG_CHAT_ID, text, parse_mode: "HTML", disable_web_page_preview: false })
});
}Your own server or agent
Any HTTPS endpoint on a public host. Three rules: verify the signature (so nobody can feed your agent fake alerts), answer 2xx within 8 seconds and do the work after (ViralHunt does not retry; what you miss is always readable through the API), and ignore brand.test. Three minimal receivers:
Node.js (Express)
import express from "express";
import crypto from "crypto";
const app = express();
const SECRET = process.env.VH_WEBHOOK_SECRET; // the secret shown in Projects after saving the URL
app.post("/viralhunt", express.raw({ type: "application/json" }), (req, res) => {
const sig = req.get("X-VH-Signature") || "";
const mac = "sha256=" + crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");
if (sig.length !== mac.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(mac))) {
return res.status(401).end(); // not ours: ignore it
}
res.status(200).end(); // answer FIRST: ViralHunt waits 8 s and does not retry
const event = JSON.parse(req.body.toString("utf8"));
if (event.event === "brand.test") return; // the test button
for (const alert of event.alerts) handle(event.project, alert); // your work, after the answer
});
app.listen(3000);Python (Flask)
import hmac, hashlib, os, threading
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["VH_WEBHOOK_SECRET"].encode() # the secret shown in Projects after saving the URL
@app.post("/viralhunt")
def viralhunt():
raw = request.get_data()
mac = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(mac, request.headers.get("X-VH-Signature", "")):
abort(401) # not ours
event = request.get_json(force=True)
if event.get("event") != "brand.test":
threading.Thread(target=handle, args=(event,)).start() # the work runs after the answer
return "", 200 # ViralHunt waits 8 s and does not retryPHP
<?php
$raw = file_get_contents('php://input');
$mac = 'sha256=' . hash_hmac('sha256', $raw, getenv('VH_WEBHOOK_SECRET')); // the secret shown in Projects
if (!hash_equals($mac, $_SERVER['HTTP_X_VH_SIGNATURE'] ?? '')) { http_response_code(401); exit; }
http_response_code(200);
if (function_exists('fastcgi_finish_request')) fastcgi_finish_request(); // answer first, work after
$event = json_decode($raw, true);
if (($event['event'] ?? '') === 'brand.test') exit;
foreach ($event['alerts'] as $alert) handle($event['project'], $alert);Local development: ViralHunt refuses private and local addresses, so expose your machine with a tunnel (ngrok http 3000, cloudflared tunnel --url http://localhost:3000) and paste the https URL it gives you. Claude Code, OpenClaw and agents that run on your machine work the same way: the tunnel brings the POST to a small local receiver that queues the alert for the agent's next turn. claude.ai or ChatGPT in the browser cannot receive a webhook at all: for those, poll (section 5) or let n8n be the receiver and hand them the summary.
4. What arrives: the payload, the headers, the signature
Format JSON (the one an agent or an automation reads). Discord and Slack get the same facts as a message.
POST https://your-server.example.com/viralhunt Content-Type: application/json User-Agent: ViralHunt-Webhooks/1.0 X-VH-Event: brand.alerts (or brand.test for the test button) X-VH-Project: 7 (the project id = the brand) X-VH-Delivery: 9f1c0a7e3b2d4c58 (unique per delivery; use it to ignore a repeat) X-VH-Signature: sha256=3f7aβ¦e21b (HMAC-SHA256 of the raw body with your secret, hex)
{
"event": "brand.alerts",
"test": false,
"project": { "id": 7, "name": "BrandiZone" },
"alerts": [
{
"id": 4182,
"platform": "reddit",
"title": "Has anyone tried the new organic argan oil from�",
"url": "https://www.reddit.com/r/HaircareScience/comments/β¦",
"author": "r/HaircareScience",
"comments_from": 12,
"comments_to": 31,
"delta": 19,
"hours": 8,
"born_hot": false,
"posted_at": "2026-10-26T06:10:00Z",
"sent_at": "2026-10-26T14:30:12Z"
}
],
"radar_url": "https://viralhunt.io/tool/trending/?project_id=7&sort=rising_comments&time_range=24h",
"sent_at": "2026-10-26T14:30:12Z"
}| Field | Meaning |
|---|---|
event | brand.alerts or brand.test. Branch on it first. |
project | The brand: its id (the project_id you use in every API call) and name. |
alerts[].id | The alert's id: pass it to alerts_read when your agent has handled it, so unread=1 stops listing it. |
platform | reddit, quora, x, instagram, tiktok, bluesky. |
title, url, author | The thread. author is the community on Reddit (r/β¦), the handle elsewhere. |
comments_from, comments_to, delta, hours | Comments at the older reading and now, the gain, and the span between the two readings. delta is the number to sort or filter by. |
born_hot | true when the thread was found already loud (one reading, 50 or more comments); then comments_from is 0. |
posted_at, sent_at | When the thread was posted (UTC, may be null) and when this delivery was made. |
radar_url | Your radar, filtered by this brand and sorted by comments climbing: the same threads with everything around them. |
Verifying the signature
X-VH-Signature is sha256= followed by the hex HMAC-SHA256 of the raw request body (the exact bytes, before any JSON parsing) with the secret shown in Projects. Compute the same on your side and compare with a constant-time function (timingSafeEqual, hmac.compare_digest, hash_equals). A mismatch means the POST did not come from ViralHunt or the body was altered on the way: ignore it. The secret is created with the URL and kept while a URL is set; to rotate it, clear the URL, save, paste it again, save: a new secret appears.
5. What to do with it: the possibilities
π A war room channel
The simplest and the most used: a Discord or Slack channel per brand. The person on duty sees the thread, reads it, comments as the brand. Nothing to build.
π€ The agent proposes the comment
Your agent receives the alert, reads the thread, writes a comment in the brand's voice and leaves it as a card on the Editorial Board with the link. A person pastes it. The networks stay the person's; the agent never posts there.
π From the thread to a post
A question that is climbing is a post the brand should make. The agent drafts it (schedule.php create with draft: true), on one of your templates, and it waits in Drafts for your OK.
π― Only what matters
Filter in the flow: delta β₯ 20, or born_hot, or platform = reddit. Big gains to the channel with a mention; the rest to a quiet log.
π A log in a sheet
Make or Zapier append one row per thread to Google Sheets: date, network, title, link, gain. Your weekly report of where the brand was talked about, with no one copying links.
π§ Ask a model, in the brand's voice
In n8n: Webhook β HTTP Request to the Claude or OpenAI API with the thread and the brand's voice (from schedule.php?action=targets) β post the three suggested replies to the channel. The person picks one.
π’ Many brands, one receiver
An agency sets the same URL on every project and routes by X-VH-Project or project.name: each brand to its channel, its sheet, its client.
π Close the loop
When the comment is posted, mark the alert taken (alerts_read). unread=1 then lists only what nobody handled, and the daily digest is the check that nothing fell through.
The agent recipe, call by call
What a receiver does with one alert when it is an agent with a ViralHunt token (all four calls are in the API reference; the skill and the MCP have them as tools).
# 1) context: the thread and what else is climbing for this brand
GET https://viralhunt.io/tool/api/v1/brand.php?action=hot&project_id=7&hours=8
# 2) the brand's voice (write the comment in it)
GET https://viralhunt.io/tool/api/v1/schedule.php?action=targets β projects[].voice
# 3) leave the proposed comment where the team works: a board card with the link
POST https://viralhunt.io/tool/api/v1/cards.php
{"title": "Comment in r/HaircareScience: argan oil thread (+19 comments)",
"description": "Proposed comment:\n\n<the comment, in the brand's voice, facts first, no link unless asked>\n\nThread: https://www.reddit.com/r/β¦",
"post_url": "https://www.reddit.com/r/β¦", "platform": "reddit", "priority": "high", "assigned_to": "Ana"}
# 4) mark the alert as taken, so nobody handles it twice
POST https://viralhunt.io/tool/api/v1/brand.php {"action": "alerts_read", "project_id": 7, "ids": [4182]}Prompt for an agent that already has the skill: "When my webhook receiver hands you a brand.alerts event, for each alert read the thread, write one comment in my brand's voice (facts first, no link unless the thread asks for one), create a board card titled with the community and the gain, assigned to Ana, with the comment and the link in the description, then mark the alert read. Never comment on the network yourself."
6. Polling instead (when nothing can receive a POST)
An agent in a browser, a cron on a machine behind a firewall, a script with no public URL: ask instead of listening. The alert cron runs every 30 minutes, so asking every 15 to 30 minutes sees everything and asking every minute sees nothing new. Use since = the time of your last read and unread=1, and mark what you took.
GET https://viralhunt.io/tool/api/v1/brand.php?action=alerts&project_id=7&since=2026-10-26T14:00:00Z&unread=1
Authorization: Bearer vhk_β¦
β the alerts written after `since` that nobody marked as taken, newest first
POST https://viralhunt.io/tool/api/v1/brand.php
{"action": "alerts_read", "project_id": 7, "ids": [4182, 4183]}GET brand.php?action=status says per network when the last search ran (last_run) and whether the always-on machine is alive: when it is not, only Reddit is being searched and an empty answer means "nobody looked", not "nobody talked". GET brand.php?action=hot&project_id=N&all=1 is the live list (climbing first, then most commented) without waiting for an alert.
7. Security
- https only, public host only. A private, local or unresolvable address is refused when you save, and again before every delivery; redirects are not followed.
- The signature is your proof of origin. Verify it on any receiver that acts (posts, spends, writes to your systems). A Discord or Slack channel that only displays can live without it.
- The secret is shown to owners and admins in Projects and through the API; members do not see it. Rotate it by clearing and re-saving the URL.
- Treat a Discord or Slack webhook URL as a password: whoever has it can post in that channel. Keep it in the field and in nothing else.
- We never send your API tokens, your account credentials or your other brands' data: a delivery carries that brand's threads and a radar link, nothing else.
- Idempotency:
X-VH-Deliveryis unique per POST. If your receiver might be called twice (a proxy that retries), remember the last few ids and skip repeats.
8. When it does not work
| What you see | What it means and what to do |
|---|---|
| The test says "no answer within 8 seconds" | The URL did not resolve, refused the connection, or your server did the work before answering. Answer 200 first; check the tunnel is up; check the host is reachable from the internet, not only from your network. |
| The test says 400 or 422 | The receiver did not like the body. For Discord or Slack the format is wrong: pick it in the selector and save. For n8n, the Webhook node may be set to GET or to a different path. |
| The test says 401, 403 or 404 | Wrong or dead URL: a Discord webhook deleted in Discord, a Slack app removed from the workspace, a path your server does not serve, or your own signature check rejecting the test (it is signed like every delivery; check the secret you configured is the current one). |
| The test works, no alert ever arrives | Three honest reasons, in order: the brand has no keywords (or too generic ones nobody discusses); no thread crossed the threshold (10 comments gained in 8 hours is a real conversation, not a mention); the always-on machine that searches X, Quora, Instagram and TikTok is off, so only Reddit is searched. status shows the second and third; the dashboard's brand card shows the same with a warning. |
| Alerts arrive twice in Discord | Two brands share keywords and the same channel, or the URL is set on two projects. Each project reports its own matches; give each brand its channel or route by project.name. |
| I missed a delivery | There is no retry by design (a retry storm is how duplicates happen), and nothing is lost: GET brand.php?action=alerts&since=β¦ has every alert ever written for the brand. |
status, keywords, hot, alerts, alerts_read, webhook, webhook_test, run. The skill (section 8d) and the MCP server (viralhunt_brand_* tools) give the same to any agent.Start with the keywords
Create an account, give your brand its keywords in Projects, paste the webhook URL in the same box and press Send a test.
Create a free account See the plans