Scan a bot reply before it is sent
Last updated: 2026-10-21
POST /v1/scan/reply checks a draft reply your bot is about to send to a person. Send the reply, and optionally the text the bot answered from and the hosts it may link to; get back verdict (pass, caution or block) and a list of findings, under a signed receipt. Nothing is fetched: every check runs on the text you send. Price: US$0.003 per call.
Free sample, no payment: curl -s https://vehcdj664efetfrsolne5umanq.srv.us/v1/sample/scan-reply runs the real check on a support bot's draft that copied a phishing link, a hidden instruction and a made-up discount from a poisoned ticket. It answers block.
Request
{
"text": "Your refund of $25 is on its way. Track it at https://shop.example.net/orders or confirm your card at https://shop-example.net.verify-account.io/login.",
"source_text": "Refund approved: $25, back to the original card in 5 to 7 days. Order page: https://shop.example.net/orders",
"allowed_urls": ["https://shop.example.net"]
}
| Field | Required | Meaning |
|---|---|---|
text | yes | the draft reply: plain text, Markdown or HTML, 1 to 10,000 characters |
source_text | no | the text the bot answered from (a ticket, a tool result, a page), at most 40,000 characters. Links whose host is not in it, and price or discount numbers not in it, become findings |
allowed_urls | no | up to 50 URLs or bare hosts the bot may link to. A link is allowed when its host is one of these or a subdomain of one; paths are not compared |
The request body is capped at 64 KB. Every field is checked before payment, so a bad request is answered with 400 and not charged. An unpaid call gets the 402 offer; an empty body or {} also gets the 402 offer (not a 400), so the price can be read.
Response
{
"verdict": "block",
"findings": [
{ "type": "url_not_allowed", "severity": "high", "url": "https://shop-example.net.verify-account.io/login", "host": "shop-example.net.verify-account.io", "detail": "This link's host is not in allowed_urls or source_text." },
{ "type": "lookalike_host", "severity": "high", "url": "https://shop-example.net.verify-account.io/login", "host": "shop-example.net.verify-account.io", "detail": "The host uses the name \"example\" of shop.example.net but belongs to verify-account.io." }
],
"urls": [
{ "url": "https://shop.example.net/orders", "host": "shop.example.net", "allowed": true },
{ "url": "https://shop-example.net.verify-account.io/login", "host": "shop-example.net.verify-account.io", "allowed": false }
],
"checks": { "urls_against_allowlist": true, "numbers_against_source": true, "hosts": true, "injection": true },
"counts": { "urls": 2, "findings": 2, "high": 2, "medium": 0, "low": 0 },
"advice": "Do not send this reply as written: it has at least one high-severity finding (see findings).",
"receipt": "eyJ…",
"tier": "paid"
}
verdict:blockwhen any finding is high,cautionwhen there are only medium or low ones,passwhen none.passmeans none of the checks below matched; it is not proof the reply is harmless or true.urls: every link found in the reply.allowedisnullwhen neitherallowed_urlsnorsource_textwas sent, since there is nothing to compare against.checks:urls_against_allowlistandnumbers_against_sourcearefalsewhen their inputs were not sent; host and injection checks always run.- The receipt (kind
scan_reply) signs the hash of the reply and of the source, the allowed hosts, the verdict and each finding's type and severity. Check it free withPOST /v1/receipt/verify.
Findings
type | Severity | Raised when |
|---|---|---|
url_not_allowed | high with allowed_urls, medium with only source_text | a link's host is not in allowed_urls, not a subdomain of one, and not the host of a link in source_text |
lookalike_host | high | a host that is not allowed uses the name of an allowed or source host, or of a common brand (PayPal, Apple, Google, Microsoft, Amazon and others), in a subdomain or a hyphenated name (shop-example.net.verify-account.io), with swapped look-alike characters (paypa1.com), or one or two letters off |
punycode_host | high | the host is punycode (xn--) or has non-ASCII letters |
ip_host | high | the link points at a bare IP address |
credentials_in_url | high | the link has a user part before @ (https://paypal.com@evil.example), so it opens the host after the @ |
link_text_mismatch | high | a Markdown or HTML link whose text shows one host while the link opens another |
shortener_host | medium | the link is a URL shortener (bit.ly, tinyurl.com, t.co and others), which hides where it leads |
injection | per flag | the reply carries an injection phrase; flag names it and its severity is the one in Detection. Text inside HTML comments counts as hidden (hidden_instructions, high) |
invisible_characters | low | zero-width or soft-hyphen characters, which can hide or split words |
unsupported_price | medium | a price ($19, €10, 25 USD) whose number does not appear anywhere in source_text |
unsupported_discount | medium | a percentage with a word like off, discount, save, sale or promo in the same clause whose number does not appear anywhere in source_text |
Known misses
- Links are found when they start with
http://,https://orwww., or are a bare host with a common ending (.com, .net, .org, .io, .co, .app and others). A host written another way, or split across words, is not seen. - A number counts as supported when the same number appears anywhere in
source_text, even in another context. Prices written in words ("nineteen dollars") are not seen, and percentages are only checked when a discount word is in the same clause. - Lookalike checks compare against your allowed hosts, the hosts in
source_textand a short brand list. A lookalike of a site that is in none of these is not flagged unless another check catches it (for exampleurl_not_allowedwhen you send an allowlist). - Without
allowed_urlsorsource_text, a link to any ordinary host passes. Send at least one of them. - Injection phrases use the same patterns as
/v1/fetch; see Detection for what they miss. - The reply's claims are not checked for truth beyond prices and discounts.