Content Moderation API Node.js: A Practical Tutorial

Why Add a Content Moderation API to Node.js Apps
Node.js powers a large share of the products where user-generated text appears first: chat rooms, comment threads, forums, marketplace listings, community feeds, and support inboxes. In nearly all of those systems the critical moment is the same. A string arrives from a user, and your handler has to decide whether it can be saved, published, or broadcast to everyone else.
Adding a content moderation API to a Node.js service is mostly an HTTP integration problem. You send a text to an endpoint, you get JSON back, and you branch on it. There is no model to host, no word list to curate, and no scoring curve to calibrate.
CautionLabs keeps that surface small. The hosted API takes a text string and returns two things:
- A
categoriesobject with twelve boolean flags, one per harm category. - A single
flaggedboolean that istruewhen any category is flagged.
That is the whole decision surface. The API is binary by design: it returns no scores, no probabilities, and no confidence values, and there are no thresholds to tune. If you have used moderation systems that hand back a float and leave you to pick a cutoff, the difference is worth noticing. With CautionLabs your cutoff logic is simply if (result.flagged).
If you want the wider picture of the endpoint and the twelve flags before getting into Node.js specifics, the complete guide to adding a content moderation API covers the endpoint and flag model in depth. This article concentrates on wiring the check into a Node.js app.
Where the check belongs in your request lifecycle
Most Node.js teams end up choosing between three placements:
- Inline before publish. Moderate inside the route handler before the database insert or the socket broadcast. Simple, and the user gets immediate feedback.
- Save as pending, moderate, then publish. Persist the content in a pending state, moderate it, and flip it live afterwards. This gives you a buffer when something goes wrong downstream.
- Asynchronous job. Push the text onto a queue, moderate in a worker, and update the record afterwards. Handy for imports and backfills.
The API has no webhooks, so there is no callback to wait for. Your code calls the endpoint directly and reacts to the response in the same flow. That keeps the integration easy to reason about and means your existing queue or job runner is the right place to handle retries.
Prerequisites and Authentication
Before writing code, confirm three things:
- A Node.js runtime with a global
fetch(Node.js 18 and later) or an HTTP client such asaxiosorundici. - A Cloud API key, created in the CautionLabs dashboard.
- The base URL,
https://api.cautionlabs.com.
Authentication is a bearer token in the Authorization header. Keep the key in an environment variable rather than in source control.
CAUTIONLABS_CLOUD_KEY=your_cloud_api_key_hereNever send the key to the browser or bundle it into client-side code. Moderation calls belong on the server, where the key can stay private and where you control the decision that follows.
One key detail trips people up early: a private on-premises key does not work with the cloud API. The two deployment paths use different credentials. If you are weighing self-hosting, the Docker deployment guide for on-premises content moderation walks through that route, and the On-Premises Guide documents the local endpoint and setup.
Send Your First Node.js Text Moderation API Request
The cloud API exposes one endpoint for text checks: POST /moderation. The request body is a JSON object with a single required field, text, which accepts a string of at least 1 character and at most 10,000 characters.
const MODERATION_URL = 'https://api.cautionlabs.com/moderation';
const response = await fetch(MODERATION_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CAUTIONLABS_CLOUD_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
text: 'You are the worst and everyone here knows it.',
}),
});
const result = await response.json();
console.log(result.flagged); // true or false
console.log(result.categories); // twelve booleansA successful response looks like this:
{
"categories": {
"toxic": true,
"profanity": false,
"hate": false,
"harassment": true,
"self_harm": false,
"adult": false,
"violence": false,
"drugs": false,
"weapons": false,
"pii": false,
"spam": false,
"minor": false
},
"flagged": true
}Two properties of that payload are worth appreciating. Every field is a boolean, so nothing needs normalising and nothing needs comparing against a constant. And flagged is a roll-up rather than a separate judgement: it is true when any category is flagged.
There is no batch endpoint. Ten texts to check means ten requests. The same holds for a conversation with many messages: each message is its own request. Because there is no webhook either, there is no asynchronous result to reconcile later, because every result arrives in the response to a request you made.
Interpret the Boolean Flags
The categories object always contains the same twelve keys:
toxicprofanityhateharassmentself_harmadultviolencedrugsweaponspiispamminor
Each is either true or false. There are no severity levels, no per-category weights, and no numeric values attached to any of them.
In practice, teams use the flags in three ways:
- Gate on
flagged. The simplest policy: allow whenflaggedisfalse, reject when it istrue. One branch, no configuration. - Route on individual categories. A comment flagged only under
spammight go to a shadow queue, while one flagged underhateorharassmentmight go straight to human review. - Combine with your own product rules. A marketplace might treat
piidifferently from a public forum, because the cost of a leaked phone number differs from the cost of a rude reply.
Whatever you choose, log the categories you act on. Storing flagged alone makes it hard to explain later why a specific piece of content was blocked. The per-category overviews under Solutions are a good place to look if you want product context for a specific category before you settle policy.
Add an Express Content Moderation Middleware Pre-Publish Check
Now for the part most teams actually need: an Express app that moderates a comment before saving it. Start with a small helper that wraps the request and normalises errors.
const MODERATION_URL = 'https://api.cautionlabs.com/moderation';
async function moderateText(text) {
const response = await fetch(MODERATION_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CAUTIONLABS_CLOUD_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ text }),
});
const payload = await response.json();
if (!response.ok) {
const error = new Error(payload.message || 'Moderation request failed');
error.code = payload.code;
error.status = response.status;
throw error;
}
return payload;
}That helper is reusable anywhere: route handlers, socket event listeners, queue workers. Next, the route handler itself.
import express from 'express';
const app = express();
app.use(express.json());
const REVIEW_CATEGORIES = ['hate', 'harassment', 'self_harm', 'minor'];
function decide(result) {
if (!result.flagged) return 'allow';
const needsHuman = REVIEW_CATEGORIES.some(
(key) => result.categories[key] === true
);
return needsHuman ? 'review' : 'block';
}
app.post('/comments', async (req, res) => {
const { text } = req.body;
let result;
try {
result = await moderateText(text);
} catch (err) {
// Decide deliberately: fail closed, fail open, or queue for later.
console.error('moderation_failed', err.code, err.status);
return res.status(503).json({ error: 'moderation_unavailable' });
}
const decision = decide(result);
if (decision === 'block') {
return res.status(422).json({ error: 'comment_rejected' });
}
if (decision === 'review') {
const comment = await saveComment({ text, status: 'pending_review' });
return res.status(202).json({ status: 'pending_review', id: comment.id });
}
const comment = await saveComment({ text, status: 'published' });
res.status(201).json(comment);
});Three things about that handler are worth calling out.
The moderation call happens before the write. Nothing reaches the database or the broadcast layer until you have a decision. That is what makes this a pre-publish check rather than a cleanup job.
The decision function is separate from the transport. decide() takes a moderation result and returns a string. That makes it trivial to unit test and easy to change when your policy evolves.
Failure is explicit. When the moderation call throws, you choose what happens. Failing closed keeps unsafe content out but breaks publishing during an outage. Failing open keeps the product usable but lets content through. Many teams compromise: fail closed for chat broadcast, fail open into a pending state for comments.
For a chat application the same helper runs inside the socket handler, before io.to(room).emit(...). The shape is identical: moderate, branch, then publish or hold.
Handle Long Text and Request Accounting
The text field accepts up to 10,000 characters. For request accounting, each started 3,000-character piece counts as one request. That means:
- A 1,200-character comment is one request.
- A 3,000-character post is one request.
- A 7,500-character article is three requests.
- A 10,000-character document is four requests.
If you need a rough pre-flight estimate in code, the arithmetic is small:
const PIECE_SIZE = 3000;
function requestCount(text) {
return Math.max(1, Math.ceil(text.length / PIECE_SIZE));
}Cloud API usage is billed per request, so this counting rule is the number to plan against rather than raw character volume. Splitting a document yourself does not reduce the count, because a text already counts by started 3,000-character pieces. Send the full text, up to 10,000 characters, in a single call. The Pricing page documents the usage-based request model if you need to size a budget.
Handle Errors and Edge Cases
Every failed request returns the same JSON shape, with a code and a message:
{
"code": "invalid_request",
"message": "The request body must include a non-empty text field."
}Five documented status codes and five documented error codes cover the failure surface:
- 400
invalid_request— the body is malformed,textis missing or empty, or the text exceeds 10,000 characters. - 401
unauthorized— theAuthorizationheader is missing or the Cloud API key is not valid. - 402
insufficient_credits— the account does not have credits available for the request. - 403
wrong_key_type— the key is the wrong type for this API. A private on-premises key does not work with the cloud API. - 500
internal_error— the request failed on the server side. Retry with backoff.
A compact handler keeps the mapping readable:
switch (err.code) {
case 'invalid_request':
return res.status(400).json({ error: 'bad_input' });
case 'unauthorized':
return res.status(500).json({ error: 'server_misconfigured' });
case 'insufficient_credits':
return res.status(503).json({ error: 'moderation_unavailable' });
case 'wrong_key_type':
return res.status(500).json({ error: 'server_misconfigured' });
case 'internal_error':
return res.status(503).json({ error: 'moderation_unavailable' });
default:
return res.status(503).json({ error: 'moderation_unavailable' });
}Notice how 401 and 403 are mapped to a 500 for your own clients. Those are configuration problems on your side, not something the end user did, so surfacing them verbatim would be misleading.
The API reference does not document rate limits, does not document 429 responses, and does not document X-RateLimit headers. Do not build throttling logic that assumes any of them. What you should build is bounded retry with exponential backoff for internal_error, plus a circuit breaker so a stretch of 500s does not stall every publish in your app.
Test Before Production
The fastest way to confirm request and response shapes is the Playground, which runs the moderation API in the browser. Paste a few representative strings — clean, borderline, obvious violation — and check which categories come back true. It is a good way to sanity check your policy before you commit to it in code.
For unit tests, mock the boolean response rather than calling the live API:
const mockResult = (overrides = {}) => ({
categories: {
toxic: false, profanity: false, hate: false, harassment: false,
self_harm: false, adult: false, violence: false, drugs: false,
weapons: false, pii: false, spam: false, minor: false,
},
flagged: false,
...overrides,
});With a factory like that you can cover every branch in decide(): allow, block, review, and the error path where moderateText throws. Log the outcome of each decision — the flagged value, the code on failures, and your own decision string. Because the API returns only booleans, there is nothing else to log and no score to chart.
Common Questions About the CautionLabs Node.js Integration
Does the API return confidence scores?
No. The response contains twelve boolean category flags and a single flagged boolean. There are no scores, probabilities, or confidence values in the payload, and there are no thresholds to tune on your side. Your policy lives entirely in how you branch on true and false.
Can I batch multiple texts in one request?
No. There is no batch endpoint. Each request accepts one text string, so moderating a hundred messages means a hundred requests. If you need throughput, run requests concurrently from your own worker pool — the API itself has no batching mode.
What happens with text longer than 3,000 characters?
Text can be up to 10,000 characters, and each started 3,000-character piece counts as one request. A 10,000-character text therefore accounts for four requests. There is no separate long-text endpoint: you send the text, and the request count follows the documented piece rule.
Does the cloud API accept private on-premises keys?
No. A private on-premises key does not work with the cloud API. The cloud API authenticates with a Cloud API key from the dashboard, sent as a bearer token. If a request comes back with wrong_key_type, that mismatch is usually why.
Are rate limits documented?
No documented rate limits, no documented 429 responses, and no documented X-RateLimit headers. Plan your integration around the documented status codes, add backoff for internal_error, and decide in advance whether your app fails open, fails closed, or queues when moderation is unavailable.
Next Steps
You now have everything needed to moderate user-generated content in Node.js: a bearer-authenticated POST /moderation call, a text field capped at 10,000 characters, twelve boolean category flags, and an overall flagged value to branch on.
A sensible order of work from here:
- Try the request in the Playground with a handful of real strings from your product.
- Confirm authentication and response fields against the Cloud API Reference.
- Add the
moderateTexthelper to your codebase and call it from one route. - Write the
decide()function with your own policy, then unit test each branch. - Expand to chat broadcasts, queue workers, and edits once the first path is stable.
If your requirements point towards running the model on your own infrastructure instead, the On-Premises Guide covers the documented setup. And if you want the conceptual overview of the endpoint and the twelve flags again before you ship, the complete guide to adding a content moderation API is the companion read.
Ready to see the response for yourself? Head to the Playground and run a request, then use the Cloud API Reference to lock down authentication and response fields before the check goes anywhere near production.