Pro · AI crawler tracking

See when AI reads your site

Earning mentions in communities gets you cited. This shows the other half of the loop: which AI crawlers actually request your pages, and why they came. A training crawler collecting content is a very different event from ChatGPT fetching your pricing page to answer someone who is asking about your space right now.

How it works

You add a few lines to your server so that when a known AI crawler requests a page, your server sends us the user-agent and the path. We identify the crawler, bucket it by purpose, and count it against the day. The card in your dashboard shows the last 7 days by category, per bot, and which pages get crawled most.

It has to be server-side. AI crawlers request raw HTML and mostly don't run JavaScript, so a client-side analytics snippet never sees them. That's why this is a middleware change rather than a script tag.

Never await the tracking call. Fire it and return your response — on Vercel and Cloudflare, hand it to waitUntil. If you await it, you add our round-trip to every crawler request on your site.

Install

  1. Mint an API key on the Developer page in the app. Keys start with mlk_ and are shown once — the same key the MCP server uses. Put it in your environment as MENTIONLEADS_API_KEY.
  2. Add the snippet for your stack below.
  3. Deploy, then wait. Crawlers arrive on their own schedule — usually within a day for an indexed site. The dashboard card fills in as hits land.

Next.js (App or Pages Router)

// middleware.ts
import { NextResponse, type NextFetchEvent, type NextRequest } from 'next/server';

const AI_BOTS = /ChatGPT-User|Claude-User|Claude-Web|Perplexity-User|MistralAI-User|DuckAssistBot|OAI-SearchBot|PerplexityBot|GPTBot|ClaudeBot|anthropic-ai|Google-Extended|GoogleOther|Googlebot|bingbot|CCBot|Bytespider|meta-externalagent|FacebookBot|Applebot|Amazonbot|YouBot|cohere-ai|Diffbot/i;

export function middleware(req: NextRequest, event: NextFetchEvent) {
  const ua = req.headers.get('user-agent') || '';
  if (AI_BOTS.test(ua)) {
    // waitUntil keeps the request alive for the background call without
    // making the crawler (or a real visitor) wait for it.
    event.waitUntil(
      fetch('https://www.mentionleads.com/api/bots/track', {
        method: 'POST',
        headers: {
          'content-type': 'application/json',
          authorization: `Bearer ${process.env.MENTIONLEADS_API_KEY}`,
        },
        body: JSON.stringify({ userAgent: ua, href: req.url }),
      }).catch(() => {}),
    );
  }
  return NextResponse.next();
}

// Skip static assets so you only track real page requests.
export const config = { matcher: '/((?!_next/static|_next/image|favicon.ico).*)' };

Express / Node

const AI_BOTS = /ChatGPT-User|Claude-User|Claude-Web|Perplexity-User|MistralAI-User|DuckAssistBot|OAI-SearchBot|PerplexityBot|GPTBot|ClaudeBot|anthropic-ai|Google-Extended|GoogleOther|Googlebot|bingbot|CCBot|Bytespider|meta-externalagent|FacebookBot|Applebot|Amazonbot|YouBot|cohere-ai|Diffbot/i;

app.use((req, res, next) => {
  const ua = req.get('user-agent') || '';
  if (AI_BOTS.test(ua)) {
    // No await: the response goes out immediately either way.
    fetch('https://www.mentionleads.com/api/bots/track', {
      method: 'POST',
      headers: {
        'content-type': 'application/json',
        authorization: `Bearer ${process.env.MENTIONLEADS_API_KEY}`,
      },
      body: JSON.stringify({
        userAgent: ua,
        href: `${req.protocol}://${req.get('host')}${req.originalUrl}`,
      }),
    }).catch(() => {});
  }
  next();
});

Cloudflare Workers

const AI_BOTS = /ChatGPT-User|Claude-User|Claude-Web|Perplexity-User|MistralAI-User|DuckAssistBot|OAI-SearchBot|PerplexityBot|GPTBot|ClaudeBot|anthropic-ai|Google-Extended|GoogleOther|Googlebot|bingbot|CCBot|Bytespider|meta-externalagent|FacebookBot|Applebot|Amazonbot|YouBot|cohere-ai|Diffbot/i;

export default {
  async fetch(request, env, ctx) {
    const ua = request.headers.get('user-agent') || '';
    if (AI_BOTS.test(ua)) {
      ctx.waitUntil(
        fetch('https://www.mentionleads.com/api/bots/track', {
          method: 'POST',
          headers: {
            'content-type': 'application/json',
            authorization: `Bearer ${env.MENTIONLEADS_API_KEY}`,
          },
          body: JSON.stringify({ userAgent: ua, href: request.url }),
        }).catch(() => {}),
      );
    }
    return fetch(request);
  },
};

Any other backend

It's one authenticated POST. Send it from whatever you run — Go, Rails, Laravel, a reverse proxy, a log shipper reading your access logs after the fact.

curl -X POST https://www.mentionleads.com/api/bots/track \
  -H "Authorization: Bearer mlk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "userAgent": "Mozilla/5.0 (compatible; ChatGPT-User/1.0; +https://openai.com/bot)",
    "href": "https://yoursite.com/pricing"
  }'

# → { "tracked": true, "bot": "ChatGPT", "purpose": "answers" }
# → { "tracked": false }   when the user-agent isn't a known AI crawler

Request reference

FieldRequiredNotes
userAgentyesThe crawler's raw User-Agent. Falls back to the request's own UA header if omitted.
hrefyesFull URL of the page requested. We keep the host and path — the query string is dropped so tokens and emails in URLs never reach us. Send a full URL rather than a bare path if you track more than one site, since the host is what separates them.
AuthorizationyesHeader, Bearer mlk_…. Same key as the MCP server; revoke it on the Developer page at any time.

Responses are always 200 for an authenticated call: { tracked: true, bot, purpose } when we recognise the crawler, { tracked: false } when the user-agent is ordinary traffic. A tracker in your request path should never see an error it might surface to a visitor. Payloads are capped at 16 KB.

Tracking more than one site

One key covers as many sites as you like. Install the same snippet on each, and the dashboard groups by the host from href, with a selector to switch between them or view everything together. Agencies can run one key across every client site; www. is stripped so a site never splits in two.

What we identify

Grouped by why the crawler came, because that decides what the visit is worth to you. AI answers means a person is waiting on a reply. Indexing means you can be cited later. Training means your content shapes what a model knows about your category.

CrawlerUser-agent containsBucket
ChatGPTChatGPT-UserAI answers
ClaudeClaude-User, Claude-WebAI answers
PerplexityPerplexity-UserAI answers
Le Chat (Mistral)MistralAI-UserAI answers
DuckAssistDuckAssistBotAI answers
OpenAI SearchOAI-SearchBotIndexing
PerplexityBotPerplexityBotIndexing
GooglebotGooglebot, GoogleOtherIndexing
BingbotbingbotIndexing
ApplebotApplebotIndexing
GPTBotGPTBotTraining
ClaudeBotClaudeBot, anthropic-aiTraining
Google ExtendedGoogle-ExtendedTraining
Meta AImeta-externalagent, FacebookBotTraining
Common CrawlCCBotTraining
BytespiderBytespiderTraining
Applebot ExtendedApplebot-ExtendedTraining

Privacy

  • We store the path, never the query string, and never a request body.
  • No visitor IPs and no cookies — this only fires for crawler user-agents.
  • Hits are aggregated per bot, path and day, so there is no per-request log to leak.
  • A user-agent can be spoofed by anyone, so treat the numbers as a strong signal rather than proof. We don't claim verified-by-IP counts we can't stand behind.

Make sure they're allowed in

Tracking is pointless if your robots.txt blocks the crawlers you want citing you. If you see nothing after a few days, check that GPTBot, OAI-SearchBot, ClaudeBot and PerplexityBot are allowed. Blocking Google-Extended only opts you out of model training and does not affect Google Search ranking.

Get Pro and start trackingIncluded in Pro · $39/mo billed yearly