The outbound webhook is the integration path from LLM Metrix to your own systems: a JSON POST to a URL you configure in Settings → Notifications. This reference documents the payload exactly as shipped, so a receiver can be built against the contract rather than against an example that drifted.
Delivery contract
- Every completed brand scan fires one POST. The webhook is an always-on data feed, not an alert: it fires even when the scan surfaced no findings, and it fires on every scan, scheduled or manual. A competitor-benchmark scan does not fire it; like every brand-facing surface, the webhook is gated to your own brand’s scans.
- It is per-user and per-URL. The URL lives on your personal notification settings; a teammate’s scans do not POST to your URL unless you configured it.
- The pause window does not silence it. When you pause alerts for a window, email, Slack and Teams stay quiet; the webhook does not. Its receiver is a system that filters, and it is the only channel that keeps recording findings for later.
- Deliveries are queued jobs with retries. A failed POST is retried with backoff, and the
failure and its retries are visible on Activity, where a stuck job can
be restarted. Retries and restarts mean the same payload can arrive more than once; make your
receiver idempotent, keying on
created_at(the scan timestamp) or the payload content. There is no idempotency key header, the same design trade-off Stripe’s webhook guide documents for its own deliveries. - Only
https://URLs are accepted (and the URL is validated before it is stored).
The payload
A single JSON object. Top-level fields:
| Field | Type | Meaning |
|---|---|---|
event |
string | Always "scan.completed". Lets one receiver dispatch multiple event types without parsing anything else. |
brand |
string | The project’s brand name. |
domain |
string | The tracked domain. |
score |
number | null | The 0–100 visibility score. null only when every engine call failed; an errored scan bills nothing and reports no score. |
summary |
string | null | The scan’s stored summary text. |
engine_results |
array | One object per engine that produced answers (see below). |
alerts |
string[] | The message strings of the scan’s deliverable findings (the same wording the Alerts feature shows), already filtered by your per-type modes and per-engine filter. Empty when nothing was found. Kept as strings so existing receivers do not break. |
alert_findings |
object[] | The same findings, same order, as objects with type, severity, delivery and message. alerts[i] equals alert_findings[i].message. Use this to filter without parsing the prose. Empty when nothing was found. |
created_at |
string | ISO-8601 timestamp of the scan, the idempotency key to deduplicate on. |
engine_results
One object per engine, in the order the engines were queried:
{
"engine": "chatgpt",
"label": "ChatGPT",
"score": 80,
"mentionRate": 1,
"avgRank": 1.5,
"sentiment": "positive",
"prompts": 2
}
engine: the stable id (chatgpt,perplexity,gemini,claude,grok,meta).score: the engine’s 0–100 score.mentionRate: the fraction of that engine’s answers that mentioned the brand (0–1).avgRank: the engine’s average answer position, ornullwhen it ranked nothing.sentiment:positive|neutral|negative.prompts: how many answers the engine delivered.
An engine whose calls all failed is omitted rather than reported as zeros: its absence means “not measured”, and treating it as a zero mention-rate would fire false negatives.
alert_findings
Parallel to alerts, same order, same messages. Each object is one deliverable finding:
{
"type": "ranking",
"severity": "warning",
"delivery": "immediate",
"message": "Perplexity did not mention your brand for any tracked query."
}
type:spike|hallucination|competitor|citation|ranking|prompt.severity:success|warning|info.delivery:immediate|digest|off, the recipient’s per-type mode at emit time.message: the same string as the matchingalertsentry.
These are the fields on the derived finding. The payload does not add extra keys here; claim quotes, engine ids and citation URLs stay off this object (see “What the payload does not carry”).
Full example
{
"event": "scan.completed",
"brand": "Acme",
"domain": "acme.com",
"score": 64,
"summary": "2 prompts across 2 engines.",
"engine_results": [
{ "engine": "chatgpt", "label": "ChatGPT", "score": 80, "mentionRate": 1, "avgRank": 1.5, "sentiment": "positive", "prompts": 1 },
{ "engine": "perplexity", "label": "Perplexity", "score": 48, "mentionRate": 0, "avgRank": null, "sentiment": "neutral", "prompts": 1 }
],
"alerts": [
"Perplexity did not mention your brand for any tracked query.",
"ChatGPT may state an inaccurate claim about your brand (e.g. \"Acme costs $99/mo\") — verify it."
],
"alert_findings": [
{
"type": "ranking",
"severity": "warning",
"delivery": "immediate",
"message": "Perplexity did not mention your brand for any tracked query."
},
{
"type": "hallucination",
"severity": "warning",
"delivery": "immediate",
"message": "ChatGPT may state an inaccurate claim about your brand (e.g. \"Acme costs $99/mo\") — verify it."
}
],
"created_at": "2026-08-11T08:00:00.000Z"
}
Slack and Teams
Slack and Teams are human channels and send rendered messages rather than this payload. Slack receives a plain-text message (the brand, domain and score, then one bullet per finding), and Teams receives a MessageCard with the same content plus a domain/score fact list. Both fire only when a scan surfaced a deliverable finding (a clean scan posts nothing), both respect the pause window, and both are configured by pasting an incoming-webhook URL. Nothing is sent to either until you configure it: there is no built-in Slack or Teams integration, and the platform’s own internal operations alerts go to a separate operator-managed Slack webhook that never mixes with customer channels.
What the payload does not carry
- Raw answer text. The webhook carries scores, mentions, alert messages and structured findings,
not the answers themselves. To read full answers from your own systems, the public API
exposes them:
GET /api/v1/scansreturns the same scan withprompt_results, and the webhook endpoints let you manage the delivery URL programmatically. - Citation URLs. If you need the cited domains and links, use the dashboard
or the per-scan email;
alerts/alert_findingsare the derived finding set, not the raw scan. - Verified claim quotes at scan time. Ground-truth re-checks run as a follow-up job, so
verificationStatus/evidenceQuote/sourceUrlon a hallucination alert are usually absent on this POST. They appear on the Alerts row after the check completes. - A signature. Every POST now carries an
X-LLMMetrix-Signatureheader so you can verify the payload came from us and was not replayed (#1297). The scheme is the Svix shape:t=<unix-seconds>,v1=<hex hmac-sha256>, where the MAC covers"<t>.<raw body>", i.e. the exact bytes on the wire. To verify: split the header on,, checktis within a few minutes of now, computehmac_sha256(your_secret, t + "." + rawBody)with your signing secret, and compare in constant time. Your secret is returned byGET /api/v1/webhooks(and minted automatically the first time a webhook URL is set); treat it like a password. Until your receiver verifies signatures, still treat the URL itself as a secret, and if it leaks, replace it in Settings → Notifications.
