EnroutiaEU

Brand reports: what the AI models say about a brand

One POST orders a report; minutes later a webhook tells you it is ready and a GET returns a self-contained HTML file. Every chat model in the catalogue, plus three closed reference models, is asked the same six questions about the brand from memory, and a judge model classifies what each one said.

What it measures

Six fixed questions, asked in the report's language and without web search, so the answers are what each model already believes about the brand rather than what it can look up:

  1. What the brand is — does the model know it at all, and does it confuse it with something else?
  2. What the model thinks of it — strengths, weaknesses, reputation.
  3. The purchase question: “recommend me a <category> for a company in <country>” — is the brand among the three names it offers?
  4. Who leads the sector — is the brand named among them?
  5. Alternatives to the brand — what the model steers people towards instead.
  6. The brand against its first competitor — or, with no competitor given, against whoever the model picks.

The models are every active chat alias in the catalogue plus GPT-6 Sol, GPT-6 Astra, Claude Sonnet 5, Claude Opus 5.5, Gemini 3.8 Flash and Perplexity Sonar (which searches the web before answering) as closed references, so the report can say “the open models know you, the closed ones do not” with a denominator on every count. Each answer is capped at 1,500 tokens.

A judge model then reads each model's six answers and classifies that model — Correct, Partial, Confuses, No answer — and lists the claims worth checking, with a severity. If you send approved facts, a claim that contradicts them is marked as such; without facts nothing can be more than “unverified”. The report labels this classification as automatic, without human review, and never invents an aggregate score: every figure comes with the number it is out of.

Ordering a report

An account token with the “Run models” permission, the same one MCP inference uses. The report is billed to your credit like any other call: each answer at that model's catalogue price, plus the judge's calls.

curl -X POST https://platform.enroutia.com/api/brand-reports \
  -H "Authorization: Bearer pat_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "brand": "Acme Analytics",
    "sector": "web analytics",
    "category": "cookieless web analytics tool",
    "country": "España",
    "language": "es",
    "competitors": ["Matomo", "Plausible"],
    "facts": "Founded in 2019 in Valencia. Does not use cookies.",
    "client_ref": "lead-4812",
    "white_label": {
      "name": "Your Agency",
      "accent": "#0f766e",
      "logo_data_url": "data:image/png;base64,…"
    }
  }'
  • brand — the brand, product or company, as it is written. Up to 120 characters.
  • sector — the sector, in the words a buyer would use (“web analytics”). Up to 120 characters.
  • category — what somebody would ask for when buying it (“cookieless web analytics tool”); it is dropped verbatim into the purchase question. Up to 120 characters.
  • country — the country of the imagined buyer. Defaults to “España”.
  • language — the language the six questions are asked in and the report is written in: “es” or “en”, and nothing else. Defaults to “es”. It changes the wording of the questions, so an English edition and a Spanish one are two separate measurements, each comparable only with its own previous edition.
  • competitors — up to three names. The first one is used in the head-to-head question; all of them are counted in the purchase answers.
  • facts — optional, up to 4,000 characters of facts you vouch for. Only with these can the judge mark a claim as contradicting the truth rather than merely unverified.
  • white_label — optional. Its name goes in the report's header; logo_data_url is an inline PNG, JPEG or SVG data URL of at most 200,000 characters, and an https URL is silently ignored because the file must stay self-contained; accent is a #rrggbb colour. The footer always names the platform that measured it.
  • client_ref — optional, up to 120 characters, returned untouched in the summary and in the webhook. Put your own row id here.
  • key_id — optional: which of your API keys pays. Omit it and the account's billing key is used.
  • models — optional list of chat aliases to restrict the run to. Omit it and every active chat alias is used; an unknown alias is a 400.
  • include_references — whether to include the three closed reference models. Defaults to true.

Send sector, category and country in the same language you ask for: they are dropped into the questions verbatim. The field names and this API stay in English whichever you pick, and a country other than Spain changes the buyer in the purchase question, not the language.

The 202 and the summary

The report is queued, never run inside the request. The response is the report's summary with its status and an orientative estimate in cents; the same object comes back from every GET and inside the webhook.

HTTP/1.1 202 Accepted

{
  "id": "6f1c…",
  "status": "queued",
  "brand": "Acme Analytics",
  "language": "es",
  "client_ref": "lead-4812",
  "models": ["mistral-medium", "gpt-oss-120b", "…"],
  "reference_models": ["gpt-6-sol", "claude-sonnet-5", "claude-opus-5-5", "gpt-6-astra", "gemini-3.8-flash", "perplexity-sonar"],
  "created_at": "2026-09-07T10:12:00+00:00",
  "started_at": null,
  "finished_at": null,
  "expires_at": "2026-12-06T10:12:00+00:00",
  "billed_eur": 0,
  "error_kind": null,
  "summary": null,
  "report_path": "/api/brand-reports/6f1c…/report",
  "estimate_cents": 9
}

Once the status is done, summary is filled: how many models were asked, how many recognised the brand, how many of the purchase answers named it and out of how many, the three headline sentences and the thesis. billed_eur is what the run actually cost.

"summary": {
  "n_models": 15,
  "recognised": 4,
  "brand_in_purchase": 0,
  "purchase_denom": 15,
  "headline": { "critical": "…", "discovery": "…", "knowledge": "…" },
  "thesis": "…"
}

A request fails before anything is queued with 409 no_key when the account has no API key to bill, 402 insufficient_credits with a zero balance, 400 unknown_models, 403 when the token lacks the permission, and 429 brand_report_quota past the daily cap.

Reading the result

You can poll the summary, but you should not have to: subscribe to the webhook and read the report when it fires. The HTML is a 409 not_ready until the status is done.

GET https://platform.enroutia.com/api/brand-reports/{id}
Authorization: Bearer pat_live_…

GET https://platform.enroutia.com/api/brand-reports/{id}/report
GET https://platform.enroutia.com/api/brand-reports/{id}/report?print=true

The report is one HTML file with nothing external in it — no fonts, no scripts, no images fetched from anywhere — so it can be attached to an email or archived as it is. Add print=true and the collapsed sections come open, for printing to PDF from the browser.

GET /api/brand-reports lists your last fifty reports, and a DELETE on a report's URL removes it — and its answers — before the retention period does.

The webhook

Create an account webhook in the panel and subscribe it to brand_report_completed and brand_report_failed. Each delivery is a POST with the event in X-Webhook-Event, the summary under data, and a signature:

POST https://your-endpoint.example/enroutia-events
Content-Type: application/json
X-Webhook-Event: brand_report_completed
X-Webhook-Signature: t=1788084720,v1=3f9a…

{
  "event": "brand_report_completed",
  "created_at": "2026-09-07T10:18:40+00:00",
  "project_id": "…",
  "data": { …the same object GET /api/brand-reports/{id} returns… }
}

X-Webhook-Signature is t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 with your webhook's secret over the timestamp, a dot and the body — the raw body bytes exactly as received, never a re-serialised object. Reject anything older than five minutes and compare in constant time:

import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody: the request body as received, bytes untouched.
export function verify(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(signatureHeader.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) {
    return false;
  }
  const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
  const given = Buffer.from(parts.v1 ?? "", "hex");
  return given.length === expected.length && timingSafeEqual(given, expected);
}

Deliveries are best-effort and have a five-second timeout: answer 200 first and do the work after. Ten consecutive failures switch the endpoint off, and the panel shows it switched off. A failed report keeps its summary (with error_kind set) so the machine that ordered it can tell its user.

A subscribed report also fires brand_report_changed when an edition differs from the last; see “Editions, subscriptions and claim reviews” below.

X-Webhook-Event: brand_report_changed

{
  "event": "brand_report_changed",
  "data": { …the summary, with "edition", "subscription_id", "previous_report_id" and "diff"… }
}

Editions, subscriptions and claim reviews

A subscription is the same order with a cadence — monthly or weekly. It runs the first report immediately and the next one when next_run_at comes round, each as a new edition of the same series with the same questions, so the numbers are comparable. The subscription object is what every subscription route returns; the summary of each report gains edition, subscription_id and previous_report_id.

POST https://platform.enroutia.com/api/brand-reports/subscriptions
Authorization: Bearer pat_live_…

{ …the same body as POST /api/brand-reports…, "cadence": "monthly" }

HTTP/1.1 202 Accepted
{
  "subscription": {
    "id": "a41d…", "brand": "Acme Analytics", "sector": "web analytics",
    "category": "cookieless web analytics tool", "country": "España", "language": "es",
    "competitors": ["Matomo", "Plausible"], "cadence": "monthly", "status": "active",
    "next_run_at": "2026-10-07T10:12:00+00:00", "last_report_id": "6f1c…",
    "edition_count": 1, "created_at": "2026-09-07T10:12:00+00:00"
  },
  "first_report": { …summary… }
}

GET    https://platform.enroutia.com/api/brand-reports/subscriptions            → {"subscriptions": [...]}
GET    https://platform.enroutia.com/api/brand-reports/subscriptions/{id}       → {..., "reports": [last 12 summaries]}
PATCH  https://platform.enroutia.com/api/brand-reports/subscriptions/{id}       {"status": "paused" | "active", "competitors"?, "facts"?, "cadence"?}
DELETE https://platform.enroutia.com/api/brand-reports/subscriptions/{id}       → 204 (cancelled; reports kept until they expire)

From the second edition on, the summary carries a diff against the previous one: per model, the verdict before and after; the recognition and purchase counts before and after; and how many models each competitor was named by. The HTML gains a “Since last edition” section with the rows that changed highlighted.

"edition": 2,
"subscription_id": "a41d…",
"previous_report_id": "6f1c…",
"competitor_mentions": { "Matomo": 9, "Plausible": 6 },
"diff": {
  "previous_report_id": "6f1c…",
  "previous_at": "2026-09-07T10:18:40+00:00",
  "models": [
    { "model": "mistral-medium", "before": "partial", "after": "correct", "changed": true },
    { "model": "gpt-oss-120b", "before": "correct", "after": "correct", "changed": false }
  ],
  "recognised": { "before": 4, "after": 6 },
  "brand_in_purchase": { "before": 0, "after": 2 },
  "changed_count": 3,
  "competitor_mentions": { "before": { "Matomo": 11 }, "after": { "Matomo": 9, "Plausible": 6 } }
}

When something moved — a model changed its verdict, or the recognition or purchase count did — the account webhook also fires brand_report_changed, with the summary and the diff under data. An edition where nothing changed fires brand_report_completed only, so a receiver can subscribe to the first and hear only news.

A subscription whose account has no API key or no credit when its edition is due is paused rather than retried: status becomes paused and brand_report_failed fires with error_kind no_credit. PATCH it back to active once there is credit; the cancelled ones keep their reports until they expire.

Pay per brand

A subscription can also be sold by card, outside your credit: send "billing": "stripe" and the answer carries a Stripe checkout instead of a first edition. The plan is 29 € per brand and month for a monthly edition and 79 € for a weekly one, VAT excluded; nothing runs until it is paid, and every edition is billed to Enroutia rather than to one of your keys. Opening a payment needs the admin role. The panel's Brand reports screen does the same with a button.

POST https://platform.enroutia.com/api/brand-reports/subscriptions
Authorization: Bearer pat_live_…

{ …the same body…, "cadence": "monthly", "billing": "stripe" }

HTTP/1.1 202 Accepted
{
  "subscription": {
    "id": "a41d…", "brand": "Acme Analytics", "cadence": "monthly",
    "status": "pending_payment", "billing": "stripe",
    "price_cents": 2900, "paid_through": null, "edition_count": 0, …
  },
  "checkout_url": "https://checkout.stripe.com/c/pay/cs_…"
}

GET  https://platform.enroutia.com/api/brand-reports/subscriptions/{id}   → {..., "billing": "stripe", "paid_through": "2026-10-25T…", "price_cents": 2900}
POST https://platform.enroutia.com/api/brand-reports/subscriptions/{id}/portal   (panel session, admin) → {"url": "https://billing.stripe.com/…"}

The status reads pending_payment until Stripe confirms the checkout; then it becomes active and the first edition is ordered at once. Each paid renewal moves paid_through; an edition falls due only while it is in the future. A refused renewal pauses the plan and fires brand_report_failed with error_kind payment_failed — a later successful retry resumes it. Stripe ending the plan (the billing portal, or retries exhausted) cancels it and fires brand_report_subscription_cancelled. A paid plan is not paused or re-cadenced with PATCH (409 managed_by_stripe); the one exception is PATCH status active on a plan paused while its period is paid (an edition refused for a reason that is not a quota), which resumes it. To stop a plan, cancel it: DELETE, or the billing portal, which can cancel but not pause or change the plan. Cancelling takes effect at once and there is no proration — the rest of the period paid is not refunded. DELETE on a plan still pending payment also expires its checkout, so it can no longer be paid; cancelling any card plan needs the admin role.

Reviewing what a model claimed

The judge lists the claims worth checking in each report. GET returns them with your review, if any; PUT marks each one correct, incorrect or outdated, with the fact that replaces it when it is wrong. A claim not in the report is a 404 claim_not_found.

GET https://platform.enroutia.com/api/brand-reports/{id}/claims
{
  "claims": [
    { "model": "mistral-medium", "claim": "Founded in 2015 in Madrid.",
      "kind": "fact", "severity": "high", "review": null },
    { "model": "gpt-oss-120b", "claim": "Uses first-party cookies.",
      "kind": "fact", "severity": "medium",
      "review": { "verdict": "incorrect", "fact": "Does not use cookies at all." } }
  ]
}

PUT https://platform.enroutia.com/api/brand-reports/{id}/claims/review
{
  "reviews": [
    { "model": "mistral-medium", "claim": "Founded in 2015 in Madrid.",
      "verdict": "outdated", "fact": "Founded in 2019 in Valencia." },
    { "model": "gpt-oss-120b", "claim": "Uses first-party cookies.", "verdict": "incorrect",
      "fact": "Does not use cookies at all." }
  ]
}
→ 200 {"reviewed": 2}      · 404 claim_not_found when a claim is not in the report

Reviews feed the next edition. Each “incorrect” or “outdated” with a fact becomes an approved fact, and each “correct” approves the claim itself; the last twenty, deduplicated, are appended to the subscription's facts within the 4,000-character limit, oldest trimmed first. The next edition's judge can then mark a repeat of the same error as contradicting the truth, not merely unverified.

Sector report

Your brand and up to three competitors before the same six questions, side by side. POST /api/brand-reports/sector takes the body of a report with competitors required (one to three, distinct, none equal to the brand) and orders one complete report per brand — each is an ordinary brand report you can open on its own, and each carries the sector's id as sector_id. A competitor is asked with your brand as its own first competitor, so its head-to-head question is “Competitor versus You”, and your facts are given to your brand's judge only. When the last of them finishes, the sector's page composes the four: the mention share, who recognises whom, whom the models recommend in the purchase question and whom they name among the alternatives to each brand, and the six questions side by side.

POST https://platform.enroutia.com/api/brand-reports/sector
Authorization: Bearer pat_live_…

{
  "brand": "Acme Analytics",
  "sector": "web analytics",
  "category": "cookieless web analytics tool",
  "country": "España",
  "competitors": ["Matomo", "Plausible", "Fathom"],
  "facts": "Founded in 2019 in Valencia. Does not use cookies.",
  "client_ref": "lead-4812"
}

HTTP/1.1 202 Accepted
{
  "id": "5c02…", "status": "queued", "brand": "Acme Analytics",
  "competitors": ["Matomo", "Plausible", "Fathom"],
  "shared_questions": ["q3_recomienda", "q4_sector"],
  "reports": [
    { "id": "6f1c…", "brand": "Acme Analytics", "role": "brand", "status": "queued",
      "report_path": "/api/brand-reports/6f1c…/report" },
    { "id": "7a90…", "brand": "Matomo", "role": "competitor", "status": "queued", … },
    …
  ],
  "billed_millicents": 0, "summary": null,
  "report_path": "/api/brand-reports/sector/5c02…/report",
  "estimate_cents": 31, "estimate_brand_cents": 9,
  "estimate_competitor_cents": 7, "estimate_unshared_cents": 36,
  "note": "If your brand's own report fails, the sector fails: …"
}

The purchase question and the industry question name no brand — they are built from the category, the sector and the country — so they are the same prompt for every brand of the set. They are asked once, by your brand's report, and copied to the competitors' at no charge: a sector of four costs one full report plus three reports of four questions, not four full reports. The 202 says both: estimate_cents is what the sector should cost, estimate_unshared_cents what four separate reports would.

The judge is the dear part. Each brand is judged separately — one call per model plus a synthesis — because whether a model knows a brand is a different question for each of the four; measured on real reports, the judge is about a quarter of what a report bills, and in a sector it is paid once per brand. The summary splits every brand's bill into answers_millicents and judge_millicents, and the page prints the judge's total and its share.

GET /api/brand-reports/sector/{id} returns the set's status — queued, running, done or failed — one line per brand with its own report_path, the total billed, and, once done, the side-by-side numbers in summary. done means your brand's report finished; a competitor whose report failed is shown as a gap, not hidden. If your brand's own report fails, the sector fails: the composed page is never built, and the competitors' reports that finished are still billed and readable at their own report_path — the 202 says so in note. Deleting a member while it runs settles the set without it. GET …/report is the composed HTML, a 409 not_ready until then.

GET https://platform.enroutia.com/api/brand-reports/sector/{id}
GET https://platform.enroutia.com/api/brand-reports/sector/{id}/report

"summary": {
  "n_models": 15, "purchase_denom": 14, "mentions_total": 41,
  "billed_millicents": 3120000, "judge_millicents": 780000,
  "brands": [
    { "brand": "Acme Analytics", "role": "brand", "recognised": 4, "judged": 15,
      "named_in_purchase": 1, "named_as_alternative": 2, "mentions": 5,
      "mention_share": 0.122, "billed_millicents": 960000,
      "answers_millicents": 750000, "judge_millicents": 210000 },
    { "brand": "Matomo", "role": "competitor", "recognised": 14, "judged": 15,
      "named_in_purchase": 12, "named_as_alternative": 9, "mentions": 25, … },
    …
  ]
}

Subscribe a webhook to brand_sector_completed: it fires once, when every report of the set has finished, with the same object the GET returns under data (status done or failed). Each member also fires its own brand_report_completed as usual; its sector_id tells them apart.

Limits

  • 10 requests a minute per account on the POST.
  • 20 reports per project per day. The 21st answers 429 brand_report_quota.
  • 5 sector reports per project per day (429 brand_sector_quota), and every brand of a sector counts against the 20 reports: a sector that does not fit whole is refused whole with 429 brand_report_quota.
  • 90 days of retention: the report and every answer are deleted after that, whether or not anyone read them.
  • 1,500 tokens per answer. Long enough for a full answer to every one of the six questions; the report marks an answer that ran out.
  • The three closed reference models run at no cost to you in this version. That may change; when it does, the catalogue changes page will say so first.
  • The classification is automatic and labelled as such in the report. Treat a “Correct” as a model's reading of another model, not as a person's.

Brands, products and companies. Not people.

The questions, the judge and the report are built for a brand, a product or a company. Do not send a person's name as the brand: the answers would be a model's opinion of an individual, which is neither something we measure nor something we want stored for 90 days. The request is yours to shape; this is the one thing it must not be.

See also: Credits and balance · Limits · MCP