Published on March 14, 2026
Automate Weekly AI Visibility Reports with Webhooks and the Sellm API
What you'll build: An automated pipeline that runs weekly AI visibility analysis, receives results via webhooks, detects meaningful changes, and sends Slack notifications and email summaries.
Manual AI search monitoring doesn't scale. If you're logging into a dashboard every week to check whether ChatGPT still recommends your brand, you're spending time that should be automated. Worse, you might miss a drop in visibility that happened on Tuesday and only notice it on Friday.
In this guide, you'll build a fully automated reporting pipeline using the Sellm API and webhooks. By the end, you'll have a system that triggers weekly analysis, processes results automatically when they're ready, alerts your team on Slack when visibility changes, and sends a weekly summary email. No polling. No manual checks.
What We'll Build
The finished system delivers weekly AI visibility reports with zero manual effort:
- Automated weekly analysis across ChatGPT, Claude, Perplexity, Gemini, Grok, and Copilot
- Webhook-driven processing - no polling loops, results arrive as soon as analysis completes
- Smart alerting - Slack notifications only when something meaningful changes
- Weekly summary emails with key metrics, trends, and competitor movements
Architecture Overview
The pipeline has five components connected by webhooks instead of polling:
Weekly Cron (schedule)
|
v
Sellm API (trigger analysis)
|
v
Webhook (run.completed event)
|
v
Processor (compare results, detect changes)
|
+---> Slack (real-time alerts for significant changes)
+---> Email (weekly summary report)
The key difference from a polling-based approach: your webhook receiver gets called automatically when analysis finishes. No need to check every 15 seconds whether the run is done.
Step 1: Set Up a Weekly Cron to Submit Analysis
First, create a scheduled job that triggers a Sellm analysis run every week. Here's a Python script using schedule:
import schedule
import requests
import time
import os
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("sellm-cron")
API_KEY = os.environ["SELLM_API_KEY"]
BASE_URL = "https://sellm.io/api/v1"
HEADERS = {
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
}
WEBHOOK_URL = os.environ["WEBHOOK_RECEIVER_URL"] # Your endpoint
def trigger_weekly_analysis():
"""Trigger a manual analysis run with webhook notification."""
try:
resp = requests.post(
f"{BASE_URL}/async-analysis",
headers=HEADERS,
json={
"prompt": "best project management tool for remote teams",
"providers": ["chatgpt", "claude", "perplexity", "gemini", "grok", "copilot"],
"country": "us",
"replicates": 10,
"webhookUrl": WEBHOOK_URL,
},
)
resp.raise_for_status()
analysis = resp.json()["data"]
logger.info(f"Analysis triggered: {analysis['analysisId']} (status: {analysis['status']})")
except requests.exceptions.HTTPError as e:
logger.error(f"Failed to trigger analysis: {e.response.status_code} {e.response.text}")
except Exception as e:
logger.error(f"Unexpected error: {e}")
# Run every Monday at 8:00 AM UTC
schedule.every().monday.at("08:00").do(trigger_weekly_analysis)
logger.info("Scheduler started. Waiting for Monday 08:00 UTC...")
while True:
schedule.run_pending()
time.sleep(60)
You can also run this as a cron job instead of a long-running process:
# crontab -e
# Every Monday at 8:00 AM UTC
0 8 * * 1 SELLM_API_KEY=sellm_xxx WEBHOOK_RECEIVER_URL=https://your-server.com/webhook python3 /path/to/trigger.py
Alternatively, if you have Sellm's scheduled runs enabled, the platform triggers analysis automatically every week. In that case, skip this step and just configure your webhook URL in the project settings.
Step 2: Configure Webhooks in the API Request
When triggering an analysis, pass a webhookUrl so Sellm knows where to send results:
curl -X POST https://sellm.io/api/v1/async-analysis \
-H "Authorization: Bearer sellm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"prompt": "best project management tool for remote teams",
"providers": ["chatgpt", "claude", "perplexity", "gemini", "grok", "copilot"],
"country": "us",
"replicates": 10,
"webhookUrl": "https://your-server.com/api/webhook/sellm"
}'
When the analysis finishes, Sellm sends a POST request to your URL with the full results:
{
"event": "analysis.completed",
"timestamp": "2026-03-14T08:42:31Z",
"data": {
"analysisId": "analysis_abc123",
"status": "completed",
"summary": {
"sovPct": 34.2,
"coveragePct": 78.5,
"avgPos": 2.1,
"sentiment": 7.8
},
"providerBreakdown": {
"sovByProvider": { "chatgpt": 41.0, "claude": 35.5, "perplexity": 28.0 },
"coverageByProvider": { "chatgpt": 85.0, "claude": 80.0, "perplexity": 70.0 },
"sentimentByProvider": { "chatgpt": 8.1, "claude": 7.5, "perplexity": 7.2 }
},
"promptBreakdown": [],
"results": []
}
}
Step 3: Build a Webhook Receiver
Here's an Express.js webhook receiver with HMAC signature verification:
const express = require("express");
const crypto = require("crypto");
const app = express();
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
const SELLM_API_KEY = process.env.SELLM_API_KEY;
// Parse raw body for signature verification
app.use("/api/webhook/sellm", express.raw({ type: "application/json" }));
function verifySignature(payload, signature) {
const expected = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(payload)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(signature, "hex"),
Buffer.from(expected, "hex")
);
}
app.post("/api/webhook/sellm", async (req, res) => {
// 1. Verify the webhook signature
const signature = req.headers["x-sellm-signature"];
if (!signature || !verifySignature(req.body, signature)) {
console.error("Invalid webhook signature");
return res.status(401).json({ error: "Invalid signature" });
}
// 2. Parse the event
const event = JSON.parse(req.body.toString());
console.log(`Received event: ${event.event} for analysis ${event.data.analysisId}`);
// 3. Acknowledge immediately (process async)
res.status(200).json({ received: true });
// 4. Process the event
if (event.event === "analysis.completed") {
try {
await processCompletedAnalysis(event.data);
} catch (err) {
console.error("Error processing analysis:", err);
}
}
if (event.event === "analysis.failed") {
await sendSlackAlert(
"Analysis Failed",
`Analysis ${event.data.analysisId} failed. Check the Sellm dashboard for details.`
);
}
});
async function processCompletedAnalysis(data) {
// The webhook payload already contains the full results.
// You can also re-fetch via GET /v1/async-analysis/{analysisId}
const { analysisId, summary, providerBreakdown, promptBreakdown } = data;
// Detect changes and send alerts
await detectChangesAndAlert(analysisId, summary, providerBreakdown, promptBreakdown);
// Store for weekly email
await storeRunResults(analysisId, summary, providerBreakdown, promptBreakdown);
}
app.listen(3000, () => console.log("Webhook receiver running on port 3000"));
Key points about the receiver:
- Acknowledge fast. Return 200 immediately and process results asynchronously. Webhook senders typically retry if they don't get a response within a few seconds.
- Verify signatures. Use
crypto.timingSafeEqualto prevent timing attacks when comparing HMAC digests. - Handle retries. Make your processing idempotent so that duplicate deliveries don't cause duplicate alerts.
Step 4: Process Results and Detect Changes
The most valuable part of automation is knowing when something changed. Here's how to compare the current run against the previous one:
// In-memory store for simplicity; use a database in production
let previousRun = null;
async function detectChangesAndAlert(analysisId, summary, providerBreakdown, promptBreakdown) {
const alerts = [];
if (previousRun) {
const prev = previousRun.summary;
// SOV dropped more than 5 percentage points
const sovDelta = summary.sovPct - prev.sovPct;
if (sovDelta < -5) {
alerts.push({
type: "sov_drop",
message: `Share of Voice dropped ${Math.abs(sovDelta).toFixed(1)}pp (${prev.sovPct}% -> ${summary.sovPct}%)`,
severity: "high",
});
}
// Position worsened by more than 1 spot
const posDelta = summary.avgPos - prev.avgPos;
if (posDelta > 1) {
alerts.push({
type: "position_worse",
message: `Average position worsened by ${posDelta.toFixed(1)} (${prev.avgPos.toFixed(1)} -> ${summary.avgPos.toFixed(1)})`,
severity: "medium",
});
}
// Coverage dropped more than 10 percentage points
const covDelta = summary.coveragePct - prev.coveragePct;
if (covDelta < -10) {
alerts.push({
type: "coverage_drop",
message: `Coverage dropped ${Math.abs(covDelta).toFixed(1)}pp (${prev.coveragePct}% -> ${summary.coveragePct}%)`,
severity: "high",
});
}
// Check for new competitors in prompt results
const prevCompetitors = new Set(
previousRun.promptBreakdown.flatMap((p) => p.topCompetitors || [])
);
const newCompetitors = promptBreakdown
.flatMap((p) => p.topCompetitors || [])
.filter((c) => !prevCompetitors.has(c));
if (newCompetitors.length > 0) {
const unique = [...new Set(newCompetitors)];
alerts.push({
type: "new_competitor",
message: `New competitor(s) detected: ${unique.join(", ")}`,
severity: "medium",
});
}
// Per-provider changes
const prevSovByProvider = previousRun.providerBreakdown.sovByProvider || {};
for (const [provider, currentSov] of Object.entries(providerBreakdown.sovByProvider || {})) {
const prevSov = prevSovByProvider[provider];
if (prevSov !== undefined) {
const pSovDelta = currentSov - prevSov;
if (pSovDelta < -10) {
alerts.push({
type: "provider_drop",
message: `${provider}: SOV dropped ${Math.abs(pSovDelta).toFixed(1)}pp (${prevSov}% -> ${currentSov}%)`,
severity: "medium",
});
}
}
}
}
// Send alerts to Slack
if (alerts.length > 0) {
await sendSlackAlerts(alerts);
} else {
console.log("No significant changes detected.");
}
// Update previous run reference
previousRun = { summary, providerBreakdown, promptBreakdown };
}
Step 5: Send Slack Notifications for Significant Changes
Format alerts as rich Slack messages so your team can quickly scan what changed:
const SLACK_WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL;
async function sendSlackAlert(title, message) {
if (!SLACK_WEBHOOK_URL) return;
await fetch(SLACK_WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
blocks: [
{ type: "header", text: { type: "plain_text", text: title } },
{ type: "section", text: { type: "mrkdwn", text: message } },
],
}),
});
}
async function sendSlackAlerts(alerts) {
const severityEmoji = { high: "!!!", medium: "!", low: "~" };
const sections = alerts.map((a) => ({
type: "section",
text: {
type: "mrkdwn",
text: `*[${a.severity.toUpperCase()}]* ${a.message}`,
},
}));
await fetch(SLACK_WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
blocks: [
{
type: "header",
text: { type: "plain_text", text: "AI Visibility Alert" },
},
{
type: "section",
text: {
type: "mrkdwn",
text: `*${alerts.length} change(s) detected* in your latest AI search analysis.`,
},
},
{ type: "divider" },
...sections,
{ type: "divider" },
{
type: "actions",
elements: [
{
type: "button",
text: { type: "plain_text", text: "View Dashboard" },
url: "https://sellm.io/dashboard",
},
],
},
],
}),
});
}
Example Slack message:
AI Visibility Alert
3 change(s) detected in your latest AI search analysis.
---
[HIGH] Share of Voice dropped 7.2pp (34.2% -> 27.0%)
[MEDIUM] Average position worsened by 1.3 (2.1 -> 3.4)
[MEDIUM] New competitor(s) detected: RivalCo, NewBrand
---
[View Dashboard]
Step 6: Build a Weekly Summary Email
In addition to real-time Slack alerts, send a weekly email digest with the full picture. Here's a function that generates the email content:
function buildWeeklySummaryEmail(summary, providerBreakdown, promptBreakdown, previousSummary) {
const sovDelta = previousSummary
? (summary.sovPct - previousSummary.sovPct).toFixed(1)
: "N/A";
const covDelta = previousSummary
? (summary.coveragePct - previousSummary.coveragePct).toFixed(1)
: "N/A";
const posDelta = previousSummary
? (previousSummary.avgPos - summary.avgPos).toFixed(1)
: "N/A";
// Build provider rows from the breakdown maps
const providers = Object.keys(providerBreakdown.sovByProvider || {});
const providerRows = providers
.sort((a, b) => (providerBreakdown.sovByProvider[b] || 0) - (providerBreakdown.sovByProvider[a] || 0))
.map(
(p) =>
`
${p}
${providerBreakdown.sovByProvider[p]}%
${providerBreakdown.coverageByProvider[p]}%
${providerBreakdown.sentimentByProvider[p]?.toFixed(1) || "N/A"}/10
`
)
.join("");
// Find the prompts where you have the lowest visibility
const weakPrompts = [...promptBreakdown]
.sort((a, b) => (a.sovPct || 0) - (b.sovPct || 0))
.slice(0, 5);
const weakRows = weakPrompts
.map(
(p) =>
`
${p.sovPct || 0}%
${(p.topCompetitors || []).slice(0, 3).join(", ") || "None"}
`
)
.join("");
return `
Weekly AI Visibility Report
Here's how your brand performed across AI search platforms this week.
Share of Voice
${summary.sovPct}%
${parseFloat(sovDelta) >= 0 ? '+' : ''}${sovDelta}pp vs last week
Coverage
${summary.coveragePct}%
${parseFloat(covDelta) >= 0 ? '+' : ''}${covDelta}pp vs last week
Average Position
${summary.avgPos.toFixed(1)}
${parseFloat(posDelta) >= 0 ? '+' : ''}${posDelta} vs last week
Sentiment
${summary.sentiment.toFixed(1)}/10
Provider Breakdown
Provider
SOV
Coverage
Sentiment
${providerRows}
Weakest Prompts
These prompts have the lowest visibility. Consider optimizing your content for them.
Prompt
SOV
Competitors Ahead
${weakRows}
`;
}
Send it using any transactional email service (Resend, SendGrid, AWS SES). Here's a minimal example with Resend:
const { Resend } = require("resend");
const resend = new Resend(process.env.RESEND_API_KEY);
async function sendWeeklyEmail(summary, providerBreakdown, promptBreakdown, previousSummary) {
const html = buildWeeklySummaryEmail(summary, providerBreakdown, promptBreakdown, previousSummary);
await resend.emails.send({
from: "reports@yourdomain.com",
to: ["team@yourdomain.com"],
subject: `AI Visibility Report - SOV: ${summary.sovPct}% | Week of ${new Date().toLocaleDateString()}`,
html,
});
}
Alert Rules Reference
Here are the recommended thresholds for automated alerts. Tune these based on your brand's typical week-over-week variance:
| Rule | Condition | Severity | Why It Matters |
|---|---|---|---|
| SOV Drop | Share of Voice drops >5pp | High | Your competitors are gaining ground in AI responses. Investigate which prompts and providers shifted. |
| Position Worsened | Average position increases >1 spot | Medium | Your brand is being mentioned later in AI responses. Being mentioned first vs. third has a meaningful trust impact. |
| New Competitor | Brand appears that wasn't in previous results | Medium | A new player is entering AI recommendations in your category. Worth monitoring early. |
| Coverage Drop | Coverage drops >10pp | High | Fewer prompts mention your brand at all. This could indicate a model update or content issue. |
| Provider-Specific Drop | SOV on a single provider drops >10pp | Medium | One AI platform changed how it references your brand. Useful for isolating platform-specific issues. |
| Sentiment Drop | Sentiment score drops >1 point | Low | AI platforms are describing your brand less favorably. Check for recent negative press or reviews. |
Pricing
Automating AI visibility reports with webhooks is cost-effective at any scale:
- Paid plans: Automated scheduled runs with weekly prompt quotas. Each prompt analysis costs less than 1 cent. Monitoring 50 prompts weekly across 3 providers = under $5/week.
The webhook-based approach has no additional cost. You're using the same API calls you'd make with polling, but without the overhead of repeated status checks.
Start Automating Your AI Visibility Reports
Set up your automated pipeline today and never miss a change in how AI platforms recommend your brand.
Get StartedFrequently Asked Questions
Do I need a paid plan to use webhooks?
No. Webhooks are available on all plans. Webhook delivery works the same way regardless of which plan you are on.
What happens if my webhook endpoint is down?
Sellm retries webhook deliveries with exponential backoff for up to 24 hours. If all retries fail, you can still fetch results by polling the API. The data is always available via GET /v1/async-analysis/{'{'}analysisId{'}'}.
Can I send webhooks to multiple endpoints?
You can configure one webhook URL per analysis trigger. If you need to fan out to multiple systems, have your webhook receiver forward events to additional endpoints (Slack, email, data warehouse) as shown in this guide.
How do I verify webhook signatures?
Each webhook request includes an X-Sellm-Signature header containing an HMAC-SHA256 digest of the request body, signed with your webhook secret. Compute the same HMAC on your end and compare using a constant-time comparison function.
Can I use this with Sellm's scheduled runs instead of triggering manually?
Yes. If you have weekly scheduled runs enabled in your project settings, you can configure a webhook URL there instead of passing it in the trigger request. The webhook fires every time a scheduled run completes, no cron job needed on your end.