Adverse Media Screening API
Counterparty screening needs repeated checks of the open web. Google News and Search cover negative press, and AI engines answer 'is X trustworthy' questions directly, so their answers belong in the screen too.
Who it's for
Compliance and risk platforms adding an open-web layer, fintechs running KYC/AML checks, and due-diligence providers.
What you get
- News results filtered by country and language for the counterparty's home market
- Search results for name-plus-risk-term queries, as JSON
- AI answers to reputation questions: what an assistant tells someone who asks about the entity
- Recurring batches for perpetual monitoring instead of point-in-time checks
Try it
curl -X POST "https://api.answerline.dev/v1/monitor/google/news" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"query":"acme holdings fraud lawsuit","country":"US"}'
Engines for this workload
What this data is and is not
Adverse media screening looks for negative public information about a person or organization: fraud allegations, litigation, regulatory action, corruption, financial crime. Much of it appears first in news coverage and search results, and in what AI assistants say when asked about a name.
The API returns that public layer as structured data:
- Google News results for a query:
title,link,snippet,source(publisher),date(as displayed),position. - Google Search results:
organicResultswithtitle,link,snippet,date, plus features such asknowledgeGraph,peopleAreSayingand, when requested, the AI Overview. - AI answers from ChatGPT and Gemini:
textandsources.
Its limits:
- Each result is a snapshot of what one public surface showed, for one query, in one market, at one time. It is not an archive or a complete record of everything published.
- It does not screen against sanctions, PEP or watch lists, and it does not verify identity.
- A search result or an AI answer is not a finding. Headlines can be wrong, names collide, and assistants can conflate people or state things their sources do not support.
- Using it does not make a process compliant with any regulation. Your compliance and legal teams decide what screening is required, how hits are adjudicated, and how long data is kept.
Within those limits it handles collection: consistent, repeatable, logged searches across many subjects, in a form a review queue can use.
Metrics
| Metric | Definition |
|---|---|
| Raw hit rate | Subjects with at least one result matching name and risk-term rules ÷ subjects screened. |
| Alert rate | Subjects with at least one new, non-dismissed hit sent to review, per 1,000 subjects screened. This sizes your review team. |
| Review precision | Hits confirmed relevant by a reviewer ÷ hits reviewed. Track per query template and risk term to prune templates that generate noise. |
| Re-alert rate | Hits on URLs already dismissed for that subject ÷ hits. Near zero when suppression works. |
| Screening coverage | Subjects whose scheduled screen completed on time ÷ subjects due. |
| Language coverage | Subjects screened in each language relevant to them ÷ subjects with that language requirement. |
| Review latency | Time from hit creation to reviewer decision. |
Precision is the metric that improves the pipeline. Every reviewer decision is a label, so feed the decisions back into templates and matching rules.
Architecture
subjects (+ risk tier, names, context, languages)
│
query builder (templates × languages × engines)
│
scheduler ── onboarding / periodic rescreen ──> batch (≤500) ──> webhook
│
hits (subject, url, matched terms, evidence) <── matcher
│
suppression (dismissed urls) ──> review queue ──> decisions (audit log)
Subject model
For each subject store the canonical name, known aliases and former names, transliterations, entity type (person or organization), context terms (employer, city, industry, registration country), relevant languages and markets, and risk tier. Collect only what your lawful basis and policy allow.
Query templates
A template combines a name, optional context and a risk-term group. Keep risk terms in data, per language:
| Group | English examples |
|---|---|
| Financial crime | fraud, money laundering, embezzlement, Ponzi |
| Corruption | bribery, kickback, corruption |
| Legal | lawsuit, indicted, convicted, charged, settlement |
| Regulatory | fined, sanctioned, license revoked, enforcement action |
| Other | investigation, arrested, scandal |
Build queries with Google’s operators:
"Jane Q. Example" (fraud OR "money laundering" OR embezzlement)
"Example Holdings" (lawsuit OR indicted OR settlement)
"Example Holdings" "Rotterdam" (fined OR sanctioned)
Long OR chains mix unrelated risk types in one result list and make precision hard to measure per group, so give each group its own query. Translate names (where they differ) and risk terms for each language, and pair each with country and hl for that market.
For AI engines, ask neutral questions: “What is publicly known about Example Holdings B.V. in Rotterdam?” A leading prompt (“What fraud has Example Holdings committed?”) invites the assistant to produce something.
Schedule
- Onboarding: the full template set on every relevant engine and language.
- Rescreen: a reduced template set per risk tier, at an interval your policy sets.
- Event-driven: re-run when the subject changes (new alias, new market).
The idempotency key identifies one query for one subject in one screening:
am:{subject_id}:{screen_id}:{template_id}:{engine}:{lang}
A screening interrupted by a crash can be resubmitted in full; tasks already queued come back as RESOURCE_ALREADY_EXISTS. Delivery, signature and retry behavior is in async tasks and webhooks, and the AI visibility tracking guide has a complete receiver. The dashboard’s delivery log keeps delivered and dead deliveries for 7 days.
Storage schema
create table subjects (
subject_id text primary key,
kind text not null, -- person | organization
name text not null,
aliases text[] not null default '{}',
context text[] not null default '{}',
languages text[] not null default '{en}',
risk_tier text not null
);
create table screens (
screen_id text primary key,
subject_id text not null references subjects,
reason text not null, -- onboarding | periodic | event
created_at timestamptz not null default now()
);
create table screen_tasks (
task_id uuid primary key,
screen_id text not null references screens,
template_id text not null,
engine text not null,
lang text not null,
query text not null,
status text not null default 'QUEUED',
finished_at timestamptz
);
create table hits (
hit_id bigserial primary key,
subject_id text not null references subjects,
url_key text not null,
url text not null,
title text,
snippet text,
source text,
raw_date text,
engine text not null,
first_task uuid not null references screen_tasks,
matched_terms text[] not null,
name_match text not null, -- exact | alias | partial
created_at timestamptz not null default now(),
unique (subject_id, url_key)
);
create table decisions (
hit_id bigint references hits,
reviewer text not null,
decision text not null, -- relevant | not_subject | not_adverse | duplicate
note text,
decided_at timestamptz not null default now()
);
unique (subject_id, url_key) makes an article one hit per subject, however many templates and screens return it. A dismissed hit stays in hits with its decision, which makes hits the suppression list. decisions is append-only, so the review history is kept.
Change detection
On rescreen, a hit is new only if (subject_id, url_key) does not exist. Also flag:
- A previously dismissed URL whose
titlechanged materially (a story update). - A jump in raw hits for a subject compared with its previous screen.
- An AI answer for the subject that mentions a risk term when the previous one did not.
End-to-end Python
Build a screening
import datetime as dt
HOOK = {"url": "https://hooks.example.com/adverse-media"}
RISK_TERMS = {
"en": {"fincrime": ["fraud", '"money laundering"', "embezzlement"],
"legal": ["lawsuit", "indicted", "convicted", "settlement"],
"regulatory": ["fined", "sanctioned", '"enforcement action"']},
"de": {"fincrime": ["Betrug", "Geldwäsche", "Untreue"],
"legal": ["Klage", "angeklagt", "verurteilt"],
"regulatory": ["Bußgeld", "Sanktionen"]},
}
MARKET = {"en": ("US", "en"), "de": ("DE", "de")}
def templates(subject):
names = [subject["name"], *subject["aliases"]]
for lang in subject["languages"]:
country, hl = MARKET[lang]
for n_i, name in enumerate(names):
for group, terms in RISK_TERMS[lang].items():
q = f'"{name}" ({" OR ".join(terms)})'
yield f"{group}-n{n_i}", "GOOGLE_NEWS", lang, {"query": q, "country": country, "hl": hl}
if subject["risk_tier"] == "high":
context = f' ({", ".join(subject["context"])})' if subject["context"] else ""
yield "ai-profile", "CHATGPT", lang, {
"prompt": f"What is publicly known about {subject['name']}{context}? Cite sources.",
"country": country}
def screen_tasks(subject, reason):
screen_id = f"{subject['subject_id']}-{dt.date.today():%Y%m%d}-{reason}"
tasks = [{"taskType": engine, "payload": payload, "webhook": HOOK,
"idempotencyKey": f"am:{subject['subject_id']}:{screen_id}:{template_id}:{engine}:{lang}"}
for template_id, engine, lang, payload in templates(subject)]
return screen_id, tasks
Insert the screens row, submit the tasks in chunks of 500, and write a screen_tasks row for each accepted task with its template, engine, language and query text. Log refusals other than RESOURCE_ALREADY_EXISTS against the subject.
Match a delivery
apply runs on each verified, non-test delivery:
import hashlib, re, unicodedata
from urllib.parse import urlsplit, urlunsplit
def fold(text):
return unicodedata.normalize("NFKD", text or "").encode("ascii", "ignore").decode().lower()
def url_key(url):
p = urlsplit(url)
return hashlib.sha256(urlunsplit(("", (p.hostname or "").removeprefix("www."), p.path.rstrip("/"), p.query, "")).encode()).hexdigest()
def name_match(text, subject):
t = fold(text)
if re.search(rf"\b{re.escape(fold(subject['name']))}\b", t):
return "exact"
if any(re.search(rf"\b{re.escape(fold(a))}\b", t) for a in subject["aliases"]):
return "alias"
tokens = fold(subject["name"]).split()
return "partial" if len(tokens) > 1 and all(tok in t for tok in tokens) else None
def matched_terms(text, lang):
t = fold(text)
return sorted({term.strip('"') for group in RISK_TERMS[lang].values() for term in group
if fold(term.strip('"')) in t})
def apply(db, d):
task = d["task"]
with db.transaction():
row = db.execute(
"""update screen_tasks st set status = %s, finished_at = now()
from screens s where st.task_id = %s and st.status not in ('COMPLETED','FAILED')
and s.screen_id = st.screen_id
returning s.subject_id, st.engine, st.lang""", (task["status"], task["id"])).fetchone()
if not row or task["status"] != "COMPLETED":
return
subject_id, engine, lang = row
s = db.execute("select name, aliases from subjects where subject_id = %s", (subject_id,)).fetchone()
subject = {"name": s[0], "aliases": s[1]}
r = d["response"]["result"]
if engine == "GOOGLE_NEWS":
items = [(i["link"], i.get("title"), i.get("snippet"), i.get("source"), i.get("date"))
for i in r.get("newsResults", [])]
else: # AI answer: the answer is one lead; its sources are the evidence to review
answer = r.get("text", "")
items = [(src["url"], src.get("label"), answer[:500], "AI answer source", None)
for src in r.get("sources", [])] if matched_terms(answer, lang) else []
for url, title, snippet, source, raw_date in items:
text = f"{title or ''} {snippet or ''}"
nm = name_match(text, subject) if engine == "GOOGLE_NEWS" else "exact"
terms = matched_terms(text, lang)
if not nm or not terms:
continue
db.execute(
"""insert into hits (subject_id, url_key, url, title, snippet, source, raw_date, engine,
first_task, matched_terms, name_match)
values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) on conflict (subject_id, url_key) do nothing""",
(subject_id, url_key(url), url, title, snippet, source, raw_date, engine, task["id"], terms, nm))
The matcher is conservative: a hit needs a name match and a risk term in the title or snippet. The snippet is Google’s excerpt, not the article, so an article whose snippet lacks the risk term is missed. The query already required the risk terms, which is why matching on title plus snippet is enough without fetching pages.
For AI answers, a hit is created per cited source only when the answer text contains a risk term, and the reviewer reads the source, not the answer.
Review queue and metrics
-- Open review queue: newest hits without a decision, exact matches first
select h.hit_id, h.subject_id, h.name_match, h.matched_terms, h.source, h.title, h.url, h.created_at
from hits h
where not exists (select 1 from decisions d where d.hit_id = h.hit_id)
order by (h.name_match = 'exact') desc, h.created_at;
-- Review precision per template and engine over 90 days
select st.template_id, h.engine,
count(*) as reviewed,
avg((d.decision = 'relevant')::int) as precision
from hits h
join screen_tasks st on st.task_id = h.first_task
join lateral (select decision from decisions d where d.hit_id = h.hit_id order by decided_at desc limit 1) d on true
where h.created_at > now() - interval '90 days'
group by st.template_id, h.engine
order by precision;
-- Alert rate per 1,000 subjects screened this month
select 1000.0 * count(distinct h.subject_id) / nullif(count(distinct s.subject_id), 0) as alerts_per_1000
from screens s
left join screen_tasks st on st.screen_id = s.screen_id
left join hits h on h.first_task = st.task_id
where s.created_at >= date_trunc('month', now());
If your process must show what was seen and when, keep each task’s full response in immutable storage with the task id and completion time.
Cost planning
Price per request at the Starter tier: Google News $0.0002 (extra pages free); ChatGPT $0.0006; Gemini $0.0006. Failed tasks are not charged, and higher tiers, reached by 30-day spend, cost less. See balance and pricing.
The template builder above produces, per language and per name or alias, 3 risk groups = 3 Google News tasks at $0.0002 = $0.0006, plus one $0.0006 ChatGPT question per language for high-risk subjects.
Worked example: a fintech screens its business customers.
| Workload | Subjects | Tasks per subject | Cost per subject | Cost |
|---|---|---|---|---|
| Onboarding, standard risk, 1 language, name only | 400 per month | 3 | $0.0006 | $0.24 |
| Onboarding, high risk, 2 languages, name + 1 alias, with AI question | 40 per month | 14 | 2 × 2 × $0.0006 + 2 × $0.0006 = $0.0036 | $0.144 |
| Monthly rescreen, standard risk, reduced to 2 news queries | 5,000 | 2 | $0.0004 | $2.00 |
| Monthly rescreen, high risk, full set | 300 | 14 | $0.0036 | $1.08 |
| Monthly total | $3.46 |
Moving standard-risk rescreens to quarterly cuts that row to about $0.67 a month on average. A new account’s 100 free requests run 33 standard onboarding screens of 3 tasks each for evaluation. Tiers are on pricing.
Pitfalls
Common names. Name plus risk term on a common name returns results about other people. Require context terms for persons with common names, rank exact matches first, and measure precision per subject type.
Transliteration and diacritics. “Müller”, “Muller” and “Mueller” are the same name. Store variants as aliases and fold diacritics before matching.
Treating AI answers as findings. Assistants can merge people with similar names or state unsupported claims. Send only their cited sources to review, and let reviewers decide from the source.
Leading prompts. Asking an assistant what crimes a subject committed invites fabrication. Ask neutral questions.
Relative dates. date is display text such as “3 days ago”. Keep it raw, record capture time, and let reviewers open the article for the publication date.
Silent coverage gaps. A screen is complete only when every task is COMPLETED or FAILED. Tasks that could not start within 4 hours fail with QUEUE_WAIT_EXCEEDED; batch items refused with INSUFFICIENT_CREDITS were never queued. Report screening coverage and resubmit gaps before marking a screen done.
Over-retention. Search results about individuals are personal data in many jurisdictions. Agree retention and access rules with your legal team before storing hits and responses.
Which engines fit
| Engine | Screening use | Limits to remember |
|---|---|---|
| Google News | Primary adverse news layer per market and language | Snapshot of current results, not an archive |
| Google Search | Pages outside news coverage, such as regulator notices, court-related pages and forums, when Google ranks them | Broader and noisier; needs strict matching |
| ChatGPT | What an assistant tells someone who asks about the subject, with sources as leads | Answers can be wrong; review sources, not the answer |
| Gemini | A second assistant view with sources |
Same caveat |
Brand crisis AI monitoring covers watching AI answers during an incident, and the Google News engine page lists every request field.
Questions
Is this a complete adverse media or sanctions screening solution?
No. The API returns what public Google News results and AI assistant answers showed for a query at the time of the request. It does not check sanctions or PEP lists, verify identities or establish facts, and using it does not by itself satisfy any regulatory requirement. It is one input to a screening process your compliance team designs and reviews.
How do I reduce false positives for common names?
Add context terms that identify the subject, such as company, city, industry or date of birth year where lawful, require the full name as a quoted phrase, and route every hit to human review with the snippet and link. Record dismissed URLs per subject so they do not alert again.
Can AI answers be used as evidence?
Treat them as leads, not evidence. An assistant's answer can be wrong or conflate people with similar names. Follow its sources to the underlying articles and review those.
How often should subjects be rescreened?
That is a policy decision for your risk framework. Technically, each rescreen is a set of tasks; a common pattern is a full screen at onboarding and periodic rescreens whose frequency depends on the subject's risk tier.
What does screening one subject cost?
It depends on the query templates. A Google News task costs $0.0002, so a screen with one news query for each of three risk groups is $0.0006; adding a ChatGPT question adds $0.0006, a Gemini question $0.0006.
More use cases
AI Visibility API: brand mentions and citations in AI
The GEO measurement layer: how ChatGPT, Gemini and Copilot describe a brand, with mentions, citations and the wording users see.
Rank Tracking API
Google positions, SERP features and the AI Overview for any keyword, as structured JSON from one call.
Local Rank Tracking API
AI answers and Google results per market as parsed JSON: city-level on AI Mode today, US-state targeting coming soon.