curl --request POST \
--url https://api.tuteliq.ai/api/v1/safety/grooming \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"messages": [
{
"sender_role": "<string>",
"text": "<string>",
"sender_age": 123
}
],
"continuation_token": "<string>",
"reset_conversation": true,
"message_id": "<string>",
"context": {
"child_age": 123,
"participant_age": 123,
"platform": "<string>",
"language": "<string>"
},
"options": {
"verdict_only": false
},
"external_id": "<string>",
"customer_id": "<string>",
"metadata": {},
"incident_moderation_enabled": true
}
'const options = {
method: 'POST',
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
body: JSON.stringify({
messages: [{sender_role: '<string>', text: '<string>', sender_age: 123}],
continuation_token: '<string>',
reset_conversation: true,
message_id: '<string>',
context: {
child_age: 123,
participant_age: 123,
platform: '<string>',
language: '<string>'
},
options: {verdict_only: false},
external_id: '<string>',
customer_id: '<string>',
metadata: {},
incident_moderation_enabled: true
})
};
fetch('https://api.tuteliq.ai/api/v1/safety/grooming', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));import requests
url = "https://api.tuteliq.ai/api/v1/safety/grooming"
payload = {
"messages": [
{
"sender_role": "<string>",
"text": "<string>",
"sender_age": 123
}
],
"continuation_token": "<string>",
"reset_conversation": True,
"message_id": "<string>",
"context": {
"child_age": 123,
"participant_age": 123,
"platform": "<string>",
"language": "<string>"
},
"options": { "verdict_only": False },
"external_id": "<string>",
"customer_id": "<string>",
"metadata": {},
"incident_moderation_enabled": True
}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)val client = OkHttpClient()
val mediaType = MediaType.parse("application/json")
val body = RequestBody.create(mediaType, "{\n \"messages\": [\n {\n \"sender_role\": \"<string>\",\n \"text\": \"<string>\",\n \"sender_age\": 123\n }\n ],\n \"continuation_token\": \"<string>\",\n \"reset_conversation\": true,\n \"message_id\": \"<string>\",\n \"context\": {\n \"child_age\": 123,\n \"participant_age\": 123,\n \"platform\": \"<string>\",\n \"language\": \"<string>\"\n },\n \"options\": {\n \"verdict_only\": false\n },\n \"external_id\": \"<string>\",\n \"customer_id\": \"<string>\",\n \"metadata\": {},\n \"incident_moderation_enabled\": true\n}")
val request = Request.Builder()
.url("https://api.tuteliq.ai/api/v1/safety/grooming")
.post(body)
.addHeader("Authorization", "Bearer <token>")
.addHeader("Content-Type", "application/json")
.build()
val response = client.newCall(request).execute()import Foundation
let parameters = [
"messages": [
[
"sender_role": "<string>",
"text": "<string>",
"sender_age": 123
]
],
"continuation_token": "<string>",
"reset_conversation": true,
"message_id": "<string>",
"context": [
"child_age": 123,
"participant_age": 123,
"platform": "<string>",
"language": "<string>"
],
"options": ["verdict_only": false],
"external_id": "<string>",
"customer_id": "<string>",
"metadata": [],
"incident_moderation_enabled": true
] as [String : Any?]
let postData = try JSONSerialization.data(withJSONObject: parameters, options: [])
let url = URL(string: "https://api.tuteliq.ai/api/v1/safety/grooming")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.timeoutInterval = 10
request.allHTTPHeaderFields = [
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
]
request.httpBody = postData
let (data, _) = try await URLSession.shared.data(for: request)
print(String(decoding: data, as: UTF8.self))using RestSharp;
var options = new RestClientOptions("https://api.tuteliq.ai/api/v1/safety/grooming");
var client = new RestClient(options);
var request = new RestRequest("");
request.AddHeader("Authorization", "Bearer <token>");
request.AddJsonBody("{\n \"messages\": [\n {\n \"sender_role\": \"<string>\",\n \"text\": \"<string>\",\n \"sender_age\": 123\n }\n ],\n \"continuation_token\": \"<string>\",\n \"reset_conversation\": true,\n \"message_id\": \"<string>\",\n \"context\": {\n \"child_age\": 123,\n \"participant_age\": 123,\n \"platform\": \"<string>\",\n \"language\": \"<string>\"\n },\n \"options\": {\n \"verdict_only\": false\n },\n \"external_id\": \"<string>\",\n \"customer_id\": \"<string>\",\n \"metadata\": {},\n \"incident_moderation_enabled\": true\n}", false);
var response = await client.PostAsync(request);
Console.WriteLine("{0}", response.Content);
false{
"conversation_type_applied": "<string>",
"grooming_risk": "none",
"verdict_withdrawn": true,
"verdict_withdrawn_reason": "<string>",
"verdict_capped": true,
"verdict_capped_reason": "<string>",
"declared_age_overridden": true,
"declared_age_overridden_reason": "<string>",
"confidence": 123,
"flags": [
"secrecy_request"
],
"confidentiality_frame": "unconditional_concealment",
"secrecy_frame_cleared": true,
"flag_attribution": {},
"ungrounded_flags": [
"<string>"
],
"reciprocity_floor_applied": true,
"consent_floor_applied": true,
"consent_violation": "explicit_refusal_overridden",
"rationale": "<string>",
"risk_score": 123,
"recommended_action": "none",
"action_detail": "<string>",
"message_analysis": [
{
"message_index": 123,
"risk_score": 123,
"flags": [
"<string>"
],
"unsupported_flags": [
"<string>"
],
"summary": "<string>"
}
],
"language": "<string>",
"language_status": "stable",
"credits_used": 123,
"continuation_token": "<string>",
"continuation_expires_at": "<string>",
"state_source": "token",
"trajectory_risk": 123,
"trajectory": "rising",
"severity_series": [
123
],
"analysis_status": "ok",
"speaker_stance": "deploying",
"action_capped": true,
"action_capped_reason": "<string>",
"external_id": "<string>",
"customer_id": "<string>",
"metadata": {},
"support": {
"country": "<string>",
"country_name": "<string>",
"emergency_number": "<string>",
"helplines": [
{
"name": "<string>",
"number": "<string>",
"description": "<string>",
"category": "<string>",
"available": "<string>"
}
],
"response_guide": {
"category": "<string>",
"immediateActions": [
"<string>"
],
"resources": [
{
"name": "<string>",
"description": "<string>",
"url": "<string>"
}
]
}
}
}Detect grooming patterns in conversations
Analyze a sequence of messages for multi-stage grooming tactics: trust building, isolation, secrecy requests, boundary testing, and sexual escalation. Accepts full conversation history with sender roles to detect patterns that single-message analysis would miss. Returns grooming risk level, identified tactics as flags, and recommended intervention actions.
curl --request POST \
--url https://api.tuteliq.ai/api/v1/safety/grooming \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"messages": [
{
"sender_role": "<string>",
"text": "<string>",
"sender_age": 123
}
],
"continuation_token": "<string>",
"reset_conversation": true,
"message_id": "<string>",
"context": {
"child_age": 123,
"participant_age": 123,
"platform": "<string>",
"language": "<string>"
},
"options": {
"verdict_only": false
},
"external_id": "<string>",
"customer_id": "<string>",
"metadata": {},
"incident_moderation_enabled": true
}
'const options = {
method: 'POST',
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
body: JSON.stringify({
messages: [{sender_role: '<string>', text: '<string>', sender_age: 123}],
continuation_token: '<string>',
reset_conversation: true,
message_id: '<string>',
context: {
child_age: 123,
participant_age: 123,
platform: '<string>',
language: '<string>'
},
options: {verdict_only: false},
external_id: '<string>',
customer_id: '<string>',
metadata: {},
incident_moderation_enabled: true
})
};
fetch('https://api.tuteliq.ai/api/v1/safety/grooming', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));import requests
url = "https://api.tuteliq.ai/api/v1/safety/grooming"
payload = {
"messages": [
{
"sender_role": "<string>",
"text": "<string>",
"sender_age": 123
}
],
"continuation_token": "<string>",
"reset_conversation": True,
"message_id": "<string>",
"context": {
"child_age": 123,
"participant_age": 123,
"platform": "<string>",
"language": "<string>"
},
"options": { "verdict_only": False },
"external_id": "<string>",
"customer_id": "<string>",
"metadata": {},
"incident_moderation_enabled": True
}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)val client = OkHttpClient()
val mediaType = MediaType.parse("application/json")
val body = RequestBody.create(mediaType, "{\n \"messages\": [\n {\n \"sender_role\": \"<string>\",\n \"text\": \"<string>\",\n \"sender_age\": 123\n }\n ],\n \"continuation_token\": \"<string>\",\n \"reset_conversation\": true,\n \"message_id\": \"<string>\",\n \"context\": {\n \"child_age\": 123,\n \"participant_age\": 123,\n \"platform\": \"<string>\",\n \"language\": \"<string>\"\n },\n \"options\": {\n \"verdict_only\": false\n },\n \"external_id\": \"<string>\",\n \"customer_id\": \"<string>\",\n \"metadata\": {},\n \"incident_moderation_enabled\": true\n}")
val request = Request.Builder()
.url("https://api.tuteliq.ai/api/v1/safety/grooming")
.post(body)
.addHeader("Authorization", "Bearer <token>")
.addHeader("Content-Type", "application/json")
.build()
val response = client.newCall(request).execute()import Foundation
let parameters = [
"messages": [
[
"sender_role": "<string>",
"text": "<string>",
"sender_age": 123
]
],
"continuation_token": "<string>",
"reset_conversation": true,
"message_id": "<string>",
"context": [
"child_age": 123,
"participant_age": 123,
"platform": "<string>",
"language": "<string>"
],
"options": ["verdict_only": false],
"external_id": "<string>",
"customer_id": "<string>",
"metadata": [],
"incident_moderation_enabled": true
] as [String : Any?]
let postData = try JSONSerialization.data(withJSONObject: parameters, options: [])
let url = URL(string: "https://api.tuteliq.ai/api/v1/safety/grooming")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.timeoutInterval = 10
request.allHTTPHeaderFields = [
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
]
request.httpBody = postData
let (data, _) = try await URLSession.shared.data(for: request)
print(String(decoding: data, as: UTF8.self))using RestSharp;
var options = new RestClientOptions("https://api.tuteliq.ai/api/v1/safety/grooming");
var client = new RestClient(options);
var request = new RestRequest("");
request.AddHeader("Authorization", "Bearer <token>");
request.AddJsonBody("{\n \"messages\": [\n {\n \"sender_role\": \"<string>\",\n \"text\": \"<string>\",\n \"sender_age\": 123\n }\n ],\n \"continuation_token\": \"<string>\",\n \"reset_conversation\": true,\n \"message_id\": \"<string>\",\n \"context\": {\n \"child_age\": 123,\n \"participant_age\": 123,\n \"platform\": \"<string>\",\n \"language\": \"<string>\"\n },\n \"options\": {\n \"verdict_only\": false\n },\n \"external_id\": \"<string>\",\n \"customer_id\": \"<string>\",\n \"metadata\": {},\n \"incident_moderation_enabled\": true\n}", false);
var response = await client.PostAsync(request);
Console.WriteLine("{0}", response.Content);
false{
"conversation_type_applied": "<string>",
"grooming_risk": "none",
"verdict_withdrawn": true,
"verdict_withdrawn_reason": "<string>",
"verdict_capped": true,
"verdict_capped_reason": "<string>",
"declared_age_overridden": true,
"declared_age_overridden_reason": "<string>",
"confidence": 123,
"flags": [
"secrecy_request"
],
"confidentiality_frame": "unconditional_concealment",
"secrecy_frame_cleared": true,
"flag_attribution": {},
"ungrounded_flags": [
"<string>"
],
"reciprocity_floor_applied": true,
"consent_floor_applied": true,
"consent_violation": "explicit_refusal_overridden",
"rationale": "<string>",
"risk_score": 123,
"recommended_action": "none",
"action_detail": "<string>",
"message_analysis": [
{
"message_index": 123,
"risk_score": 123,
"flags": [
"<string>"
],
"unsupported_flags": [
"<string>"
],
"summary": "<string>"
}
],
"language": "<string>",
"language_status": "stable",
"credits_used": 123,
"continuation_token": "<string>",
"continuation_expires_at": "<string>",
"state_source": "token",
"trajectory_risk": 123,
"trajectory": "rising",
"severity_series": [
123
],
"analysis_status": "ok",
"speaker_stance": "deploying",
"action_capped": true,
"action_capped_reason": "<string>",
"external_id": "<string>",
"customer_id": "<string>",
"metadata": {},
"support": {
"country": "<string>",
"country_name": "<string>",
"emergency_number": "<string>",
"helplines": [
{
"name": "<string>",
"number": "<string>",
"description": "<string>",
"category": "<string>",
"available": "<string>"
}
],
"response_guide": {
"category": "<string>",
"immediateActions": [
"<string>"
],
"resources": [
{
"name": "<string>",
"description": "<string>",
"url": "<string>"
}
]
}
}
}Authorizations
API key as Bearer token
Body
Sequence of messages to analyze. Consecutive same-role messages of ≤3 characters are automatically defragmented before tactic analysis to prevent letter-by-letter evasion.
100Show child attributes
Show child attributes
Opaque signed token from a previous /grooming call. Carries derived analysis state across calls without server-side storage.
Discard any continuation_token and start a fresh conversation.
Optional customer-side identifier for this batch (used as the evidence pointer in the issued continuation_token).
128Show child attributes
Show child attributes
Show child attributes
Show child attributes
Top-level alias for options.support_threshold. Minimum severity to show crisis support resources (default: high). Critical always shows.
low, medium, high, critical Your unique identifier (e.g. message ID, content ID) for correlating Tuteliq results with your own system. Echoed back in the response and included in webhook payloads so you can match alerts to the original content.
255Your end-customer identifier for multi-tenant / B2B2C scenarios. If you serve multiple customers (schools, apps, brands) from a single API key, set this to route webhook alerts to the correct customer. Echoed back in the response and included in webhook payloads as "customerId".
255Arbitrary key-value pairs for additional context (e.g. { "channel": "discord", "region": "eu" }). Stored with the detection result, echoed in the response, and included in webhook payloads. Maximum 20 properties.
Per-call override of your account incident logging setting. When set, it takes precedence over the account-level flag for THIS request: true forces the incident to be persisted, false suppresses persistence. Omit to use your account default (which itself defaults to enabled). Lets you control incident logging per request, e.g. suppress logging for test traffic.
Response
Default Response
Present only when a caller-declared context.conversation_type actually changed routing. Confirms the setting was taken into account, rather than leaving the platform to assume it was.
Grooming-risk level. "low" indicates a signal worth monitoring but no action required; branch your alerting on recommended_action (flag_for_review / block / immediate_intervention), not the bare presence of a low-level risk.
none, low, medium, high, critical True when the analysis returned a high or critical verdict with no tactic and no risk score to support it. The verdict was reconciled to what the response actually evidences and recommended_action brought down to review, rather than returning two fields that contradict each other. Never fires on a finding that names a tactic or carries any score. Absent unless it applied.
Human-readable explanation. Present only when verdict_withdrawn is true.
True when the analysis returned a high or critical verdict carrying a risk score but naming no tactic to support it. The verdict is capped to medium and recommended_action to flag_for_review, so the conversation still reaches a reviewer rather than routing an escalation a reviewer cannot check; risk_score is capped to the top of the medium band. Never fires on a finding that names a tactic. Only ever lowers, never raises. Absent unless it applied.
Human-readable explanation. Present only when verdict_capped is true.
True when the caller declared an adult child_age but a participant states a minor age in the conversation itself. The declared age was not used. Nothing is escalated by this on its own; the analysis simply runs as if no age had been sent. Worth investigating at your end: an age check that disagrees with what the user says in the chat is what a bypassed age verification looks like. A stated ADULT age never has this effect, because message text is attacker-controlled.
Human-readable explanation. Present only when declared_age_overridden is true.
Closed vocabulary. Values outside this list are dropped before the response is built, so this field is safe to switch on. Two groups share the array. The eight TACTICS the analyser classifies against are secrecy_request, boundary_pushing, photo_request, gift_giving, isolation, flattery, sexual_content and meeting_request; each asserts that a participant performed that behaviour. The remaining four, sexualization, reconnaissance, grooming and sextortion, reach this array from coded-term corroboration and assert something weaker: that the conversation contains a recognised coded term of that kind. If you are reporting on the tactic taxonomy, use the first eight.
secrecy_request, boundary_pushing, photo_request, gift_giving, isolation, flattery, sexual_content, meeting_request, sexualization, reconnaissance, grooming, sextortion The adult's own framing of who may know, described factually rather than judged. unconditional_concealment asks the child to keep something from someone with no stated limit; limited_confidentiality offers privacy but names a condition under which the adult would tell someone ("unless someone's at risk"), which is safeguarding practice rather than a secrecy demand; guardian_included tells the child to inform a parent or carer, or says the adult will.
unconditional_concealment, limited_confidentiality, guardian_included, none True when secrecy_request was removed from flags because the adult stated the limits of confidentiality or included a guardian. Routing is unaffected: the secrecy_request + isolation escalation reads the pre-removal flag list, so this can never lower an action. Absent when nothing was removed.
Which participant each flag is attributed to: tactic -> the sender_role values of the messages that actually carry it. Derived from the per-message analysis, so it answers "who is doing this?" rather than assuming the non-child role. Omitted when the model produced no per-message tags. Attribution only; it does not change the verdict or imply the other party is blameless.
Show child attributes
Show child attributes
Conversation-level tactics the model asserted but that could not be supported: either tied to no specific message at all, or tied only to messages that a separate adjudication check found do not express that tactic (#262). Removed from flags and surfaced rather than silently dropped: a tactic here is an UNSUPPORTED claim, not a finding, and should not be treated as evidence.
True when the verdict was floored to at least medium / 0.6 / flag_for_review because a separate factual classifier confirmed adult-minor romantic reciprocity - an adult and a minor declaring or reciprocating romantic love, directly or relayed through a third party (#189). A raise-only floor: it never lowers anything, and it exists because this implicit-outcome shape carries no tactic vocabulary and its model-side detection proved unstable across sittings (#292). Absent when the floor did not apply.
True when recommended_action was raised to immediate_intervention because the model reported an explicit refusal being overridden, or a threat to expose an intimate image or a confidence, in order to obtain compliance. This is a floor and never lowers an action. It exists because urgent routing on this endpoint is otherwise driven by the adult/child age gap, which is structurally absent when both participants are minors, leaving peer sexual coercion unable to reach the urgent band (#278). Absent when the floor did not apply.
The factual finding behind consent_floor_applied, present only when one was reported. Deliberately describes what happened rather than who did it, so it is independent of the participants' ages.
explicit_refusal_overridden, threatened_exposure Routing key — a single stable token, safe to switch on. Ordered weakest to strongest: none (no signal), monitor (log, no human needed), flag_for_review (queue for a moderator), block (withhold or limit the content), immediate_intervention (crisis path). Human-readable guidance is in action_detail, not here.
none, monitor, flag_for_review, block, immediate_intervention Optional one-to-two-sentence explanation of recommended_action, intended for a moderator UI. Never branch on this field — it is free text and its wording is not stable across releases.
Per-message risk breakdown showing how risk escalates across the conversation. Rows are emitted for the messages the model judged relevant, so a conversation may have fewer rows than messages: a message without a row carried no per-message finding, and is not the same as a row with risk_score 0. message_index is model-supplied; every flag on a row was checked against that message by a separate adjudication, and a flag the check refuted is moved to the row's unsupported_flags rather than left in flags.
Show child attributes
Show child attributes
Language code used for analysis (auto-detected or provided)
Language support maturity: "stable" (en) or "beta" (all others)
stable, beta Number of credits consumed by this request
Privacy-first signed token carrying derived analysis state across calls. Pass back on the next /grooming request.
ISO 8601 expiry timestamp of the continuation_token.
How prior state was sourced for this call.
token, fresh, reset Conversation-level risk 0-1, distinct from risk_score which scores only this message. Anchored on the highest severity seen so far and decaying slowly across benign turns, so a friendly message after an escalation does not reset it. Absent on the first turn. Derived from the signed token: no message content is stored to produce it.
Direction of travel across the conversation so far.
rising, stable, declining, none Per-turn severity, oldest first — the evidence behind trajectory_risk, so a moderator can see why a benign-looking message carries elevated conversation risk.
Status of the analysis. ok is the default. engine_error means the analysis engine could not produce a verdict (malformed/adversarial input) and recommended_action is forced to flag_for_review. out_of_scope_adults means the caller provided 2+ adult ages (via context.participant_age, context.child_age, or per-message sender_age) and the call was short-circuited as out of scope for grooming — consider /safety/coercive-control or /safety/social-engineering for adult-adult dynamics.
ok, engine_error, out_of_scope_adults The model's own conclusion on whether the content is the speaker deploying a tactic in this exchange, or describing/reporting one performed by a third party (a disclosure, a survivor testimony, a bystander report) — independent of severity. Present when the model returns a recognised value AND at least one tactic is present: a stance is a statement about a tactic, so it is omitted on a conversation with no flags.
deploying, describing_third_party, unclear True when recommended_action was capped below block because speaker_stance was describing_third_party — withholding a disclosure or a help-seeking report silences the person who needs to be heard. grooming_risk/risk_score are unaffected; only routing was capped. Absent when no cap applied.
Present only when action_capped is true.
Echo of the external_id you provided in the request
Echo of the customer_id you provided in the request
Echo of the metadata you provided in the request
Country-specific crisis helplines and response guidance (only included for high/critical severity)
Show child attributes
Show child attributes