←Back to Insights
Insights•

Content Moderation API Error Codes: Production Debugging

C
CautionLabs Team
Core Contributor
server room
Photo by S.C. Air National Guard · Public Domain · Source

The Happy Path Is the Easy Part

A successful POST /moderation call returns exactly two things: a categories object containing twelve boolean flags (toxic, profanity, hate, harassment, self_harm, adult, violence, drugs, weapons, pii, spam, minor) and a top-level flagged boolean that is true when any of those twelve is true. There is nothing else in the response — no scores, no probabilities, no confidence values, and no thresholds to tune. If you already have that call returning 200s, you got there somehow; our walkthrough on how to add text moderation in an afternoon is one route, and this article assumes you have finished it.

Production integrations rarely break on the response side. The response schema is small and fixed, so parsing it is not usually where your incident comes from. What breaks is the request side: a payload that violates the request schema, a key of the wrong type, an account that has run out of credits, and the occasional server-side failure. Those are four distinct failure classes, and they all arrive through the same small error object.

The single most useful design decision you can make is to branch on the code field in the error body rather than on the HTTP status. The status tells you how the transport layer behaved; the code tells you what your application should do next. Retrying wrong_key_type will never succeed. Retrying internal_error sometimes will. Only the code distinguishes them.

The Error Shape: code and message

Every failed call returns the same error schema, and both fields are required:

  • code — one of five documented values: invalid_request, unauthorized, insufficient_credits, wrong_key_type, internal_error.
  • message — a human-readable string describing what went wrong.

The endpoint documents these response statuses: 200, 400, 401, 402, 403 and 500. The codes and the statuses are documented as separate lists, so treat them as separate lists in your code. Do not build a lookup table that assumes a particular status always carries a particular code. Read code for application logic, and use the status only for transport-level logging, metrics, and alerting on the shape of your traffic.

Here is what each error body looks like. The message values below are illustrative: the schema defines message as a plain string, so treat it as diagnostic text for humans and never parse it for logic.

JSON
{
  "code": "invalid_request",
  "message": "text is required"
}
JSON
{
  "code": "unauthorized",
  "message": "missing or invalid API key"
}
JSON
{
  "code": "insufficient_credits",
  "message": "account has no remaining credits"
}
JSON
{
  "code": "wrong_key_type",
  "message": "this key cannot be used with the cloud API"
}
JSON
{
  "code": "internal_error",
  "message": "unexpected error while moderating text"
}

The dispatcher below is the shape I would ship. It is a plain HTTP client — there is no SDK to import — and it makes the retry-versus-fail-fast decision in one place, keyed on code:

JavaScript
// One dispatcher for every documented error code.
async function moderate(text) {
  const response = await fetch("https://api.cautionlabs.com/moderation", {
    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.status === 200) {
    return { decision: "moderated", flagged: payload.flagged, categories: payload.categories };
  }

  switch (payload.code) {
    case "invalid_request":
      // Your payload broke the schema. Fix the caller. Never retry as-is.
      throw new Error(`invalid_request: ${payload.message}`);

    case "unauthorized":
    case "wrong_key_type":
      // Configuration problem. Fail fast, alert, do not retry.
      throw new Error(`${payload.code}: ${payload.message}`);

    case "insufficient_credits":
      // Operational problem. Stop sending and page the account owner.
      throw new Error(`insufficient_credits: ${payload.message}`);

    case "internal_error":
      // Transient. Retry with bounded backoff.
      return { decision: "retry" };

    default:
      // Unknown code. Fail fast so new codes are visible rather than silently retried.
      throw new Error(`unhandled moderation error code: ${payload.code}`);
  }
}

invalid_request: Matching the Request Schema Exactly

The request body is a JSON object with a single required property: text, a string with minLength: 1 and maxLength: 10000. That is the entire schema. invalid_request means your body did not satisfy it, and in practice it comes from three places:

  • Missing or empty text. An empty string fails minLength: 1, and so does a property that was dropped during serialisation because it was undefined or null.
  • Text longer than 10,000 characters. This fails maxLength.
  • A body that is not valid JSON, or a missing Content-Type: application/json header. If your HTTP client sends a form-encoded body or forgets the content type, the server has nothing it can parse.

The fix belongs in the caller, before the request leaves your process:

  1. Trim and normalise. Strip leading and trailing whitespace, and collapse the input you actually intend to send. A whitespace-only string should be treated as empty by your own validation.
  2. Check the length on the exact string you are about to serialise. Not on the raw user input, and not on some intermediate value that a later transformation changes.
  3. Short-circuit empty input. If a comment or message is empty after trimming, do not send it. There is nothing to moderate, and a request that can only fail is a request you should not pay for.
  4. Handle oversized input explicitly. You choose the policy — truncate, split, or reject with your own validation error — but you must choose it before the request goes out.

A useful discipline: log the serialised body length alongside every invalid_request you see. If the length is 0 or greater than 10,000, you have found the bug without reading a single message string. If the length looks correct, check the header — a client that silently switches content types is the second most common cause.

unauthorized and wrong_key_type: Which Key Goes Where

The cloud API authenticates with a bearer cloud API key created in the dashboard, sent as an Authorization: Bearer <key> header. unauthorized means the request did not carry a usable cloud key: the header is missing, malformed, or the key is not recognised. wrong_key_type means the key is real, but it is not the kind of key this endpoint accepts.

The classic cause of wrong_key_type is a private, on-premises key being pointed at the cloud API. A private on-premises key does not work with the cloud API. If your service reads a single MODERATION_API_KEY environment variable and that variable is populated differently in staging and production, this is exactly the error you will get, and it will look mysterious until you check which key the process actually loaded. Our guide to on-premises content moderation with Docker covers the private key and the local endpoint in the context where it belongs.

Neither of these errors should ever be retried. They are configuration facts, not transient conditions:

  • Fail fast. Return an error to your caller or route to your fallback policy. Do not loop.
  • Do not log the key. Log a prefix or an internal identifier, never the full value, and make sure your error handler does not dump request headers.
  • Alert loudly. A spike in either code means a deploy, a secret rotation, or an environment mismatch. Every request in that window is failing, which is worth a page.
  • Check the environment first. When you see wrong_key_type in production, diff the key your process loaded against the key you expect for that environment before you look anywhere else.

insufficient_credits and How Requests Are Counted

The cloud API is usage-based at $0.40 USD per 1,000 requests. The detail that matters for capacity planning is how a request is counted: each started 3,000-character piece of a request counts as one request. A single request carries text of up to 10,000 characters, so the cost of a call scales with the length of the text you send, not with the number of HTTP calls you make.

Concretely: a 500-character comment is one request. A 7,500-character document is three requests, because it starts three 3,000-character pieces. A 10,000-character document is four. If your product lets users paste long articles, your request budget is driven by total characters processed, and one HTTP call can consume several requests' worth of credit.

insufficient_credits means the account has no remaining credits to pay for the call. It is an operational signal, not a code bug, and it is the one error where your client behaviour can make things worse. What to do instead:

  • Track characters, not calls. Log the character count of every request alongside the response code. That metric predicts credit exhaustion; call counts do not.
  • Alert on burn rate. A daily character total against your remaining balance tells you how many days of runway you have. Alert on the runway, not on the moment the balance reaches zero.
  • Throttle before you exhaust. If a batch job or a backfill is going to consume a large share of your balance, rate-limit it at the application level and keep headroom for interactive traffic.
  • On insufficient_credits, stop sending. Backing off and retrying in a loop does not create credits. Halt the workload, surface the condition to whoever owns the account, and let your fallback policy handle the traffic in the meantime.
  • Distinguish the two failure shapes. A sudden insufficient_credits on every request usually means the account is out. An occasional one under load usually means two systems are sharing a budget and one of them is winning.

internal_error: What Your Client Should Do

internal_error means the server accepted the request and something failed while processing it. Your client cannot fix it, and the error carries no information about the text itself. Your job is to contain it.

  • Log code and message together, and never depend on the request body. Your logs may redact user text, and they should. If your debugging path requires the original payload, you have built a debugging path that will not exist when you need it.
  • Treat it as transient at the client layer. Bounded retries with exponential backoff and jitter are reasonable. Unbounded retries are not — they turn a blip into an outage. The dispatcher above returns a retry decision for exactly this case and nothing else.
  • Decide your fallback policy in advance. Fail open (let the content through), fail closed (block it), or queue it for a human. All three are defensible; picking one at 3 a.m. is not.
  • Do not attempt a partial decision. The API returns no scores and no confidence values, so there is no partial result to fall back on. If the call fails, you have a result or you do not. Your fallback is a policy decision, not a numeric one.

One pattern worth adopting is to make the fallback explicit in your own return type — something like { decision: "allow" | "block" | "queue", source: "api" | "fallback" } from your moderation wrapper — so that downstream code cannot accidentally treat a fallback as an API result.

What the API Does Not Do (So You Know What You Must Build)

The absences matter as much as the features when you are writing the code around the call:

  • No batch endpoint. There is one endpoint, POST /moderation, and it takes one text per call. If you have a backlog, you build the fan-out, the concurrency, and the per-call error handling yourself.
  • No webhooks. The API does not call you back. Moderation happens inline in your request path, which is the right place for it, but it means your latency budget includes the call and your error handling must be synchronous.
  • No thresholds. Every category is a boolean, and flagged is true when any of them is true. Mapping those booleans to your own policy actions — warn, hide, block, escalate to review — is entirely your design.
  • No documented rate limits. The documentation does not document rate limits, 429 responses, or X-RateLimit headers. Do not build client behaviour that assumes any of them exist. Control your own concurrency, and use your own character-count metrics to decide how fast to send.

Confirm your integration handles every documented error code, then run real traffic against the Playground and check the Cloud API Reference before you ship to production. The five codes are a short list, and the switch statement that covers them is shorter.

FAQ

Which status codes can POST /moderation return? The endpoint documents 200 for a successful moderation, plus 400, 401, 402, 403 and 500 for failures. Every failure carries the same error body with a code and a message. Branch on code for application logic and keep the status for transport-level logging and metrics, because the two lists are documented separately.

How do I tell a bad key from an unauthorised one? Read the code. unauthorized means the request did not carry a usable cloud API key — missing, malformed, or unrecognised. wrong_key_type means the key is real but is not the type this endpoint accepts, and the classic cause is a private on-premises key pointed at the cloud API. Both are configuration problems: fail fast, alert, and never retry.

How many requests does a long piece of text cost? Each started 3,000-character piece of a request counts as one request. A request accepts text of up to 10,000 characters, so a 7,500-character text costs three requests and a 10,000-character text costs four. The cloud API is usage-based at $0.40 USD per 1,000 requests, so track characters rather than calls when you forecast spend.

Can I retry a 500 response? Yes, with bounds. internal_error is the server reporting a failure after accepting the request, so bounded retries with exponential backoff and jitter are reasonable. Decide in advance what happens when those retries are exhausted — fail open, fail closed, or queue for a human — and remember there is no score or confidence value to fall back on, because the API does not return them.

Want more insights?

Stay updated with the latest in moderation and platform safety.

Back to Overview