TUTORIAL

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:

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:

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.prompt}
          ${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

${providerRows}
Provider SOV Coverage Sentiment

Weakest Prompts

These prompts have the lowest visibility. Consider optimizing your content for them.

${weakRows}
Prompt SOV Competitors Ahead
`; }

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:

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 Started

Frequently 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.