Skip to main content
LLM Metrix
Back to Knowledge Base
Concepts

Outbound Webhook Reference

The exact JSON payload your outbound webhook receives on every completed brand scan: field by field, with the delivery guarantees and the failure modes a receiver has to survive.

By Team @ LLM Metrix8 min read4 sectionsUpdated Aug 11, 2026

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, or null when 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 matching alerts entry.

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/scans returns the same scan with prompt_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_findings are 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 / sourceUrl on 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-Signature header 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 ,, check t is within a few minutes of now, compute hmac_sha256(your_secret, t + "." + rawBody) with your signing secret, and compare in constant time. Your secret is returned by GET /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.
Was this helpful?

Ready to put this into practice?

Apply these concepts with our step-by-step tutorials or check your visibility now.