Unprofane

The Azure Content Moderator replacement with term positions

A built-in profanity list in 17 languages, and every term with its position. Content Safety, Microsoft’s replacement, has neither.

Live: edit the text
screen.TermsContent Moderator’s shape, rebuilt /v1/check 200 · answered by the rules · 3 ms { { "action": "block","Terms": [ "matches": [{ {"Index": 14, "start": 14,"OriginalIndex": 14, "end": 21,"ListId": 0, "term_id": "fr.connard","Term": "c0nnard" "text": "c0nnard", "normalized": "connard", "category": "insult", "severity": "strong", "evasion": ["leetspeak"]} }] ], "censored": "Ce mec est un *******"} }

toTerms() rebuilt Terms from this answer; your loop over them stays. The mapping, in your language

What breaks when Content Moderator retires

Microsoft’s replacement, Content Safety, has no built-in term list, and its blocklist matches carry no position.

What a caller readsContent Moderator ProcessText/ScreenContent Safety text:analyze
Where the word isIndexintegerOriginalIndexintegerNo field for a position
Which termListIdintegerTermstringblocklistNamestringblocklistItemIdstringblocklistItemTextstring
A built-in profanity list112 language rowsNonedoesn’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

Change the call, keep the Terms loop

Send the text to /v1/check, and map the answer to the Terms your code already reads.

What changes

Program.cs
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}");
UnprofaneTerms.cs new file
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.

screen.py
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)
unprofane_terms.py new file
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 None

12 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.

screen.mjs
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);
unprofane-terms.js new file
// 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.

Any HTTP client
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
unprofane-terms.jq new file
# 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.

Your term lists move once

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.json
5,000
terms on a project’s block list, 64 characters each, read through every disguise
50
more in a request, for a list you chose per call

Content Moderator allowed five lists of 10,000. Lists, in the docs

What does not carry over

  • Personal data detectionPII=trueNot offered. Microsoft moved it to Azure AI Language, a service of its own.
  • Image, video and review APIsNot offered. Unprofane reads text.
  • Autocorrectautocorrect=trueNot offered. The text is never rewritten; a misspelled word it reads is tagged misspelled.

Before you move

When does Azure Content Moderator retire?
On March 15, 2027, by Microsoft's retirement notice and its Content Moderator overview. Two of Microsoft's other pages disagree: the documentation's landing page says by February 2027 and its lifecycle list for 2027 says March 31, 2027, so plan for the earliest your contract can live with. No new Content Moderator resource could be created after March 15, 2024.
Does Azure AI Content Safety replace the profanity screen?
Not its term list. Microsoft's own FAQ says Content Safety doesn't have a built-in term list: it scores hate, sexual content, violence and self-harm by severity, matches only the blocklists you fill yourself, and a blocklist match names the list and the item, not where it was in the text. Unprofane reads text against a built-in list in 17 languages and answers each match with its position, its disguise, its category and its severity. Unprofane vs Azure AI Content Safety has the rest.
How much of my Content Moderator code changes?
The call, and nothing that reads its answer. Send the text to /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.
Are the positions the same as Content Moderator's?
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.
Does it read Content Moderator's languages?
17 of them, each with its own list: Content Moderator's term list had 112 language rows, and every language Unprofane reads is among them. A text in another language is still read by all 17 lists, with no list of its own. The guide maps Content Moderator's ISO 639-3 codes to the ISO 639-1 codes /v1/check takes (language codes), and languages has how well each one reads.
What happens to my custom term lists?
They move to your project's block list: up to 5,000 terms of up to 64 characters, read through the same disguises as the text. Export each one while Content Moderator still answers; a list you chose per call goes in the request instead, as 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.
What does it cost after Content Moderator?
Test keys are free, need no card and answer 100 checks a day across the account, enough to build the move and check it on staging. A live key needs a plan, from $9 a month, and Growth or Scale starts with a free trial. The pricing page has every plan and what an extra check costs.