The Measurement API is the REST interface to everything Praised records: visibility scores, sampled answers, citations and fix-queue findings, ready to pull into your own stack. Every endpoint below is generated from the OpenAPI spec, so a client you generate stays in sync.
Loading the OpenAPI specification…
POST /v1/auth/api-token with X-API-Key: <your key>,
then send the token as Authorization: Bearer <token> on the calls below.
The key itself authenticates the platform API; this API keeps its own token table, so a key sent
straight here is rejected. Both shipped clients do the exchange for you.
Base URL: …
· Raw spec: openapi.json
429 with a Retry-After header; back off
that many seconds and continue. Real dashboards and connectors sit far under this.
Use Praised from Claude, Claude Code, or any MCP client — read your measurement data and work the action queue without leaving your editor.
npx line in
claude_desktop_config.json / .mcp.json):
claude mcp add praised \
--env PRAISED_API_KEY=<your org API key> \
-- npx -y @praised/mcp-server
# equivalent JSON for any MCP client
{
"mcpServers": {
"praised": {
"command": "npx",
"args": ["-y", "@praised/mcp-server"],
"env": { "PRAISED_API_KEY": "<your org API key>" }
}
}
}
The key comes from Settings → API keys and is exchanged for a short-lived token behind
the scenes, so nothing permanent travels to the measurement API. Every call is scoped to what you can
already see and do. Self-hosting? PLATFORM_URL,
MEASURE_API_URL and STUDIO_URL point it at your
own deployment.
GPTBot, ClaudeBot, PerplexityBot and friends fetch your pages before an engine can cite them.
POST /v1/crawler/ingest takes access-log lines and keeps only the AI-bot hits,
attributed to your tracked products. The fastest way to feed it is a Cloudflare Worker on your zone —
copy, set two values, deploy:
// wrangler secret put PRAISED_API_KEY (a key from Settings → API keys)
// Route: yourdomain.com/*
const AI_UA = /(GPTBot|OAI-SearchBot|ChatGPT|Claude|anthropic|Perplexity|Google-Extended|GoogleOther|Bytespider|Meta-ExternalAgent|FacebookBot|Applebot|Amazonbot|CCBot|cohere|DuckAssistBot|YouBot|PetalBot|Diffbot|Timpibot|MistralAI)/i;
export default {
async fetch(request, env, ctx) {
const response = await fetch(request);
const ua = request.headers.get("user-agent") || "";
if (AI_UA.test(ua)) {
ctx.waitUntil(fetch("https://api.praised.co/v1/crawler/ingest", {
method: "POST",
headers: { "content-type": "application/json", "x-api-key": env.PRAISED_API_KEY },
body: JSON.stringify({ hits: [{
user_agent: ua,
path: new URL(request.url).pathname,
status: response.status,
timestamp: new Date().toISOString(),
ip: request.headers.get("cf-connecting-ip"), // lets us VERIFY the bot
}] }),
}).catch(() => {}));
}
return response;
},
};
The regex is a coarse prefilter so ordinary visitor traffic never leaves your edge — exact bot detection
(vendor, purpose: training vs live answers vs search) happens server-side against the maintained registry.
Send ip. A User-Agent is a string anyone can type. The address
it arrived from is the only thing that can prove the bot was real: we check it against the vendors' published
IP ranges and, for Google and Apple, forward-confirmed reverse DNS. Hits with no address are labelled
unverifiable — never spoofed, because not checking is not the same as failing. The address
is used for that check and then discarded: there is no IP column in the crawler table, and a
test fails the build if one is ever added.
middleware.js) at the project
root. Runs before the response, so the status isn't known yet — the field is optional and the server records
the hit either way. waitUntil keeps it off your visitors' path:
// Set PRAISED_API_KEY in Project Settings → Environment Variables
const AI_UA = /(GPTBot|OAI-SearchBot|ChatGPT|Claude|anthropic|Perplexity|Google-Extended|GoogleOther|Bytespider|Meta-ExternalAgent|FacebookBot|Applebot|Amazonbot|CCBot|cohere|DuckAssistBot|YouBot|PetalBot|Diffbot|Timpibot|MistralAI)/i;
export const config = { matcher: "/:path*" };
export default function middleware(request, context) {
const ua = request.headers.get("user-agent") || "";
if (AI_UA.test(ua)) {
context.waitUntil(fetch("https://api.praised.co/v1/crawler/ingest", {
method: "POST",
headers: { "content-type": "application/json", "x-api-key": process.env.PRAISED_API_KEY },
body: JSON.stringify({ hits: [{
user_agent: ua,
path: new URL(request.url).pathname,
timestamp: new Date().toISOString(),
ip: (request.headers.get("x-forwarded-for") || "").split(",")[0].trim(),
}] }),
}).catch(() => {}));
}
}
netlify/edge-functions/praised.js).
The report is awaited only on bot requests — the regex gate means your visitors' requests never wait on it:
// netlify env:set PRAISED_API_KEY vis_…
const AI_UA = /(GPTBot|OAI-SearchBot|ChatGPT|Claude|anthropic|Perplexity|Google-Extended|GoogleOther|Bytespider|Meta-ExternalAgent|FacebookBot|Applebot|Amazonbot|CCBot|cohere|DuckAssistBot|YouBot|PetalBot|Diffbot|Timpibot|MistralAI)/i;
export default async (request, context) => {
const response = await context.next();
const ua = request.headers.get("user-agent") || "";
if (AI_UA.test(ua)) {
await fetch("https://api.praised.co/v1/crawler/ingest", {
method: "POST",
headers: { "content-type": "application/json", "x-api-key": Netlify.env.get("PRAISED_API_KEY") },
body: JSON.stringify({ hits: [{
user_agent: ua,
path: new URL(request.url).pathname,
status: response.status,
timestamp: new Date().toISOString(),
ip: context.ip, // lets us VERIFY the bot
}] }),
}).catch(() => {});
}
return response;
};
export const config = { path: "/*" };
#!/usr/bin/env node
// PRAISED_API_KEY=vis_… node ship-logs.mjs /var/log/nginx/access.log
import { readFileSync } from "node:fs";
const AI_UA = /(GPTBot|OAI-SearchBot|ChatGPT|Claude|anthropic|Perplexity|Google-Extended|GoogleOther|Bytespider|Meta-ExternalAgent|FacebookBot|Applebot|Amazonbot|CCBot|cohere|DuckAssistBot|YouBot|PetalBot|Diffbot|Timpibot|MistralAI)/i;
// combined log format: ip - - [time] "METHOD /path HTTP/x" status bytes "referer" "user-agent"
// the leading field is the client IP — that is what lets us VERIFY the bot
const LINE = /^(\S+) \S+ \S+ \[([^\]]+)\] "\S+ (\S+)[^"]*" (\d{3}) \S+ "[^"]*" "([^"]*)"/;
const hits = [];
for (const line of readFileSync(process.argv[2], "utf8").split("\n")) {
const m = LINE.exec(line);
if (!m || !AI_UA.test(m[5])) continue;
hits.push({ user_agent: m[5], path: m[3], status: Number(m[4]), ip: m[1] });
}
const BATCH = 1000; // request bodies are capped at 1 MB; 1,000 hits stays well under
for (let i = 0; i < hits.length; i += BATCH) {
const r = await fetch("https://api.praised.co/v1/crawler/ingest", {
method: "POST",
headers: { "content-type": "application/json", "x-api-key": process.env.PRAISED_API_KEY },
body: JSON.stringify({ hits: hits.slice(i, i + BATCH) }),
});
console.log(`shipped ${Math.min(i + BATCH, hits.length)}/${hits.length} — ${r.status}`);
}
Install it, then Settings → Praised: paste a key from Settings → API keys and press Test connection. That records a test hit straight away, so you find out in a second rather than waiting days for a real crawl.
One setting matters: Visitor address from. The address a request arrived from is the only
thing that can prove a crawler was genuine, and behind a CDN the address your server sees is the CDN's. Pick
the source your hosting actually provides — the settings screen shows what it currently resolves to, so you
can check it against your own address. Getting it wrong makes hits read can't verify, which is
honest; the plugin will not read the client-supplied end of X-Forwarded-For, because that would
let anyone forge an address inside a vendor's range and manufacture verified traffic in your reports.
Download the plugin · also on the WordPress plugin directory.
| Tool | What it does | Writes? |
|---|---|---|
list_projects | Every brand/product being tracked. | — |
get_project | One project: prompt counts, runs, latest run id. | — |
list_runs | Measurement runs, newest first, with actual cost. | — |
get_run_metrics | Every metric with its 95% CI and sample size. | — |
get_citations | Domains the engines cited, classified own/rival/third-party. | — |
get_benchmark | You vs the field: mention share and rank. | — |
get_hallucinations | Engine claims that contradict your approved facts. | — |
get_trends | Headline metrics over time, with noise-aware deltas. | — |
get_experiments | Measured lift from a specific content change. | — |
list_actions | The work queue: what to fix, ranked, with evidence. | — |
get_fix | Paste-ready JSON-LD / FAQ / intro for one page. | — |
check_ai_access | Whether AI crawlers can actually reach a site. | — |
run_site_audit | Crawl + audit a site, adding findings to the queue. | yes |
mark_action_fixed | Mark an item fixed — triggers an immediate re-check. | yes |
verify_actions | Re-check everything marked fixed. | yes |
list_actions, pulls the exact markup with
get_fix, applies it in your repo, then calls
mark_action_fixed — which re-audits the live page and reports back whether the
finding is genuinely gone. A fix that didn't land comes back as not cleared, with the reason, and
the item returns to the queue. The assistant never gets to mark its own homework.