PII Detection API: Screening User Text Before You Store It

Most teams never decide to store personal data. It arrives one field at a time: a phone number pasted into a notes box, an address typed into a bio, an order reference dropped into a support form. Six months later you are working out which tables contain data you have to delete on request.
This article covers one narrow, practical step: screening user-generated text with a PII detection API before you insert it. It is not a replacement for a redaction pipeline or a data-loss-prevention program. It is a gate that sits between the request body and your database, and it returns a boolean.
Why PII ends up in your database without anyone noticing
Free-text fields are where personal data hides. Bios, review bodies, comment threads, chat messages, support-ticket descriptions and "anything else we should know?" boxes. Users type phone numbers, home addresses, email addresses, order numbers, dates of birth and account identifiers into all of them, usually without thinking of it as personal data.
Once a value lands in a row, it is not just in the row. It is in the write-ahead log, the replication stream, the nightly backup, the search index built on top of the table, the log line you emitted with the request body, and the analytics export that runs on Fridays. Retention policies have to cover it. Subject access and deletion requests have to find it. One pasted phone number can turn a routine "delete my account" job into an archaeology project.
The usual answers are a regex pass over incoming text, a full DLP product, or nothing until an incident. A regex pass works until it doesn't: formats vary, users obfuscate, and numbers show up in contexts you did not anticipate. A model-backed check gives you a second opinion on the same text for the cost of one HTTP call in the request path.
Get the framing right before you write any code. This is a pre-persistence screen. It reduces how often personal data reaches storage, which reduces how far you have to look when something must be deleted. Redaction — and the decision about what happens to a flagged submission — stays in your code.
What the CautionLabs API gives you for PII
CautionLabs exposes a single endpoint, POST /moderation, on https://api.cautionlabs.com. You send JSON, you get JSON back, and every category in the response is a boolean.
Request: POST /moderation with a single text field
The request body has one required property, text.
{
"text": "You can reach me on 07700 900123 or at the address in my profile."
}Authentication is a bearer token in the Authorization header. The key must be a Cloud API key from the dashboard. A private (on-premises) key does not work with the cloud API.
Authorization: Bearer <your-cloud-api-key>
Content-Type: application/jsonEach request covers a text of up to 3,000 characters. Texts of up to 10,000 characters are checked in 3,000-character pieces, one request each.
Response: a pii boolean inside categories, plus the overall flagged boolean
{
"categories": {
"toxic": false,
"profanity": false,
"hate": false,
"harassment": false,
"self_harm": false,
"adult": false,
"violence": false,
"drugs": false,
"weapons": false,
"pii": true,
"spam": false,
"minor": false
},
"flagged": true
}Read it as follows:
categories.piiistrueorfalse. There is no number behind it.flaggedistruewhen any category is flagged, so in the shape above it istruebecausepiiis.- All twelve categories follow the same shape:
toxic,profanity,hate,harassment,self_harm,adult,violence,drugs,weapons,pii,spam,minor.
There are no scores, probabilities or confidence values anywhere in the response, and there are no thresholds to tune. That is deliberate, and it is what makes the output directly usable as a control-flow condition: you branch on a boolean.
Two limits are worth internalising now:
- The API does not return spans, offsets, matched substrings, entity types, or a masked version of your text.
- The API does not redact anything. Redaction is application code.
There is also a dedicated category page if you want the product-level view of PII detection alongside the other eleven categories.
Wiring a PII check into your write path
Screening before insert, not after
By the time a row is committed, the text is in the table, the WAL, the replica, the backup, and probably a log line. Screen the text while it is still an in-memory string.
A Python version using requests:
import os
import requests
MODERATION_URL = "https://api.cautionlabs.com/moderation"
CLOUD_KEY = os.environ["CAUTIONLABS_CLOUD_KEY"]
def screen_text(text: str) -> dict:
response = requests.post(
MODERATION_URL,
headers={
"Authorization": f"Bearer {CLOUD_KEY}",
"Content-Type": "application/json",
},
json={"text": text},
timeout=10,
)
response.raise_for_status()
return response.json()
def create_comment(user_id: int, body: str) -> dict:
result = screen_text(body)
if result["categories"]["pii"]:
return {"stored": False, "reason": "pii_detected"}
comment_id = insert_comment(user_id, body)
return {"stored": True, "comment_id": comment_id}The same shape in Node.js with fetch:
const MODERATION_URL = "https://api.cautionlabs.com/moderation";
async function screenText(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 }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(`${error.code}: ${error.message}`);
}
return response.json();
}
export async function createComment(userId, body) {
const result = await screenText(body);
if (result.categories.pii) {
return { stored: false, reason: "pii_detected" };
}
const commentId = await insertComment(userId, body);
return { stored: true, commentId };
}If you want the fuller Python walkthrough — client setup, environment handling and a production layout — the content moderation API Python tutorial covers it. For Express middleware and a Node project layout, the Node.js moderation tutorial applies the same step on the JavaScript stack. If you have not integrated the API at all yet, add a content moderation API in an afternoon is the general overview to read first.
Deciding what a pii: true result does to the request
Because the result is a boolean, your policy is deterministic. There is no cutoff to argue about. Three patterns cover most teams:
- Reject. Return an error to the client, keep the text out of the database, and ask the user to remove the personal data. Simplest to reason about, harshest on false positives.
- Hold for review. Write the text to a quarantine table that is excluded from search, analytics and exports, then let a moderator decide. A good middle ground for support tickets and appeals.
- Store with a flag. Store the row as normal but set a
contains_piicolumn. Your retention and deletion jobs then have a cheap index to scan, and your analytics export can exclude it.
All three keep redaction out of scope. If you want to store a cleaned version of the text, the API does not produce one. You would run your own masking over the raw string after the flag comes back and store the masked result instead of dropping the submission.
One detail trips people up: flagged is true when any category fires, so a submission with spam: true and pii: false still comes back flagged: true. If the only thing you care about at that call site is personal data, branch on result["categories"]["pii"] directly. Use flagged when you want a general "something is wrong here" signal.
What a boolean PII flag does not do
Be precise about the boundaries, because they shape your design:
- No locations. The response does not include spans, offsets or matched substrings. It does not tell you which characters triggered the flag.
- No entity types. There is no breakdown into names, addresses, phone numbers or anything else. The only thing you get is a category named
piiwith atrue/falsevalue. - No redaction or masking. The API never returns an edited version of your text. Whatever happens to a flagged submission happens in your code, after the response.
- No scores. There is no confidence value, so there is no numeric threshold to set. Treat the flag as a signal and layer your own workflow on top: human review, a second check, or a stricter policy for a specific field.
- No assumptions about scope. The documented contract is a boolean category named
pii. Do not design around guesses about what it catches or misses. Measure it against your own traffic instead.
That last point matters for sizing the trade-off. You are trading recall and precision you cannot see for a decision you can make in one line of code. For a field where any personal data at all is a problem, that trade is usually worth it. For a field where false positives are expensive, route the flag to a human rather than to a hard reject.
Combining PII screening with the other eleven categories
One POST /moderation call returns pii alongside toxic, profanity, hate, harassment, self_harm, adult, violence, drugs, weapons, spam and minor. You do not need to chain separate checks, and one call keeps your latency and your failure handling in one place.
A small routing function is enough:
# Application policy, not an API feature.
REJECT = {"pii", "minor", "self_harm"}
def route(result: dict) -> str:
flagged = {name for name, value in result["categories"].items() if value}
if flagged & REJECT:
return "reject"
if flagged:
return "hold_for_review"
return "store"There is nothing to tune here. The set membership test replaces the threshold you would otherwise be debating, and the same function works whether your priority at that call site is personal data, minor safety or spam.
If the text you are screening is model output rather than user input — a summariser that copies a phone number out of a transcript, for example — the same call applies before you log or display it. Checking LLM output for unsafe content covers that adjacent case.
Handling failures and choosing a key type
Failed requests return an error object in this shape:
{
"code": "unauthorized",
"message": "string"
}The code field is one of five documented values:
invalid_request— the body did not match the schema: a missingtextproperty, an empty string, or a text outside the schema's length bounds.unauthorized— the key was not accepted.insufficient_credits— the account cannot pay for the request.wrong_key_type— the key you sent is not the right kind for this endpoint. A private (on-premises) key does not work with the cloud API, so use a Cloud API key from the dashboard.internal_error— something failed on our side.
Treat them differently. invalid_request and wrong_key_type are deterministic: retrying sends the same bad request again. internal_error is the one worth a bounded retry. insufficient_credits is an operational alert, not a user error. The content moderation API error codes article goes deeper on production debugging for each of them.
The bigger design question is fail-open versus fail-closed. When the moderation call fails — a timeout, internal_error, a network blip — do you store the text or not?
- Fail-closed means the submission is not written. For a field where storing personal data is the expensive mistake, this is usually the right default: reject with a retryable error, or write the raw text to a short-lived quarantine store that is excluded from indexes and exports.
- Fail-open means the submission is written and a log entry records that it was unscreened. Reasonable when the cost of blocking legitimate users exceeds the cost of occasionally storing something you will later delete.
Decide per field, not per application. A public comment thread and an internal support ticket do not need the same answer, and the decision should be written down somewhere a reviewer can find it.
When PII must never leave your network
Sometimes the answer is that the text cannot be sent to a third party at all. For those cases CautionLabs ships an on-premises option: the model runs on your own infrastructure, and you call a local POST /moderate endpoint (default http://localhost:8000) with {"text": "..."}. The model container makes no outbound requests and has no telemetry, so text stays inside your network. The same boolean flag model applies.
To decide whether a hosted call is acceptable for your data in the first place, read the on-premises vs cloud content moderation comparison, and the cloud API terms, which state that your text is not stored. If you already know you need the local deployment, deploy on-premises content moderation with Docker walks through the registry login and running the image.
Both options are described on the pricing page, along with the usage model for each.
FAQ
Can I get the exact location of the PII in my text?
No. The response contains booleans only. There are no spans, offsets, matched substrings or entity types, so the API cannot tell you which characters looked like personal data, nor what kind of data they were. If you need to redact, run your own masking over the text after the flag comes back.
Is there a batch endpoint or a webhook?
No. There is no batch endpoint and no webhooks. You send one request per text. Each request covers a text of up to 3,000 characters, and texts of up to 10,000 characters are checked in 3,000-character pieces, one request each.
Do I need to tune a threshold for PII?
No. The API returns true/false flags and an overall flagged value, with no scores or probabilities. There is nothing to tune. Your policy is a branch on a boolean, which is exactly what makes it easy to unit test.
Does the cloud API store my text?
No. Per the cloud API terms, your text is not stored.
Can I use my on-premises key with the cloud API?
No. A private (on-premises) key does not work with the cloud API. Use a Cloud API key from the dashboard for https://api.cautionlabs.com.
How many requests does a long submission cost?
Each request covers up to 3,000 characters, and texts of up to 10,000 characters are checked in 3,000-character pieces, one request each. See the pricing page for the current rate.
Add the screen to one write path this week
Start narrow. Pick the single field most likely to collect personal data — the free-text box on your support form is usually the winner — and put the check in front of the insert. Return a clear error or quarantine the row, log the decision, and watch how often pii fires in real traffic. Once you have a week of data, you can decide whether to widen the policy to comments, bios and chat.
Try a PII check in the Playground with your own sample text, then read the Cloud API reference to add POST /moderation to your write path.