Even without the model, the rules catch several times what any npm word list catches, and Scunthorpe gets through.
bad-words, obscenity, leo-profanity and @2toad/profanity are open-source npm packages that match a word list, some with basic leetspeak handling, in your own process with no network call.
The private test set, 9,029 texts labelled blind, read once on 2026-09-28, engine 2026-09-29. Innocent words: the Scunthorpe Test, run of 2026-10-12. How it was measured
The measured rows are the private test set, read once; the rest is what each returns.
| What | Unprofane | bad-words and obscenity |
|---|---|---|
| Toxic text caught | 89.0% | 5.1% to 9.7% |
| Toxic text caught, rules alone | 53.4% | 5.1% to 9.7% |
| Disguised spellings caught | 86.0% | 2.5% to 7.2% |
| False positives on innocent words | 0.0% | 8.7% to 21.2% |
| Languages | 17 | English |
| Reads usernames: split into words, read backwards | Yes | No |
| Names how each word was disguised | Yes | No |
| Abuse in ordinary words | Yes | No |
The private test set, 9,029 texts labelled blind, read once on 2026-09-28, engine 2026-09-29. Innocent words: the Scunthorpe Test, run of 2026-10-12. 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
Innocent words that contain a profane string are part of the test, and a filter that blocks one is counted wrong.
0.0%
false positives on 1,447 innocent words and phrases, the Scunthorpe Test of 2026-10-12
02
Not just leetspeak: reversal, look-alike letters, separators and zero-width characters, each named in the answer.
username
kcuffuck
reversedreview · strong
Shown as ****
03
A curated list for each, and a model for abuse written in ordinary words, which no list holds.
17languages
When a wrong answer reaches a person, a word list is not enough.
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.