Unprofane
DocsMigration guide

Azure Content Moderator migration guide

Move a Content Moderator text screen to /v1/check, field by field. The request, the Terms your code reads, the language codes, your term lists, a staging check and a rollback.

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.

Shell
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.jq

The 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:

Shell
# 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.Returned is less than Paging.Total, read the next page with offset.
  • The PUT replaces 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)

C#
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:

C#
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

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:

Python
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 None

The 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

JavaScript
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:

JavaScript
// 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

Shell
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.jq

unprofane-terms.jq, which prints Terms as the wire carried them, in PascalCase:

Text
# 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, as language.detected on a check.
  • The term list API: lists are managed with /v1/projects/{id}/lists or 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.