Handling verdicts
Turn a label and a reason into a policy: what to drop, what to review, what to show the visitor, and how to fail safely.
A verdict is only useful once your code does something with it. This page is the policy most integrations end up with.
Three buckets, not two
Treat spam verdicts differently depending on how they were reached.
| bucket | condition | action |
|---|---|---|
| Bot | spam with any of HONEYPOT_FILLED, FAST_SUBMIT, URL_IN_NAME, DUP_KNOWN_SPAM, IP_REPUTATION_BAD, EMAIL_REPUTATION_BAD, EMAIL_DOMAIN_REPUTATION_BAD | Drop. Show the normal “thanks” page and send nothing. |
| Review | any other spam verdict (ML_SPAM, LLM_SPAM, MANY_URLS, ML_GRAY_FALLBACK) | Store it in a review folder with the reasons. Tell the visitor how to reach you if they do not hear back. |
| Deliver | ham | Deliver as normal. |
const BOT = new Set(["HONEYPOT_FILLED", "FAST_SUBMIT", "URL_IN_NAME", "DUP_KNOWN_SPAM",
"IP_REPUTATION_BAD", "EMAIL_REPUTATION_BAD", "EMAIL_DOMAIN_REPUTATION_BAD"]);
function bucket(verdict) {
if (!verdict) return "deliver"; // API unreachable: fail open
if (verdict.label === "ham") return "deliver";
return verdict.reasons.some((r) => BOT.has(r)) ? "bot" : "review";
}
The review bucket is small (about one verdict in twenty is a gray-zone case) and is where false positives live if you ever have any. Giving it a home means nobody loses a customer to a filter they cannot see.
Keep the id
Store the verdict id, label and reasons next to the message. The id is what you send to /v1/feedback when a verdict was wrong, and to DELETE /v1/submissions/{id} when a person asks for their data to be removed.
Give people a button
The single most effective way to reduce support load is a “Not spam” button that works. Wire it to /v1/feedback with label: "ham". The message is delivered next time, the sender gets a good mark, and your memory learns the pattern. A “Mark as spam” button on the inbox side does the reverse. Both can be batched with /v1/feedback/batch.
Show the reason
Next to every filtered message, show the reason in plain words. People trust a filter they can see. The reason code reference has suggested wording for every code; a few examples:
| code | wording |
|---|---|
HONEYPOT_FILLED | A hidden field was filled in. |
DUP_KNOWN_SPAM | The same message was sent to other sites recently. |
ML_SPAM | Looks like an unsolicited pitch. |
LLM_SPAM | Not the kind of message this site is for. |
Fail open
If spamex is slow, unreachable, or your quota is exhausted, deliver the message and log the failure. Losing a real enquiry costs more than one spam email. In code, that means every non-200 response and every timeout maps to “deliver”.
200 use the label
401 / 403 configuration problem: deliver, alert yourself
402 quota exhausted: deliver, alert yourself
413 / 422 your payload: deliver, fix the request
429 back off per Retry-After, deliver
5xx / timeout deliver
Fail closed only for forms that trigger an expensive action on submit, such as creating an account or sending an SMS.
Timeouts
Set a five-second client timeout. Rules and model verdicts take a few milliseconds; a gray-zone verdict can take up to three seconds while the language model answers. If your form handler answers the visitor’s browser directly and you cannot afford the wait, use fast mode.
Do not retry a 200
Classification is not idempotent by design. The same message sent twice comes back DUP_KNOWN_SPAM the second time if the first verdict was confident spam, and the second call counts against your quota. Retry only on network errors and 5xx.