Reason codes beat spam scores
A number between 0 and 1 tells you how sure a model is. It does not tell you what to do. Codes do.
Most spam APIs return a score. 0.87. What should your form handler do with 0.87? Deliver? Quarantine? Show a CAPTCHA? The score is honest about the model’s confidence and useless as an instruction, and it pushes the hardest decision, the threshold, onto the person least equipped to make it: the developer wiring up a contact form on a Friday afternoon.
spamex returns a score too, but it also returns a label and a list of reason codes, and the codes are the part we designed most carefully.
Codes are branches
{"label": "spam", "score": 0.99, "stage": "rules", "reasons": ["HONEYPOT_FILLED"]}
That is not a probability to interpret. It is a fact: the hidden field was filled. A human did not do that. Drop it silently and move on.
{"label": "spam", "score": 0.74, "stage": "llm", "reasons": ["URLS_PRESENT", "NAME_EMAIL_MISMATCH", "LLM_SPAM"]}
That one is a judgement call the language model made about a plausible-looking message with a link and a mismatched identity. Probably a cold pitch. Possibly a real partnership inquiry. It belongs in a review folder, not in the bin.
{"label": "ham", "score": 0.01, "stage": "rules", "reasons": ["DUP_KNOWN_HAM"]}
You marked this exact text legitimate last week. Deliver it without a second look.
Three verdicts, three different correct actions, and the score alone would not have distinguished them.
Codes are contracts
Every code is a stable string. New ones can be added; none will be renamed or change meaning. That makes them safe to branch on in code, to put in log filters, to show in an admin UI, and to build alerts on (“LLM_UNAVAILABLE rate above 5 % for ten minutes”).
They also make feedback specific. When a customer says “this was wrong”, the codes tell us which stage and which rule to look at, instead of a model’s opaque probability drifting somewhere.
Codes are honest about uncertainty
ML_GRAY_FALLBACK is our least glamorous code and the one we are proudest of. It appears when a message fell in the gray zone and the language model could not be reached, so the small model’s nearest label was used. The verdict is still spam or ham, because your handler needs one, but the code says plainly that this one is a coin with a slight bias. A bare score would have hidden that.
What we deliberately did not do
We did not add a third label. “Maybe” sounds honest and is a way of exporting the hard decision back to you. The API always commits, and uses codes to tell you how much to trust the commitment. That is the whole design: a decision you can act on, and enough context to overrule it.
The full list is in the reason codes reference.