Perspective was built for news comments, but plenty of game servers ended up using it for chat. It stops working after December 31, 2026, so if your Roblox experience or Minecraft server sends chat to commentanalyzer.googleapis.com, that check goes dark on January 1.
Disclosure: we build Paxmod, a chat moderation API for games, and both examples call it.
Paxmod uses its own format, so this is a code change, not a URL swap. The request is the same on every platform:
POST https://www.paxmod.com/api/v1/text
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
{ "message": "where do you live", "user_id": "12345", "context_id": "global" }
Use the www host, because the apex domain redirects. On success you get status: "success", result as "flagged" or "not_flagged", a human-readable reason, and per-category scores under content_moderation.categories. Check status before trusting result. Messages are capped at 5,000 characters, which chat never gets near.
Why game chat is different
Perspective scores each message on its own and knows nothing about who sent it. Two things in game chat need more than that:
- Personal information. "where do you live" isn't toxic, so Perspective scored it as fine. In a game full of kids, it's the message you most want to catch. Paxmod has a Personally Identifiable Information category, and the Gaming chat preset turns it on.
-
Who and where. Send a stable
user_id(the player's UserId or UUID) and acontext_id(server, world, channel or party) with every message. The Studio plan adds conversation context, which checks a message against that player's recent history instead of on its own.
Roblox (Luau, server-side)
You don't need your own proxy for this. Roblox server scripts can call outside HTTPS APIs through HttpService, and the experience's Secrets store keeps the API key out of your code.
Setup:
- In Studio, go to File, Experience Settings, Security and turn on Allow HTTP Requests.
- In the Creator Dashboard, open your experience, go to Secrets, and create
PAXMOD_API_KEYwith the domainwww.paxmod.com. Secrets can only go in the URL or headers, never the request body, which is why the key goes in theAuthorizationheader. - Secrets aren't available in local playtests or client scripts. To test in Studio, add the same key under Local Secrets in Experience Settings.
ServerScriptService/PaxmodClient (ModuleScript):
--!strict
local HttpService = game:GetService("HttpService")
local PaxmodClient = {}
local API_URL = "https://www.paxmod.com/api/v1/text"
export type Verdict = { flagged: boolean, reason: string? }
-- Returns a Verdict, or nil if the check couldn't run (you decide what nil means).
function PaxmodClient.moderate(text: string, userId: number, contextId: string): Verdict?
if text == "" then
return { flagged = false }
end
local ok, response = pcall(function()
return HttpService:RequestAsync({
Url = API_URL,
Method = "POST",
Headers = {
["Content-Type"] = "application/json",
-- The key never appears in your script or logs.
["Authorization"] = HttpService:GetSecret("PAXMOD_API_KEY"):AddPrefix("Bearer "),
},
Body = HttpService:JSONEncode({
message = text,
user_id = tostring(userId),
context_id = contextId,
}),
})
end)
if not ok or not response.Success then
warn("[Paxmod] check failed:", if ok then response.StatusCode else response)
return nil
end
local decoded = HttpService:JSONDecode(response.Body)
if decoded.status ~= "success" then
return nil
end
return { flagged = decoded.result == "flagged", reason = decoded.reason }
end
return PaxmodClient
Text players type into your own UI
Pet names, signs, bios and the like are the easy case. The client sends the raw string to the server, and the server checks it before anything is saved. A RemoteFunction handler is allowed to yield, so it can wait for the HTTP call:
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local PaxmodClient = require(script.Parent.PaxmodClient)
local setPetName = ReplicatedStorage:WaitForChild("SetPetName") -- a RemoteFunction
setPetName.OnServerInvoke = function(player: Player, name: any)
if typeof(name) ~= "string" or #name == 0 or #name > 30 then
return false
end
local verdict = PaxmodClient.moderate(name, player.UserId, "pet-names")
if verdict == nil or verdict.flagged then
return false -- nothing gets saved, so failing closed is cheap here
end
-- Save it, and still run Roblox's TextService filtering before showing it to anyone else.
return true
end
Chat
For chat, the server-side hook is TextChannel.ShouldDeliverCallback. (OnIncomingMessage only runs on clients.) Roblox's docs say chat callbacks must not yield, so this checks in the background and lets the message through either way (a server Script):
local TextChatService = game:GetService("TextChatService")
local PaxmodClient = require(script.Parent.PaxmodClient)
local general = TextChatService:WaitForChild("TextChannels"):WaitForChild("RBXGeneral")
local seen: { [string]: boolean } = {}
general.ShouldDeliverCallback = function(message: TextChatMessage, _recipient: TextSource)
-- This runs once per recipient, so only check each message once.
local id = message.MessageId
if not seen[id] then
seen[id] = true
local source = message.TextSource
if source then
-- Don't yield inside the callback. Check in the background.
task.spawn(function()
local verdict = PaxmodClient.moderate(message.Text, source.UserId, "RBXGeneral")
if verdict and verdict.flagged then
source.CanSend = false -- mute in this channel
task.delay(60, function()
if source.Parent then
source.CanSend = true
end
end)
-- Log verdict.reason for your moderators here.
end
task.delay(30, function()
seen[id] = nil
end)
end)
end
end
return true
end
Notes:
- This acts after the message, not before it. Roblox's own text filter still decides what other players see, and you must keep it. This adds a layer on top that mutes the sender and gives you a log.
-
Check what the server actually sees. Roblox documents
TextChatMessage.Textas the filtered text, and the docs don't spell out whether the server gets it before or after Roblox's filter in this callback. Log a few messages in a live server before you rely on it. If Roblox has already hashed out personal info by the time you see it, this hook won't catch it, so lean on the RemoteFunction pattern for custom text and treat chat checks as a second line. - Rate limits. Each game server can make 500 external HTTP requests per minute. A busy server with chatty players can hit that, and at that point it's worth routing chat through your own backend and batching from there.
Minecraft (Paper, Java 17+)
When a player types a message, Paper fires AsyncChatEvent off the main thread, so a short blocking HTTP call only delays that one message, not the server. If a plugin makes a player chat, the event fires synchronously instead, which is why the listener checks isAsynchronous() first. Gson and Adventure's plain-text serializer both come with the Paper API, so there's nothing extra to shade in.
package com.example.chatguard;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import io.papermc.paper.event.player.AsyncChatEvent;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.logging.Logger;
public final class PaxmodChatListener implements Listener {
private static final URI API = URI.create("https://www.paxmod.com/api/v1/text");
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(2))
.build();
private final String apiKey;
private final Logger log;
public PaxmodChatListener(String apiKey, Logger log) {
this.apiKey = apiKey;
this.log = log;
}
@EventHandler(priority = EventPriority.LOW, ignoreCancelled = true)
public void onChat(AsyncChatEvent event) {
// If another plugin fires chat on the main thread, don't block the server.
if (!event.isAsynchronous()) return;
// originalMessage() is what the player typed, before other plugins changed it.
String text = PlainTextComponentSerializer.plainText().serialize(event.originalMessage());
if (text.isBlank()) return;
JsonObject body = new JsonObject();
body.addProperty("message", text);
body.addProperty("user_id", event.getPlayer().getUniqueId().toString());
body.addProperty("context_id", event.getPlayer().getWorld().getName());
HttpRequest request = HttpRequest.newBuilder(API)
.timeout(Duration.ofSeconds(3))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body.toString()))
.build();
try {
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject();
if (response.statusCode() != 200 || !"success".equals(json.get("status").getAsString())) {
log.warning("Paxmod check failed with HTTP " + response.statusCode() + ", allowing message");
return; // fail open, but loudly
}
if ("flagged".equals(json.get("result").getAsString())) {
event.setCancelled(true);
event.getPlayer().sendMessage(
Component.text("Your message was blocked by chat moderation.", NamedTextColor.RED));
log.info(event.getPlayer().getName() + " blocked: " + json.get("reason").getAsString());
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Exception e) {
log.warning("Paxmod check failed (" + e.getMessage() + "), allowing message");
}
}
}
Register it in onEnable, reading the key from your config instead of hard-coding it:
String key = getConfig().getString("paxmod-api-key");
getServer().getPluginManager().registerEvents(new PaxmodChatListener(key, getLogger()), this);
If you use a chat plugin like ChatProtect or AutoMod that was set up with Perspective, check its config for a Perspective key. Those plugins call Perspective themselves, so you'll need to turn that part off and use a listener like this one, or check whether the plugin supports another provider. There's a Minecraft-specific write-up on paxmod.com.
Fail open or fail closed?
Both examples let messages through when the check fails, because blocking all chat during an outage is its own disaster. Just don't let it be silent: log every failure and alert when the failure rate climbs. Perspective's shutdown is a good reminder of what happens when a check that fails open goes quiet and nobody notices.
Before you switch
- Run both APIs on the same chat while Perspective still works, and compare decisions before you cut over.
- Set thresholds per category in the Paxmod console. Perspective thresholds don't carry over.
- Delete the Perspective key once you're done.
Paxmod plans start at $29/month for 50,000 messages (Studio is $149/month for 500,000), with a 14-day trial that includes 10,000 messages. A card is needed for the trial and there's no free plan. There's also a Roblox integration page with a ready-made server module.
For the full attribute mapping and before and after code in curl, Node and Python, see the guide: Perspective API alternative: migrate before Dec 31.
Top comments (0)