Profanity filtering users can't bypass.
A built-in profanity list in 17 languages, and every term with its position. Content Safety, Microsoft’s replacement, has neither.
Microsoft’s replacement, Content Safety, has no built-in term list, and its blocklist matches carry no position.
IndexintegerOriginalIndexintegerNo field for a positionListIdintegerTermstringblocklistNamestringblocklistItemIdstringblocklistItemTextstringdoesn’t have a built-in term list
Microsoft’s pages, read October 20, 2026: Content Moderator’s text moderation, its language support, the Content Safety FAQ and Analyze Text. Unprofane vs Azure AI Content Safety
Send the text to /v1/check, and map the answer to the Terms your code already reads.
Remove: using Microsoft.Azure.CognitiveServices.ContentModerator;using Microsoft.Azure.CognitiveServices.ContentModerator.Models;Remove: using System.Text;Add: using System.Net.Http.Json;Add: using System.Text.Json; Remove: var key = Environment.GetEnvironmentVariable("CONTENT_MODERATOR_KEY");Add: var key = Environment.GetEnvironmentVariable("UNPROFANE_KEY");var text = "Ce mec est un c0nnard"; Remove: var client = new ContentModeratorClient(new ApiKeyServiceClientCredentials(key))Remove: {Remove: Endpoint = Environment.GetEnvironmentVariable("CONTENT_MODERATOR_ENDPOINT"),Remove: };Remove: using var body = new MemoryStream(Encoding.UTF8.GetBytes(text));Remove: Screen screen = await client.TextModeration.ScreenTextAsync("text/plain", body, "eng");Add: var http = new HttpClient { DefaultRequestHeaders = { Authorization = new("Bearer", key) } };Add: using var res = await http.PostAsJsonAsync("https://api.unprofane.com/v1/check", new { text, mode = "fast" });Add: res.EnsureSuccessStatusCode();Add: var check = await res.Content.ReadFromJsonAsync<JsonElement>();Add: Screen screen = new() { OriginalText = text, Terms = UnprofaneTerms.ToTerms(check) }; foreach (DetectedTerms term in screen.Terms ?? new List<DetectedTerms>())Console.WriteLine($"{term.Term} at {term.OriginalIndex}");using System.Text.Json;using Microsoft.Azure.CognitiveServices.ContentModerator.Models; // Content Moderator's Terms from an Unprofane answer: null when// nothing matched, as Screen gave. DetectedTerms is the SDK's own// model, so the code that reads screen.Terms stays as it is.public static class UnprofaneTerms{public static IList<DetectedTerms>? ToTerms(JsonElement check, int listId = 0){var terms = new List<DetectedTerms>();foreach (var m in check.GetProperty("matches").EnumerateArray()){var start = m.GetProperty("start").GetInt32();var text = m.GetProperty("text").GetString();var id = m.GetProperty("term_id").GetString() ?? "";var list = id.StartsWith("list.") ? listId : 0;terms.Add(new DetectedTerms(start, start, list, text));}return terms.Count > 0 ? terms : null;}}8 new lines in the call, and a 22-line file that maps the answer to Terms. The loop over Terms stays as it is.
Written against the source of Microsoft.Azure.CognitiveServices.ContentModerator 2.0.0: its ScreenTextAsync, Screen and DetectedTerms.
import osRemove: import ioRemove: from azure.cognitiveservices.vision.contentmoderator import ContentModeratorClientRemove: from msrest.authentication import CognitiveServicesCredentialsAdd: import jsonAdd: import urllib.requestAdd: from types import SimpleNamespaceAdd: from unprofane_terms import to_terms Remove: KEY = os.environ["CONTENT_MODERATOR_KEY"]Add: KEY = os.environ["UNPROFANE_KEY"]text = "Ce mec est un c0nnard" Remove: client = ContentModeratorClient(Remove: endpoint=os.environ["CONTENT_MODERATOR_ENDPOINT"],Remove: credentials=CognitiveServicesCredentials(KEY),Remove: )Remove: screen = client.text_moderation.screen_text(Remove: text_content_type="text/plain",Remove: text_content=io.BytesIO(text.encode("utf-8")),Remove: language="eng",Remove: )Add: request = urllib.request.Request(Add: "https://api.unprofane.com/v1/check",Add: data=json.dumps({"text": text, "mode": "fast"}).encode("utf-8"),Add: headers={"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"},Add: )Add: with urllib.request.urlopen(request, timeout=5) as res: # raises on any status but 2xxAdd: screen = SimpleNamespace(original_text=text, terms=to_terms(json.load(res), text)) for term in screen.terms or []:print(term.term, term.original_index)from types import SimpleNamespace def to_terms(check, text, list_id=0):"""Content Moderator's terms from an Unprofane answer: Nonewhen nothing matched, as Screen gave. Unprofane counts offsetsin UTF-16 code units and Python in code points, so each startbecomes an index into text."""utf16 = text.encode("utf-16-le")terms = []for m in check["matches"]:at = len(utf16[: m["start"] * 2].decode("utf-16-le"))listed = m["term_id"].startswith("list.")terms.append(SimpleNamespace(index=at,original_index=at,list_id=list_id if listed else 0,term=m["text"],))return terms or None12 new lines in the call, and a 22-line file that maps the answer to Terms. The loop over Terms stays as it is.
Ran against a local Unprofane API on October 20, 2026, with the standard library only. to_terms runs on the engine’s own answers in every test run.
Remove: import { ContentModeratorClient } from '@azure/cognitiveservices-contentmoderator';Remove: import { CognitiveServicesCredentials } from '@azure/ms-rest-azure-js';Add: import { toTerms } from './unprofane-terms.js'; Remove: const KEY = process.env.CONTENT_MODERATOR_KEY;Add: const KEY = process.env.UNPROFANE_KEY;const text = 'Ce mec est un c0nnard'; Remove: const client = new ContentModeratorClient(Remove: new CognitiveServicesCredentials(KEY),Remove: process.env.CONTENT_MODERATOR_ENDPOINT,Remove: );Remove: const screen = await client.textModeration.screenText('text/plain', text, { language: 'eng' });Add: const res = await fetch('https://api.unprofane.com/v1/check', {Add: method: 'POST',Add: headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },Add: body: JSON.stringify({ text, mode: 'fast' }),Add: });Add: if (!res.ok) throw new Error(`Unprofane answered ${res.status}`);Add: const screen = { originalText: text, terms: toTerms(await res.json()) }; for (const term of screen.terms ?? []) console.log(term.term, term.originalIndex);// Content Moderator's terms from an Unprofane answer, in the Node// SDK's own fields: null when nothing matched, as Screen gave.export function toTerms(check, listId = 0) {const terms = check.matches.map((m) => ({index: m.start,originalIndex: m.start,listId: m.term_id.startsWith('list.') ? listId : 0,term: m.text,}));return terms.length > 0 ? terms : null;}9 new lines in the call, and a 11-line file that maps the answer to Terms. The loop over Terms stays as it is.
Ran against a local Unprofane API on October 20, 2026. toTerms runs on the engine’s own answers in every test run.
Remove: curl -s -X POST "$CONTENT_MODERATOR_ENDPOINT/contentmoderator/moderate/v1.0/ProcessText/Screen?language=eng" \Remove: -H "Ocp-Apim-Subscription-Key: $CONTENT_MODERATOR_KEY" \Remove: -H "Content-Type: text/plain" \Remove: --data-binary "Ce mec est un c0nnard"Add: curl -s -X POST "https://api.unprofane.com/v1/check" \Add: -H "Authorization: Bearer $UNPROFANE_KEY" \Add: -H "Content-Type: application/json" \Add: -d '{"text":"Ce mec est un c0nnard","mode":"fast"}' \Add: | jq --argjson listId 0 -f unprofane-terms.jq# Content Moderator's Terms from an Unprofane answer: null when nothing# matched, as Screen gave.{Terms: ([.matches[] | {Index: .start,OriginalIndex: .start,ListId: (if (.term_id | startswith("list.")) then $listId else 0 end),Term: .text}]| if length == 0 then null else . end)}5 new lines in the call, and a 13-line file that maps the answer to Terms. The loop over Terms stays as it is.
Ran against a local Unprofane API on October 20, 2026. The jq filter runs on the engine’s own answers in every test run.
Export each list while Content Moderator still answers, and put it on your project’s block list.
# 1. Export a Content Moderator list (here list 123, its English terms).
curl -s "$CONTENT_MODERATOR_ENDPOINT/contentmoderator/lists/v1.0/termlists/123/terms?language=eng&offset=0&limit=5000" \
-H "Ocp-Apim-Subscription-Key: $CONTENT_MODERATOR_KEY" \
| jq '{terms: [.Data.Terms[].Term]}' > block.json
# 2. Put the terms on your project's block list.
curl -X PUT "https://api.unprofane.com/v1/projects/prj_YOUR_PROJECT_ID/lists/block" \
-H "Authorization: Bearer up_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d @block.jsonContent Moderator allowed five lists of 10,000. Lists, in the docs
PII=trueNot offered. Microsoft moved it to Azure AI Language, a service of its own.autocorrect=trueNot offered. The text is never rewritten; a misspelled word it reads is tagged misspelled./v1/check instead of ProcessText/Screen, and pass the answer through a short mapping function that returns Content Moderator's Terms (Index, OriginalIndex, ListId, Term), so the code that reads screen.Terms stays as it is. The migration guide has the call and the function for C#, Python, Node.js and any HTTP client, field by field.OriginalIndex is where the word starts in the text you sent: in a captured 2020 answer to a text opening with "This workshop is a crap.", Content Moderator put crap at 19, and so does the mapping. Offsets count in UTF-16 code units, as C# and JavaScript index a string; the guide's Python mapping turns them into code points. Content Moderator's Index pointed into its NormalizedText, a copy with stop words cut out, and is now the same as OriginalIndex./v1/check takes (language codes), and languages has how well each one reads.lists.block, up to 50 terms. Content Moderator allowed 5 lists of 10,000 terms, so more than 5,000 does not fit one list. The migration guide has the export, and lists the rest.