Content
Send text, images, video, or audio to Stream, and Stream checks it against your policy and returns keep, flag, or remove. If your own model already classified the content, send its results instead so moderators can review them in the review queue.
Which API should I use?
Four endpoints accept content. Pick the one that matches what you want back.
| Check | Analyze | Custom Check | Labels | |
|---|---|---|---|---|
| Use it when | You want Stream to moderate a whole entity and return one action | You need a verdict per named field (title, description), or you have image bytes |
Your own model already classified the content and you want moderators to review it | You only want classification labels for a text and will act on them yourself |
| Content | texts, images (max 30), videos, audios (max 10), and other_media |
Named text fields, plus image bytes over HTTP multipart. No video or audio | Text, image, or video, plus your own flags | Only text |
| Result | One recommended_action for the whole entity |
Per-field action on texts and images |
A review queue item | labels, harm_type, severity, and recommended_action |
| Review queue | An item is created whenever the action isn't keep |
Stateful mode only (when you send entity fields). Stateless mode returns verdicts inline | Always | Never |
| Async | Image, video, and audio checks can finish async (status: pending) |
Optional: async_response: true delivers results by webhook |
No | No |
| SDKs | All server-side SDKs | All server-side SDKs for texts. Image bytes are HTTP only | All server-side SDKs | All server-side SDKs |
Examples below use the server-side SDKs. See the setup guides for Node, Python, Go, Java, PHP, Ruby, or .NET.
Stream polls (in Chat and Feeds) don't need an API call: their text is moderated automatically with a polls policy. See Polls.
Check
Submit text, image URLs, video, and audio. Get back one recommended_action (keep, flag, or remove) and a review-queue item you can show on a custom dashboard.
- Create a policy with engines and actions
- Call
checkwith your entity ids andconfig_key - Apply
recommended_actionin your product - Review flagged items in the dashboard or via the review queue API
API Usage
In the context of the Moderation API, the term "entity" refers to a piece of content that you send for moderation. This can be text, images, video, audio, or a mix.
var response = await client.CheckAsync(new CheckRequest
{
EntityType = "entity_type",
EntityID = "entity_id",
EntityCreatorID = "entity_creator_id",
ModerationPayload = new ModerationPayload
{
Texts = new List<string> { "this is bullshit", "f*ck you as***le" },
Images = new List<string> { "example.com/test.jpg" },
},
ConfigKey = "config_key",
});Request Params
| key | required | type | description |
|---|---|---|---|
| entity_type | true | string | This is identifier for the type of content you are sending for moderation. This helps with categorizing content on dashboard. E.g., if you have multiple products then you can set unique entity_type for content coming from each product. It could be any string. |
| entity_id | true | string | Unique identifier for entity |
| entity_creator_id | true | string | Unique identifier for user who created this entity. Generally this is the user id of the app user. |
| moderation_payload | false | object | Texts, images (max 30), videos, audios (max 10), and other media. For comments, custom can carry comment context for the review queue. custom is limited to 10KB; larger payloads are rejected with HTTP 413. |
| config_key | false | string | Key of the config to be used for moderation. See Configuration for details. |
| country_codes | false | array | Up to 300 ISO 3166-1 alpha-2 codes (for example ["US", "DE"]). Values are trimmed, upper-cased and deduplicated, and the first is the primary country. Any invalid entry returns 400. An empty list means no country. Every country is sent to the AI text and image harm detection engines as country context, and is matched by country rules. |
| country_code | false | string | Deprecated, use country_codes. One ISO 3166-1 alpha-2 code, treated as a one-entry country_codes. Ignored when country_codes is sent, but an invalid value still returns 400. |
Response
| key | type | description |
|---|---|---|
| status | completed or pending |
pending means async checks are still running. completed means the check finished. The response also includes triggered_rules, task_id when a task is running, and test_mode when you sent a test check. A test check can also include intent_matched_topic, intent_score, intent_score_threshold, and intent_would_fire. |
| task_id | string | Id of the task running the async moderation. You can check the status of the task itself using GetTask endpoint. |
| recommended_action | “flag” , “remove” , “keep” | Final result of moderation which suggest what action should be taken for moderated entity.
|
| item | object | This is basically a json representation of the review queue item accessible on Stream dashboard. You can use this to compose your own dashboard UI. |
Comment context in the review queue
When the entity is a comment, moderators decide faster if they can see what it was posted on and the conversation before it. Send that context in moderation_payload.custom and the dashboard's review queue shows it next to the flagged comment:
- A content card: what the comment was posted on (type, id, title, tags, subtitle, description, link, thumbnail)
- The thread: the top-level comment, the latest earlier replies and the moderated comment, highlighted with its action (Flagged, Removed, Shadow blocked, Blocked)
The format works for any product: a video, a news article, a marketplace listing. The dashboard recognizes it by shape, not by entity_type: a positive integer schemaVersion marks it. Without one, custom shows as a plain list of keys and values. For other kinds of review items, see Custom context blocks.
await client.moderation.check({
entity_type: "comment",
entity_id: "a_r7",
entity_creator_id: "driver_dan",
moderation_payload: {
texts: ["Cyclists can all go back where they came from"],
custom: {
schemaVersion: 1,
depth: 1,
content: {
id: "news-48213",
type: "article",
title: "City council approves new bike lanes downtown",
subtitle: "Local news",
tags: [
{ key: "section", label: "Section", values: ["Transport"] },
{
key: "topics",
label: "Topics",
values: ["City council", "Cycling"],
},
],
description: "The plan adds 12 km of protected lanes over two years...",
url: "https://example.com/news/bike-lanes",
imageUrl: "https://example.com/news/bike-lanes.jpg",
},
thread: {
topLevel: {
id: "a_top",
user: { id: "cyclist_jen", name: "Jen" },
text: "Finally. I have been waiting for this for years.",
createdAt: "2026-10-06T08:12:00Z",
},
earlierReplyCount: 7,
earlierReplies: [
{
id: "a_r6",
user: { id: "driver_dan", name: "Dan" },
text: "Good luck finding parking now.",
createdAt: "2026-10-06T08:40:00Z",
},
],
},
},
},
config_key: "config_key",
});The moderated comment itself is not part of custom: its text comes from texts and its author from entity_creator_id. Keys inside custom are camelCase.
| key | type | description |
|---|---|---|
| schemaVersion | positive integer | Required. Set to 1. It marks custom as a context; any positive integer works and its value is not compared |
| contextVersion | any | Optional. Your own version of the context data, for your purposes. The dashboard never reads it |
| depth | 0 or 1 |
0 for a top-level comment, 1 for a reply. Threads are one level deep. When omitted, the comment is treated as a reply if thread.topLevel is set |
| content.id | string | Your id for the content, shown next to the type so moderators can copy it |
| content.type | string | Free-form kind of content: video, article, listing, ... |
| content.title | string | Title of the content |
| content.subtitle | string | Shown after the tags, on one line under the title |
| content.tags | array of tag groups | { key, label, values }. Groups show on one line, separated by ·, values within a group by commas. Hovering a value shows its group label. key is not displayed |
| content.description | string | Longer description, truncated at 150 characters with a "Show more" toggle |
| content.url | string | Link to the content, opened from the card. Only http and https URLs are shown |
| content.imageUrl | string | Thumbnail. Only http and https URLs are shown |
| thread.topLevel | comment | The comment a reply sits under. Omit it for a top-level comment |
| thread.earlierReplies | array of comments | Replies posted before the moderated one, oldest first. Send the latest few, not the whole thread |
| thread.earlierReplyCount | integer | Total replies before the moderated one. When it is larger than earlierReplies, the dashboard shows "Showing last X of N earlier replies" |
| thread comment | object | Each thread comment has an id, a user ({ id, name, image }, name and image optional), its text and an optional createdAt timestamp (ISO 8601) |
Every field except schemaVersion is optional, and unknown keys are ignored. A malformed part never hides the rest of the context: the rest renders, and the malformed part (a mistyped field, a thread comment sent as a string) is shown as sent in an "Additional info" card labelled with its path, such as thread.topLevel.
Custom context blocks
For review items that are not a comment, send custom.blocks: an ordered list of blocks the dashboard draws in the order sent. A new kind of review item is a new arrangement of blocks, with no dashboard change needed. Each block has a kind:
| kind | shows |
|---|---|
summary |
A header card: why the item is here and what it adds up to |
list |
Rows such as violations, prior reports or recent posts |
content |
What an item was posted on. Same fields as the comment context's content |
thread |
A comment thread, ending with the item under review, which the dashboard adds. Same fields as the comment context's thread, plus depth on the block. Only one thread block is drawn; any further one is shown as sent |
For example, a summary block followed by a list block:
await client.moderation.check({
entity_type: "user_review",
entity_id: "review_8812",
entity_creator_id: "user_42",
moderation_payload: {
custom: {
schemaVersion: 1,
blocks: [
{
kind: "summary",
label: "Suspension review",
title: "Recommended: Suspend",
subtitle: "Reached 3 strikes",
facts: [
{ label: "Strikes", before: 2, after: 3 },
{
label: "Shadow banned since",
value: "2026-11-02T20:09:00Z",
format: "datetime",
},
{ label: "No previous suspension" },
],
counts: [
{ label: "MEDIUM", count: 6 },
{ label: "LOW", count: 5 },
],
note: "11 violations in the last 90 days",
},
{
kind: "list",
title: "Violations",
sort: "newest",
total: 11,
collapsedCount: 3,
items: [
{
id: "v_1",
createdAt: "2026-11-02T20:09:00Z",
text: "You are all clowns",
details: ["Comment", "Automated"],
tags: [
{
key: "category",
label: "Category",
values: ["Harassment & Bullying"],
},
{ key: "tier", label: "Tier", values: ["MEDIUM"] },
],
content: { title: "Weekend match report" },
badge: "Trigger",
},
],
},
],
},
},
config_key: "config_key",
});Summary block
| key | type | description |
|---|---|---|
| label | string | Shown in caps on the first line, next to the user, e.g. Suspension review |
| user | user | Who the summary is about ({ id, name, image }). Defaults to the review item's creator |
| title | string | The bold line, e.g. Recommended: Suspend |
| subtitle | string | Shown after the title, e.g. Reached 3 strikes |
| facts | array of facts | { key, label, value, before, after, format }. A fact shows value, a before -> after pair, or its label alone. format: "datetime" shows value, before and after as dates; a value that is not ISO 8601 shows as sent. key is not displayed. A fact without a label is dropped |
| counts | array of counts | { key, label, count }, shown as chips such as MEDIUM × 6 |
| note | string | A closing line, e.g. 11 violations in the last 90 days |
List block
| key | type | description |
|---|---|---|
| title | string | Plural noun for the rows, e.g. Violations. Shown with the sort as the heading, e.g. Violations · newest first. Also used in the "Show all 11 violations" button |
| sort | newest or oldest |
Sorts rows by createdAt; rows without an ISO 8601 time go last. When omitted, rows keep the order sent |
| total | integer | How many rows exist, when you send fewer. When higher than the rows sent, the list notes "Showing N of M violations" and the button reads "Show N more". A total lower than the rows sent is ignored |
| collapsedCount | positive integer | Rows shown before "Show all". Defaults to 3 |
| items | array of rows | The rows, described below. A row with nothing to show (no text, tag values, createdAt, details, badge or content.title) is dropped |
Each row can have an id, a user (defaults to the review item's creator), text, createdAt (ISO 8601; any other value shows as sent), details (strings shown in the header after the time, e.g. Comment, Automated), tags (tag groups { key, label, values }, shown as chips with the group label on hover), content (a content object; its title shows as On <title>, linked when url is an http(s) URL) and badge (a corner badge such as Trigger). A badged row is highlighted and stays visible when the list is collapsed.
Timestamps are ISO 8601. A time without an offset is read as UTC, and a date-only value shows without a time. Labels and chips show exactly as sent. A block of an unknown kind, or one that does not parse, is shown as sent in the "Additional info" card (labelled e.g. blocks[1]) and the other blocks still render. A comment context sent with depth, content and thread instead of blocks keeps working unchanged. Send one or the other: when blocks holds a block the dashboard can draw, top-level depth, content and thread are ignored; when it holds none, custom is read as a comment context and the blocks are shown as sent.
This section covers what moderators see. To let the AI Text (LLM) engine read the same data when it decides on the comment, list the keys in the policy's llm_config; see Context from custom data.
Analyze
Submit named text fields and get a keep / flag / remove decision per label. Use it when you need a verdict per field instead of one action for the whole entity.
Server-side only. Call analyze the same way as check for named texts. At least one text field or image is required.
Raw image bytes are not in the SDK. Send them with an HTTP multipart/form-data request: one JSON payload part plus image:<label> file parts. That is the only way to attach bytes today.
Modes
Stateful vs stateless. Send entity_type, entity_id, and entity_creator_id together to persist a review-queue row (when content is flagged) and evaluate rules. Omit all three for a stateless call: verdicts only, no queue, no webhooks. config_key is required when stateless. Partial entity fields return 400.
entity_type is yours (listing, external:call). Types that start with stream: are reserved. You may add a subtype for routing (external:call:keyframe); webhooks echo the canonical type (external:call).
Sync vs async. Default is sync: verdicts in the HTTP body. Set async_response: true (stateful only) to get status: "pending" and receive moderation.text_analysis.complete / moderation.image_analysis.complete webhooks instead. Do not send async_response: true without entity fields.
Calling Analyze
var response = await client.AnalyzeAsync(new AnalyzeRequest
{
EntityType = "listing",
EntityID = "listing_42",
EntityCreatorID = "user_42",
ConfigKey = "catalog-v1",
ContentIds = new Dictionary<string, string> { { "caption", "cap_42" } },
Texts = new Dictionary<string, string> { { "caption", "Vintage watch, mint condition" } },
});Image bytes (HTTP only)
client.moderation.analyze sends the JSON fields above. It cannot attach files. To send raw image bytes, POST multipart/form-data yourself (max 10 images, 250 KB each by default, 20 MB for the whole request):
curl -X POST "https://chat.stream-io-api.com/api/v2/moderation/analyze?api_key=${API_KEY}" \
-H "Authorization: ${TOKEN}" \
-H "stream-auth-type: jwt" \
-F 'payload={"entity_type":"listing","entity_id":"listing_42","entity_creator_id":"user_42","config_key":"catalog-v1","texts":{"caption":"Vintage watch, mint condition"},"content_ids":{"caption":"cap_42","main_photo":"img_42_main"}};type=application/json' \
-F "image:main_photo=@frame.jpg;type=image/jpeg"Analyze request
| key | required | type | description |
|---|---|---|---|
| entity_type | with the other entity fields | string | Caller-defined type. Required with entity_id and entity_creator_id. Omit all three for stateless mode |
| entity_id | with the other entity fields | string | Your identifier for this content |
| entity_creator_id | with the other entity fields | string | User id of the content creator |
| config_key | stateless: yes | string | Policy key. Optional in stateful mode (resolved from entity_type). Unknown keys return 422 |
| texts | no | object | Named text fields, keyed by your label. Max 20 keys, each 1 to 64 characters |
| content_ids | no | object | Per-field ids keyed by the same labels as texts or image:<label>. Echoed as id on each verdict. Values: 1 to 128 chars, A-Z a-z 0-9 . _ - |
| content_published_at | no | string (RFC3339) | When the content was produced. Used as the score for aggregation-rule windows |
| custom | no | object | Metadata stored on the review-queue item and echoed on webhooks |
| async_response | no | bool | Stateful only. When true, HTTP is pending and verdicts arrive via webhooks |
| country_codes | no | array | Up to 300 ISO 3166-1 alpha-2 codes (for example ["US", "DE"]). Trimmed, upper-cased and deduplicated; the first is the primary country. Any invalid entry returns 400. Every country is sent to the AI text and image harm detection engines as country context, and is matched by country rules |
| country_code | no | string | Deprecated, use country_codes. One ISO 3166-1 alpha-2 code, treated as a one-entry country_codes. Ignored when country_codes is sent, but an invalid value still returns 400 |
Images: HTTP only. Each file part is named image:<label>. Max 10 images, 250 KB each by default, 20 MB for the whole request. The SDK analyze method does not accept file parts.
Analyze response
Successful calls return 201.
| key | type | description |
|---|---|---|
| status | complete, partial, pending |
complete if every field was screened. partial if some have error. pending is the async ack |
| texts | object | Per-text verdicts keyed by your label. Absent on async pending |
| images | object | Per-image verdicts keyed by your label |
| duration | string | Server wall-clock duration |
Each text or image field:
| key | description |
|---|---|
| id | Echo of content_ids[<label>] when you sent one |
| action | keep, flag, or remove. Omitted when error is set. Never treat a missing action as keep |
| classifications | Text: { name, severity }. Image: { name, confidence, subclassifications } |
| severity | Text only. Highest severity across classifications (low, medium, high, critical) |
| language | Text only. Detected language code |
| confidence | Image only. Highest confidence (0 to 1) |
| ocr_classifications | Image only. Text labels extracted from the image (name, severity) |
| error | Field could not be screened. Retry this label only |
{
"status": "complete",
"texts": {
"caption": {
"id": "cap_42",
"action": "flag",
"classifications": [{ "name": "INSULT", "severity": "high" }],
"severity": "high",
"language": "en"
}
},
"images": {
"main_photo": {
"id": "img_42_main",
"action": "keep"
}
}
}If some fields fail, status is partial (still 201). Retry only the labels with error. If nothing could be screened, the API returns 503 with a standard error envelope, not this body. Bad input is 400. A missing policy is 422.
Async pending body:
{ "status": "pending", "duration": "112.84ms" }Wait for Analyze webhook events. A provider error stays on *.complete as field error. moderation.analysis.failed means Stream could not finish the listed content_ids after the pending ack. Treat those ids as unscreened.
Custom Check
Custom Check lets you submit results from your own AI model, for a Stream Chat message or your own content, so moderators can review them in the dashboard.
Add Custom Flags to a Chat Message
Custom Flags can be added to a Chat message. This enables you to submit your own AI moderation results of a chat message for review by moderators on the Dashboard.
await client.CustomCheckAsync(new CustomCheckRequest
{
EntityType = "stream:chat:v1:message",
EntityID = messageId,
EntityCreatorID = "user_id",
Flags = new List<CustomCheckFlag>
{
new() { Type = "custom_check_image", Reason = "Image was NSFW", Labels = new List<string> { "NSFW" } },
new() { Type = "custom_check_text", Reason = "Text was harmful", Labels = new List<string> { "harmful" } },
new() { Type = "custom_check_video", Reason = "Video contains copyright material", Labels = new List<string> { "copyright-violation" } },
},
});Add Custom Check Flags to the Custom Content
The Custom Check endpoint enables you to submit your own AI moderation results for the custom content for review by moderators on the Dashboard.
await client.CustomCheckAsync(new CustomCheckRequest
{
EntityType = "entity_type",
EntityID = "entity_id",
EntityCreatorID = "entity_creator_id",
ModerationPayload = new ModerationPayload
{
Texts = new List<string> { "offensive" },
},
Flags = new List<CustomCheckFlag>
{
new()
{
Type = "custom_check_text",
Reason = "Text was offensive",
Custom = new Dictionary<string, object> { { "explaination", "custom is a nullable field allowing you to attach a custom object to the flag for your specific use cases or requirements" } },
},
},
});Request Params
| key | required | type | description |
|---|---|---|---|
| entity_type | true | string | This is identifier for the type of the entity. This helps with categorizing Content. |
| entity_id | true | string | Unique identifier for entity. |
| entity_creator_id | true | string | Unique identifier for user who created this entity. Generally this is the user id of the app user. |
| moderation_payload | true | object | Entity or content to be moderated |
| flags | true | array | List of custom flags |
| flag.type | true | "custom_check_text", "custom_check_image", "custom_check_video" | Type of a custom flag |
Response
| key | type | description |
|---|---|---|
| status | complete |
Custom check stores the review item as complete. Check itself uses completed or pending. Analyze uses complete, partial, or pending. |
| id | string | id of the review queue item. |
| item | object | This is basically a json representation of the review queue item accessible on Stream dashboard. You can use this to compose your own dashboard UI. |
Polls
Stream moderates the text of polls as users write it: the question, the description, the options, options suggested by other users, and free-text answers. Set up a polls policy and poll writes that carry text are checked before they are stored, with no extra API calls from your app.
Poll moderation counts towards your text moderation quota.
What gets moderated
| Poll write | Texts checked | Review queue entity type |
|---|---|---|
| Create a poll | Question, description and every option | stream:v1:poll |
| Update a poll (full or partial) | The poll's texts after the update | stream:v1:poll |
| Add an option (including a user's suggestion) | The new option | stream:v1:poll |
| Edit an option | The option's new text | stream:v1:poll |
| Cast a vote with a free-text answer | The answer | stream:v1:poll_vote |
Votes on an option carry no text and are not moderated. An update that leaves every text unchanged (for example, closing the poll) is not checked either. A partial update is checked on the texts that will actually be stored.
Each review queue item is filed under the user who made the write: the poll's creator for a create, the user who made an update, the suggesting user for a suggested option, and the voter for an answer. One user has one open item per poll. A later write by that same user updates the item and replaces its payload, including poll_operation. A different user gets a separate item.
Set up a polls policy
- Navigate to the Stream Dashboard.
- Go to Moderation → Policies and create a policy with the Polls type.
- Leave the name as
defaultto createpolls:default, which applies to every poll write in the app. A named policy (polls:<name>) applies only where your server passes its key (see Use a different policy from your server). - Add the engines you want: AI text, LLM text, blocklists, regex filters and allowlists. A polls policy does not include image, video, audio or flooding.
Without a polls policy, poll writes are not moderated.
Actions
A polls policy maps each rule to one of two actions:
| Action | What happens to the write |
|---|---|
flag |
The write is stored and visible, and a review queue item is created for a moderator to review. |
remove |
The write is rejected and nothing is stored. A review queue item is still created. |
shadow, mask and mask_flag are treated as flag: the write is stored and a review item is opened. bounce, bounce_flag and bounce_remove reject the write the same way remove does, but a bounce does not open a review item. Any other action leaves the write stored.
Only that remove verdict rejects the write. If the check cannot be completed, the write is stored.
Handling a removed write
A removed write fails with HTTP status 400 and error code 73, so your app can tell the user their poll, option or answer was not accepted:
{
"code": 73,
"message": "poll moderation failed: content blocked",
"StatusCode": 400
}For server-side requests, the error's details also names what matched: action, text_harms (AI and LLM labels) and blocklists_matched. Client-side requests never receive those details.
Users with bypass_moderation set are never checked. When a polls policy applies, each text is limited to 10,000 characters. A longer text is rejected with an input error and is not checked.
Use a different policy from your server
Server-side requests can pass config_key (and config_team) on any poll write to use a named policy instead of polls:default, for example polls:kids-mode. A config_key that does not exist is rejected. The write is not checked against polls:default instead. Client-side requests cannot choose a policy: the field is ignored, so users cannot opt themselves out of polls:default.
Review poll content
Poll items appear in the review queue like any other content. The dashboard shows the live poll, marks the flagged question, description, option or answer, and shows a removed write on its own, because that text was never stored. A poll attached to a chat message is shown with that message. A poll on a feed, or one that was never posted, is shown by itself.
Each poll moderation check records what the write was and, for option writes, which option, under moderation_payload.custom:
| Field | Value |
|---|---|
poll_id |
The poll's ID. |
poll_operation |
create_poll, update_poll, add_option, update_option or answer. |
option_id |
The option an add_option or update_option write targeted. |
moderation_payload.text_ordered_keys labels each entry of moderation_payload.texts as name, description, option or answer.
Actions on poll items
| Action | Use on | What it does |
|---|---|---|
delete_poll |
stream:v1:poll |
Deletes the whole poll, with every option and vote. Only the item filed by the poll's creator can do this, including after that creator's later edit replaces the item's payload. An item filed by someone else cannot be used to delete the poll. |
delete_poll_option |
stream:v1:poll |
Deletes one option and keeps the rest of the poll. Pass option_id when the item covers the whole poll. An item for a single option already names it. |
delete_poll_answer |
stream:v1:poll_vote |
Deletes a free-text answer and keeps the poll and other votes. Supported for polls on chat messages. |
All three deletes are permanent: poll content cannot be restored. Moderators can also mark poll items reviewed, ban the user who wrote the content, or escalate the item.
delete_poll_option and delete_poll_answer refuse when the stored content no longer holds the text that was checked. A removed edit leaves the original option or answer in place, and that original is not deleted. An answer also cannot be deleted once its poll is closed, or when the poll is not on a chat message.
See Actions for how to submit them.