ViralHunt/Agents/Webhooks

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.

1Give the brand its keywords in Projects. Without keywords nothing is watched, so nothing fires.
2Paste the destination URL in the same box (a Discord or Slack webhook, your n8n trigger, your server) and pick the format. Save.
3Press Send a test. A sample alert arrives; if it does not, the message under the button says why.
The networksReddit · Quora · XInstagram · TikTokBlueskyposts that mentionyour keywordsViralHuntsearches the keywordsevery few hoursevery 30 min: which threadsgained 10+ comments in 8 h→ one signed POST per runDiscord or Slack channela message per run, no coden8n · Make · Zapier→ Telegram, Sheets, email, a modelYour agent→ reads the thread, proposes the comment

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

EventWhenHow oftenWhat 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)

  1. Projects β†’ your brand β†’ the keywords button ("N keywords tracked" or "Add the keywords this brand owns").
  2. Write the keywords (one per line; #hashtags allowed) and tick the networks.
  3. 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.
  4. Save keywords. The secret appears under the field: copy it into your server's configuration (you never need it for Discord or Slack).
  5. 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.

  1. In Discord, open the channel where the alerts should land (make one: #brand-alerts). Channel settings (the gear) β†’ Integrations β†’ Webhooks β†’ New Webhook.
  2. 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.
  3. 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".

VHViralHuntAPPToday at 14:30
πŸ”₯ BrandiZone: 2 discussions heating up right now
Has anyone tried the new organic argan oil from…?
r/HaircareScience Β· 12 β†’ 31 comments (+19 in 8 h)
Reddit Β· ViralHunt
What is the best oil for frizzy hair in humid weather?
Quora Β· 0 β†’ 64 comments, born hot
Quora Β· ViralHunt

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

  1. Go to api.slack.com/apps β†’ Create New App β†’ From scratch β†’ name "ViralHunt", pick your workspace.
  2. Incoming Webhooks β†’ turn it On β†’ Add New Webhook to Workspace β†’ choose the channel β†’ Allow. Copy the URL: https://hooks.slack.com/services/T…/B…/….
  3. 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.

  1. 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).
  2. 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".
  3. 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:

0 sViralHunt POSTsyour receiver answers 2xx8 swe stop waitingthen you work: read the thread, draft, create the cardno answer in 8 s = the delivery is marked failed; there is no retry, and the alert stays in GET alerts

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 retry

PHP

<?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"
}
FieldMeaning
eventbrand.alerts or brand.test. Branch on it first.
projectThe brand: its id (the project_id you use in every API call) and name.
alerts[].idThe alert's id: pass it to alerts_read when your agent has handled it, so unread=1 stops listing it.
platformreddit, quora, x, instagram, tiktok, bluesky.
title, url, authorThe thread. author is the community on Reddit (r/…), the handle elsewhere.
comments_from, comments_to, delta, hoursComments 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_hottrue when the thread was found already loud (one reading, 50 or more comments); then comments_from is 0.
posted_at, sent_atWhen the thread was posted (UTC, may be null) and when this delivery was made.
radar_urlYour radar, filtered by this brand and sorted by comments climbing: the same threads with everything around them.

Verifying the signature

raw bodythe exact bytes receivedyour secretshown in ProjectsHMAC-SHA256hexsha256=3f7a…e21bwhat you computedcompare, constant timeX-VH-Signature: sha256=3f7a…e21bβœ“ ours: handle itβœ— not ours: ignore

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)

Webhook (push)ViralHuntone POST the minute an alert is writtenyour URLPolling (pull)ViralHuntGET alerts?since=…&unread=1 every 15 to 30 minonly what is new; then POST alerts_readyour script or agentalerts are computed every 30 minutes: asking every minute sees nothing new

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

8. When it does not work

What you seeWhat 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 422The 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 404Wrong 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 arrivesThree 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 DiscordTwo 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 deliveryThere 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.
Reference. Every call on this page is under Brand in the API reference: 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