The response
Decision, scores, signals and what to do with each of them.
Every endpoint answers with the same object. Nothing is endpoint-specific.
{
"id": "mod_01jr7q9x2c8h4m6v0b3n5k7t9d",
"reference": "comment_9931",
"decision": "review",
"flagged": ["toxicity"],
"scores": { "toxicity": 0.58, "profanity": 0.31 },
"signals": [
{
"category": "toxicity",
"score": 0.58,
"detector": "ai_text",
"reason": "Contempt aimed at the person being replied to.",
"evidence": ["nobody asked, genius"]
}
],
"used_ai": true,
"took_ms": 912,
"cached": false,
"project": "forum",
"policy": { "slug": "listings", "version": 4 },
"topics": { "gambling": 0.07 },
"facts": { "language": "en", "language_confidence": 0.91 },
"context": { "repeats": 0, "similar": 0 },
"credits": { "remaining": 4771, "charged": 12, "renews_at": "2026-09-30T00:00:00+00:00" }
}
Fields
| Field | Type | |
|---|---|---|
id | string | This verdict's name. Keep it: it is what a false positive report, a support ticket and a webhook all refer to. |
reference | string|null | Whatever you sent as reference, handed back. Your comment id, your listing id. |
decision | string | allow, review or block. The one field you have to act on. |
flagged | string[] | Everything that crossed its review line, worst first: categories by name, subjects as topic:gambling, lead types as lead:no_budget. A shortcut, not extra information. |
scores | object | Category to confidence, 0 to 1. Only categories that scored appear. |
signals | object[] | Why. One entry per detector that had something to say. |
used_ai | boolean | Whether the model ran, or the cheap detectors settled it. |
took_ms | integer | Wall time inside the pipeline, not including the network. |
topics | object | How much this is about each subject you measure, 0 to 1. A separate axis from scores. See topics. Absent when no topic is configured. |
facts | object | What we noticed that is not a finding: the language, an address's canonical form, an image's fingerprint. Absent when there was nothing to notice. |
context | object | What we knew beyond the text: how often it has arrived, and the sender's record. See context. Absent when it was empty. |
redacted | string | The content with the personal data masked, when you asked for it with redact. |
degraded | boolean | Present and true only when you asked for the model and it could not be reached, so this verdict came from the cheap detectors alone. Different from used_ai: false, which means they settled it. |
model | object | Present only when you asked for the model and we deliberately did not run it, which is a third thing again and not a failure. asked, read and why. |
shadow | object | What a policy on trial would have decided. Recorded, never acted on. See policies. |
project | string | The project the verdict was filed under: the one the call named, or your default. |
policy | object | Which rules produced this verdict, and which version of them. default means the shipped ones, inline rules sent with the call, and overridden: true a policy with the call's rules laid over it. See policies. |
cached | boolean | Whether this content had been judged before and the answer was remembered rather than recomputed. |
credits | object | remaining, charged for this call, and renews_at (ISO 8601). Cheap to watch on every call. |
A review verdict also opens an entry in
the queue, so the middle decision has
somewhere to be decided rather than being a word in a response nobody acts on.
flagged
Everything that crossed its review line, worst first. A verdict is decided on three axes and this one field carries all of them, so a prefix is what says which axis a name belongs to and where its number is:
| Shape | What it is | Its number |
|---|---|---|
toxicity | A category: a claim that something is wrong with the content. | scores |
topic:gambling | A subject you gave a line to. Not a claim that anything is wrong, only that your rule is about it. | topics |
lead:no_budget | A kind of lead you gave a line to. | leads |
Categories keep their plain names, so code written against this field before the other
two axes existed reads exactly what it always read. Only a measure with a line in your
policy or in rules can appear here at all: a subject or a lead type nobody
acted on is measured and reported, and named nowhere.
Neither of the other two axes produces a signal, because a measure is not a finding. So when one of them took the decision, this field is the only place that says what took it. Read it before telling somebody nothing was flagged about content you have just refused.
Signals
| Field | |
|---|---|
category | One of the fifteen. |
score | This detector's confidence, to three decimals. The score in scores is the aggregate. |
detector | What found it. Text: term, policy_term, evasion, link_spam, scam, url, contact_details, sensitive_numbers, flood, repetition, script_abuse, language_mismatch, gibberish, prompt_injection. Across a conversation: pile_on, minor_safety. One person's words in a thread: account_rental, off_platform, rental_scam, job_scam, romance_scam. Only on their own surface: fake_review and discriminatory_job_ad. Identity: disposable_email, junk_name. Images: image_metadata, image_hash. Model: ai_text, ai_image. The measurements are not in here, because they are not findings: topic and lead answer in topics and leads, and image_provenance in facts. |
reason | One sentence a support agent can read out to the person whose content it is. |
evidence | Fragments copied verbatim from the content. Sometimes empty, when nothing specific carries the score. Card and phone numbers are masked here: a detector whose job is to notice a number that should not be public must not be the thing that copies it somewhere else. |
spans | Where it was, as [offset, length] into the content you sent. Only from detectors that matched the raw text, because anything working on normalised text has no truthful way back and a mask on a guessed position covers the wrong words. This is what redact uses. |
reasonandevidenceexist so you can tell somebody why their post was held without writing that sentence yourself. Show them. A rejection with no reason is what makes people think moderation is arbitrary.
Scores are ours, the decision is yours
decision is scores compared against our default thresholds. If
your policy differs, ignore it and read scores directly: a dating app
tolerates flirting and not harassment, a children's forum is the other way round, and a
marketplace cares about spam above everything. That is also why this is fifteen
categories and not one "toxicity" number, which would bake somebody else's policy into
your product.
Claims about content, never about people
Every category describes the text or the image. "This text is harassment" can be checked; "this user is a harasser" is a claim about a human being, and no API has any business making it. Nothing here is a verdict on an account.
reference
Every endpoint accepts an optional reference: your own id for the thing
being judged, up to 120 characters. It is never parsed, only stored and handed back,
and it is what makes a verdict findable later by the name you already use for it. Send
it. The alternative is keeping a map from our ids to yours, which is a table you have
to write and keep correct.
cached
A verdict is a function of what went into it, so identical content submitted twice gets the same answer without running the pipeline again, which matters most on the calls that reach a model, where the repeat would otherwise pay for another reading. A cached call is billed as one check, one credit, whatever the original cost.
used_ai and cached answer different questions and both are
worth reading. used_ai: true, cached: true means a model produced this
verdict, earlier, for content identical to yours. Every call still gets its own
id and its own row: it is a second question with the same answer, not the
same question. For the same call made twice, see
idempotency.
facts
Things worked out on the way past that are not claims that anything is wrong. Kept out
of signals deliberately: a signal says something is wrong with the content,
and putting this text is Spanish in the same list as this text is a
threat makes every client filter it back out again.
language, language_confidence | Text over 60 characters. Below that a guess is worse than nothing, because somebody will route on it. |
email.canonical, email.domain, email.mx | The address as its provider reads it, and whether the domain can receive mail at all. a.n.a+tag@googlemail.com and ana@gmail.com are one inbox and two rows in your users table. |
image.text | What is written in the picture, transcribed. It has already been through the text detectors; see images. |
age_signal | Somebody in a conversation said how old they are. A fact and never a finding: a thirteen-year-old saying so is a child using a website, not a thing they did wrong. |
image.hash | A perceptual fingerprint, so you can recognise the same picture on your side too. It survives re-saving and re-compression, which a checksum of the file does not. |
degraded
Model providers have outages. When ours is failing, a call that asked for the model gets
the free detectors' verdict and "degraded": true, and is billed as the
cheap call, because that is what ran.
It is a separate field rather than a quiet used_ai: false on purpose. Those
are very different statements: one says the cheap detectors were enough, the other says
nobody read it. Only the first is reassuring, and a client that treats an outage as a
clean bill of health is exactly the failure this field exists to prevent. Hold or queue
what matters to you when you see it.
After a few failures in a row the model is skipped outright for a minute rather than waited for on every request. One outage should not turn a service that answers in milliseconds into one that answers in seconds.
model
Present only when you asked for the model and we deliberately did not run it. Nothing failed and nothing is wrong: it is a decision of ours, and it is named rather than left for you to work out from the bill.
"model": { "asked": true, "read": false, "why": "conversation_sampling" }
why | |
|---|---|
conversation_sampling | A message of a conversation with nothing in it for the free detectors, arriving between the points where the whole thread is read again. Every new message is a new call, so reading all of them would make a forty message thread forty model calls. |
Three statements that look alike and are not, and this field is what separates the third
from the other two. used_ai: false with no model means the
cheap detectors settled it. degraded means nobody could read it.
model means we chose not to. The verdict is the free detectors' verdict in
all three cases, and the call is billed as the cheap one.