# 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`.

Free try on your own text, no payment: `POST https://vehcdj664efetfrsolne5umanq.srv.us/v1/scan/reply/preview` takes the same body and returns the same `verdict`, `findings`, `urls`, `checks` and `counts`, with `signed: false` and no receipt. It allows 30 calls a day per IP address (shared with the text-quote preview); after that it answers 429 `preview_cap`. Example: `curl -s -X POST https://vehcdj664efetfrsolne5umanq.srv.us/v1/scan/reply/preview -H 'content-type: application/json' -d '{"text": "Reset it at https://paypa1.com/login", "allowed_urls": ["paypal.com"]}'`.

## Request

```json
{
  "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

```json
{
  "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`: `block` when any finding is high, `caution` when there are only medium or low ones, `pass` when none. `pass` means none of the checks below matched; it is not proof the reply is harmless or true.
- `urls`: every link found in the reply. `allowed` is `null` when neither `allowed_urls` nor `source_text` was sent, since there is nothing to compare against.
- `checks`: `urls_against_allowlist` and `numbers_against_source` are `false` when 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 with `POST /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](/docs/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://` or `www.`, 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_text` and a short brand list. A lookalike of a site that is in none of these is not flagged unless another check catches it (for example `url_not_allowed` when you send an allowlist).
- Without `allowed_urls` or `source_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](/docs/detection) for what they miss.
- The reply's claims are not checked for truth beyond prices and discounts.
