Microsoft retires Azure Content Moderator on March 15, 2027 (the retirement notice). Two of its other pages give other dates: its documentation says by February 2027, and its lifecycle list says March 31, 2027. Plan for the earliest one your contract can live with. Nobody has been able to create a new Content Moderator resource since March 2024.
Microsoft’s replacement, Azure AI Content Safety, “doesn’t have a built-in term list” (its FAQ), and its blocklist matches carry a list name, an item id and the item’s text, with no position (Analyze Text, 2024-09-01). POST /v1/check reads text against a built-in list in 17 languages and answers every match with its position. This page moves a ProcessText/Screen call to it, field by field. Why to move, with the engine at work, is on the Azure Content Moderator page.
curl -s -X POST "https://api.unprofane.com/v1/check" \
-H "Authorization: Bearer $UNPROFANE_KEY" \
-H "Content-Type: application/json" \
-d '{"text":"Ce mec est un c0nnard","mode":"fast"}' \
| jq --argjson listId 0 -f unprofane-terms.jqThe answer’s matches carry everything Terms did. A short mapping function, below in C#, Python, JavaScript and jq, turns them into Terms, so the code that reads screen.Terms stays as it is.
The request, field by field#
| Content Moderator | Unprofane | Notes |
|---|---|---|
ProcessText/Screen |
/v1/check |
Both a POST; the host is api.unprofane.com. |
Ocp-Apim-Subscription-Key |
Authorization |
A bearer key: a test key (up_test_…) on staging, a live key (up_live_…) in production. |
The raw text, text/plain |
JSON, in text |
text/html, text/xml and text/markdown have no counterpart: send the text without its markup, and the offsets count in what you sent. |
| Up to 1,024 characters | Up to 10,000 characters | |
language, ISO 639-3 |
language, ISO 639-1 |
Empty there, auto here (the default), to detect. The codes are in the table below. Every covered language is read whichever you name. |
listId |
The block list | Your project’s list, and lists.block per request. See term lists. |
classify |
categories |
With score and action. Not a calibration of Category1 to Category3. categories.sexual is the nearest to Category1; Category2 has no counterpart; the nearest to Category3 is the highest of profanity, insult, slur, threat and rude. ReviewRecommended is nearest to action not being allow. |
autocorrect |
Not offered | The text is never rewritten. A misspelled word it reads shows in evasion as misspelled. |
PII |
Not offered | Microsoft moved it to Azure AI Language, a separate service. |
mode |
Send fast for the rules alone, as Screen’s term list was: a check never leaves Cloudflare and answers in milliseconds. Leave it out (balanced) to have the model also read every text the rules do not block outright. matches come from the rules either way. |
|
context, policy and mask |
What the text is, how strict to be, and how censored masks a match. See contexts and policies. |
The response, field by field#
| Content Moderator | Unprofane | Notes |
|---|---|---|
Terms |
matches |
Terms was null when nothing matched; matches is []. The mapping gives back null. |
Terms[].OriginalIndex |
matches[].start |
The offset in the text as sent, in UTF-16 code units, as C# and JavaScript index a string. Python indexes by code point: its mapping converts. |
Terms[].Index |
matches[].start |
Content Moderator’s Index pointed into NormalizedText, a copy with stop words cut out that no client can rebuild. Read OriginalIndex. |
Terms[].Term |
matches[].text |
The text the match covers, as typed (c0nnard), so OriginalIndex plus its length is the span to mask. matches[].normalized is what it reads as (connard). |
Terms[].ListId |
matches[].term_id |
0 for the built-in list. A term from your project’s block list has a term_id that starts with list.; the mapping gives it the list id you pass. |
Language |
language.detected |
ISO 639-1, or und when it cannot tell. |
NormalizedText |
matches[].normalized |
Each match as read, not the whole text. |
Classification |
categories, score, action |
See classify above. |
Status |
The HTTP status | 200 for an answer; an error is a problem details object, below. |
TrackingId |
id (chk_…) |
Quote it in a feedback call to correct an answer. |
OriginalText, AutoCorrectedText, PII and Misrepresentation |
Not returned | You have the text you sent. |
action, severity, evasion, censored and reason |
What Screen never had: the decision under your policy, how strong the word is, the disguise each match was typed with, a masked copy ready to show, and one sentence on why. |
Language codes#
Content Moderator’s term list covered 112 language rows, and its language took an ISO 639-3 code. Unprofane reads 17 languages, each with its own list, and every one of them is among those rows. A text in another language is still read by all 17 lists, with no list of its own.
| Content Moderator | Unprofane | Language |
|---|---|---|
ara |
ar |
Arabic |
deu |
de |
German |
eng |
en |
English |
spa |
es |
Spanish |
fra |
fr |
French |
heb |
he |
Hebrew |
hin |
hi |
Hindi |
ind |
id |
Indonesian |
ita |
it |
Italian |
jpn |
ja |
Japanese |
kor |
ko |
Korean |
nld |
nl |
Dutch |
pol |
pl |
Polish |
por |
pt |
Portuguese |
rus |
ru |
Russian |
tur |
tr |
Turkish |
zho |
zh |
Chinese |
Microsoft’s pages name the languages and the standard but never list the codes; these are the standard’s. How well each language reads is on languages.
Move your term lists#
Content Moderator allowed five lists of 10,000 terms each, chosen per call with listId, and each term belonged to a language. A project here has one block list of up to 5,000 terms of up to 64 characters, read through the same disguises as the text, and a request can add up to 50 terms of its own in lists.block.
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- Run the export once per list and per language. If the answer’s
Paging.Returnedis less thanPaging.Total, read the next page withoffset. - The
PUTreplaces the list. To keep several lists, join their exports first:jq -s '{terms: (map(.terms[]) | unique)}' list-*.json > block.json. - Every term on the project’s list applies to every check. A list you chose for some calls only (one channel’s words, one game’s names) goes in the request instead, as
lists.block. - More than 5,000 terms does not fit one list; a term the lexicon already reads, disguises included, does not need to be on it.
Lists has the rest: the allow list, the dashboard and what a list cannot do.
Move, step by step#
1. Make a key for each environment
Sign in and make a test key (up_test_…) for staging and a live key (up_live_…) for production on the dashboard’s keys page. A test key is free and answers 100 checks a day across the account; a live key needs a plan. Keep your Content Moderator key: it is your way back until Microsoft retires the service.
2. Move your term lists
As above, into the project your keys belong to.
3. On staging, replace the call
Each client below keeps its loop over Terms and changes the call before it. Read the key from the environment, so staging and production differ only in the value.
C# (.NET)
using Microsoft.Azure.CognitiveServices.ContentModerator.Models;
using System.Net.Http.Json;
using System.Text.Json;
var key = Environment.GetEnvironmentVariable("UNPROFANE_KEY");
var text = "Ce mec est un c0nnard";
var http = new HttpClient { DefaultRequestHeaders = { Authorization = new("Bearer", key) } };
using var res = await http.PostAsJsonAsync("https://api.unprofane.com/v1/check", new { text, mode = "fast" });
res.EnsureSuccessStatusCode();
var check = await res.Content.ReadFromJsonAsync<JsonElement>();
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:
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;
}
}The SDK’s Screen and DetectedTerms keep the rest of your code compiling, so leave the package referenced until that code is rewritten; once nothing names them, a class of your own with the same four properties takes their place.
Python
import os
import json
import urllib.request
from types import SimpleNamespace
from unprofane_terms import to_terms
KEY = os.environ["UNPROFANE_KEY"]
text = "Ce mec est un c0nnard"
request = urllib.request.Request(
"https://api.unprofane.com/v1/check",
data=json.dumps({"text": text, "mode": "fast"}).encode("utf-8"),
headers={"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"},
)
with urllib.request.urlopen(request, timeout=5) as res: # raises on any status but 2xx
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:
from types import SimpleNamespace
def to_terms(check, text, list_id=0):
"""Content Moderator's terms from an Unprofane answer: None
when nothing matched, as Screen gave. Unprofane counts offsets
in UTF-16 code units and Python in code points, so each start
becomes 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 NoneThe objects carry the SDK’s attribute names (term.original_index, term.list_id), so code written against azure-cognitiveservices-vision-contentmoderator reads them unchanged. Remove the package once nothing imports it.
Node.js
import { toTerms } from './unprofane-terms.js';
const KEY = process.env.UNPROFANE_KEY;
const text = 'Ce mec est un c0nnard';
const res = await fetch('https://api.unprofane.com/v1/check', {
method: 'POST',
headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ text, mode: 'fast' }),
});
if (!res.ok) throw new Error(`Unprofane answered ${res.status}`);
const screen = { originalText: text, terms: toTerms(await res.json()) };
for (const term of screen.terms ?? []) console.log(term.term, term.originalIndex);unprofane-terms.js:
// 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;
}Any HTTP client
curl -s -X POST "https://api.unprofane.com/v1/check" \
-H "Authorization: Bearer $UNPROFANE_KEY" \
-H "Content-Type: application/json" \
-d '{"text":"Ce mec est un c0nnard","mode":"fast"}' \
| jq --argjson listId 0 -f unprofane-terms.jqunprofane-terms.jq, which prints Terms as the wire carried them, in PascalCase:
# 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
)
}Each of these stops with an error when the API refuses, as the SDKs threw on any answer but a 200, so a wrong key never reads as a clean text.
4. Check it on staging
While Content Moderator still answers, send a sample of your own texts to both and compare the Terms each returns. Read every text where they disagree before you switch: a word on one list and not the other, a disguised spelling, a place name. Then read the texts your moderators already decided, and choose a policy by what it does to them, not by the old service’s answers.
5. Then production
Ship the new call behind a flag with your live key, and keep the old call beside it. Watch your error logs for refused requests in the first days: a 401 is a key, a 402 a plan, and a 429 a burst over your plan’s limit, with Retry-After.
6. Roll back
Turn the flag off, so the old call and your Content Moderator key answer again. This works until Microsoft retires the service; after that, there is no Content Moderator to roll back to.
What does not carry over#
- Personal data detection (
PII=true): not offered. Microsoft moved it to Azure AI Language. - Image, video and the review tool: not offered. Unprofane reads text.
- Autocorrect (
autocorrect=true): not offered. The text is never rewritten. - Language detection (
ProcessText/DetectLanguage): only for the 17 languages, aslanguage.detectedon a check. - The term list API: lists are managed with
/v1/projects/{id}/listsor the dashboard, one block list a project, with no list ids, no per-language terms and no index to refresh: a change applies within a minute.
Errors#
Content Moderator’s SDKs threw an APIErrorException on any status but 200. Here an error is an RFC 9457 problem details object with type, title, status and detail, under the same status a client already handles: errors has each type and what to send instead.