More abuse caught at the same false-alarm rate, profanity as a category of its own, and every disguise named.
The OpenAI moderation endpoint returns category scores (hate, harassment, self-harm, sexual, violence and their subtypes) for text and images, free to API customers.
48.2 points ahead
The private test set, 9,029 texts labelled blind, read once on 2026-09-28, engine 2026-09-29. How it was measured
The measured rows are the private test set, read once; the rest is what each returns.
| What | Unprofane | OpenAI moderation |
|---|---|---|
| Toxic text caught, same false-alarm rate | 89.0% | 40.8% |
| Clean text flagged, each as shipped | 7.0% | 20.6% |
| Toxic usernames caught, each as shipped | 76.5% | 18.8% |
| Disguised spellings caught, each as shipped | 86.0% | 45.5% |
| Profanity as a category | Yes | No |
| Names how each word was disguised | Yes | No |
| A censored version, ready to show | Yes | No |
| Reserved names: admin, support, your brand | Yes | No |
| Keeps every text at the edge, on request | Yes | No |
The private test set, 9,029 texts labelled blind, read once on 2026-09-28, engine 2026-09-29. Every system, every row.
The real engine, live. Type a disguised word and watch it read, named and judged; type an innocent one and watch it pass.
Every answer carries the same evidence through the API: the words, where they were, how they were disguised and a censored version.
01
Profanity is a category of its own, and every disguise is undone and named: leetspeak, look-alike letters, separators, reversal.
username
xXsh1tXxshit
leetspeakreview ยท strong
Shown as xX****Xx
02
The rules answer inside the Worker, and an Edge-only project never sends a text to any model.
5ms
at p99 inside the Worker, for a check the rules answer
Our own measurement in workerd, 300 requests, 2026-09-28
03
A name that poses as your platform is blocked as reserved, not as rude, so the sign-up form can say what is wrong.
username
official_support
official: reservedsupport: reservedblock
The form can say the name is taken, not that it is rude.
For usernames, chat and comments, where the question is whether a text can be shown, Unprofane catches more and says why.
A test key is free, with a plan or without one. One request, in the language you use, and the answer carries the evidence.
curl -X POST https://api.unprofane.com/v1/check \
-H "Authorization: Bearer up_test_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"text":"xXsh1tXx","context":"username","policy":"teen"}'const res = await fetch('https://api.unprofane.com/v1/check', {
method: 'POST',
headers: { Authorization: 'Bearer up_test_YOUR_KEY', 'Content-Type': 'application/json' },
body: JSON.stringify({
"text": "xXsh1tXx",
"context": "username",
"policy": "teen"
}),
});
const result = await res.json();import httpx
r = httpx.request(
"POST",
"https://api.unprofane.com/v1/check",
headers={"Authorization": "Bearer up_test_YOUR_KEY"},
json={
"text": "xXsh1tXx",
"context": "username",
"policy": "teen",
},
timeout=5.0,
)
result = r.json()req, _ := http.NewRequest("POST", "https://api.unprofane.com/v1/check",
strings.NewReader(`{"text":"xXsh1tXx","context":"username","policy":"teen"}`))
req.Header.Set("Authorization", "Bearer up_test_YOUR_KEY")
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)$res = file_get_contents('https://api.unprofane.com/v1/check', false, stream_context_create(['http' => [
'method' => 'POST',
'header' => "Authorization: Bearer up_test_YOUR_KEY\r\nContent-Type: application/json\r\n",
'content' => '{"text":"xXsh1tXx","context":"username","policy":"teen"}',
]]));
$result = json_decode($res, true);require 'net/http'; require 'json'
uri = URI('https://api.unprofane.com/v1/check')
req = Net::HTTP::Post.new(uri, 'Authorization' => 'Bearer up_test_YOUR_KEY', 'Content-Type' => 'application/json')
req.body = '{"text":"xXsh1tXx","context":"username","policy":"teen"}'
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
result = JSON.parse(res.body)using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "up_test_YOUR_KEY");
var req = new HttpRequestMessage(HttpMethod.Post, "https://api.unprofane.com/v1/check");
req.Content = new StringContent("{\"text\":\"xXsh1tXx\",\"context\":\"username\",\"policy\":\"teen\"}", Encoding.UTF8, "application/json");
var res = await http.SendAsync(req);
var result = await res.Content.ReadFromJsonAsync<JsonElement>();var http := HTTPRequest.new()
add_child(http)
http.request_completed.connect(func(_r, _code, _h, body):
var result = JSON.parse_string(body.get_string_from_utf8())
print(result))
http.request("https://api.unprofane.com/v1/check",
PackedStringArray(["Authorization: Bearer up_test_YOUR_KEY", "Content-Type: application/json"]),
HTTPClient.METHOD_POST, "{\"text\":\"xXsh1tXx\",\"context\":\"username\",\"policy\":\"teen\"}")Free test keys, 100 checks a day, no card. Live from $9 a month. Every answer shows its evidence.