Quick Answer
Send the ticket text to POST /api/v1/ai/pii-redaction with return_entities set to true, and store data.result.redacted_text instead of the original. The response also lists what was found in entities_redacted (for example one email and one phone number), so you can flag tickets that contained sensitive data.
Support tickets are full of personal data: names, email addresses, phone numbers, order IDs and sometimes card numbers. Once that text is copied into logs, analytics tools or AI prompts, it is hard to clean up. In this tutorial you will build a PII redaction step in Node.js that masks personal data before a ticket is stored or forwarded anywhere else.
1. Why regex alone is not enough
Regular expressions catch well-formatted emails and card numbers, but miss names, addresses written in prose, phone numbers with unusual spacing and data in other languages. A language-model based redaction API understands context, so call me on nine eight two two... and my name is Priya are masked too. A good setup combines both: fast regex for obvious patterns, the API for everything else.
The cost of getting this wrong is not abstract. Personal data in a support inbox tends to spread: it is copied into a helpdesk, mirrored to a data warehouse, pasted into a Slack thread and sent to an AI summarizer. Every copy is another place to secure, another system to search when someone files a data-deletion request, and another potential leak. Redacting once, at the point of entry, means most of those copies never contain the sensitive details in the first place.
| Example in a ticket | Regex | AI redaction |
|---|---|---|
priya.k@example.com | Caught | Caught |
| "I'm Priya Kapoor from Pune" | Missed | Name masked |
| "call me on nine eight two two one..." | Missed | Phone masked |
| "4111 2222 3333 4444" | Caught if the pattern allows the spacing | Caught |
| "I live at 12 Park Street, flat 4B" | Missed | Address masked |
2. Write a redaction helper
const RSFLOWHUB_URL = 'https://rsflowhub.com/api/v1/ai/pii-redaction';
/**
* Returns { text, found } where text is safe to store.
* Falls back to a basic regex mask if the API is unavailable.
*/
export async function redactPII(text, options = {}) {
try {
const res = await fetch(RSFLOWHUB_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.RSFLOWHUB_KEY,
},
body: JSON.stringify({ text, return_entities: true, ...options }),
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { data } = await res.json();
return {
text: data.result.redacted_text,
found: data.result.entities_redacted ?? [],
};
} catch (err) {
console.warn('PII redaction unavailable, using regex fallback:', err.message);
return { text: basicMask(text), found: [] };
}
}
function basicMask(text) {
return text
.replace(/[\w.+-]+@[\w-]+\.[\w.]+/g, '[EMAIL]')
.replace(/\+?\d[\d\s-]{7,}\d/g, '[PHONE]');
}
The return_entities: true flag is what makes the API include the entities_redacted summary. Without it you still get the masked text, but not the list of what was found. The result looks like this:
{
"success": true,
"request_id": "req_3f9a2c7e-5b1d-4e8a-9c2f-7d6e1a0b4c8d",
"data": {
"result": {
"redacted_text": "Hi, I am [NAME], my email is [EMAIL] and my number is [PHONE].",
"entities_redacted": [
{ "type": "NAME", "count": 1 },
{ "type": "EMAIL", "count": 1 },
{ "type": "PHONE", "count": 1 }
]
}
},
"meta": { "version": "v1", "timestamp": "2026-10-05T09:30:00Z" }
}
Notice that the response contains counts, not the original values. The API reports that an email was found without repeating it, so the summary itself is safe to log.
3. Choose what to redact, and how
By default the endpoint redacts all high-risk personal data. Two optional fields let you tune that for your use case:
| Field | Allowed values | Use it to |
|---|---|---|
redact_types | names, emails, phone_numbers, credit_cards, ssn, addresses, passwords, ip_addresses | Mask only some categories. An empty list redacts all high-risk PII. |
redaction_style | placeholder, asterisk, scramble | Choose [EMAIL] style tokens, partial masks such as j***@example.com, or scrambled values. |
Which setting is right depends on who reads the result. Placeholders are best for logs and AI prompts because the meaning stays clear ([EMAIL] asked for a refund). Asterisk masks keep a hint of the original, which helps support agents recognise a customer without seeing the full value. Selective redaction is useful when your team legitimately needs some data, for example keeping names so agents can greet customers but masking cards and passwords:
const { text } = await redactPII(ticket.message, {
redact_types: ['credit_cards', 'ssn', 'passwords'],
redaction_style: 'asterisk',
});
4. Redact tickets in Express middleware
import express from 'express';
import { redactPII } from '../lib/redact.js';
const router = express.Router();
async function redactBody(req, res, next) {
if (typeof req.body?.message === 'string') {
const { text, found } = await redactPII(req.body.message);
req.body.message = text;
req.body.containsPII = found.length > 0;
}
next();
}
router.post('/tickets', express.json(), redactBody, async (req, res) => {
// Only the redacted message reaches your database, helpdesk and analytics.
// await db.tickets.insert({ message: req.body.message, contains_pii: req.body.containsPII });
res.status(201).json({ ok: true });
});
export default router;
If agents need the original details to resolve a ticket, store the original in a separate, access-controlled table with a short retention period, and use the redacted version everywhere else. A simple layout is a tickets table that holds redacted text for the helpdesk, analytics and any AI tooling, and a ticket_originals table with strict permissions that is purged automatically after the ticket is closed.
5. Handle long tickets and large batches
The endpoint accepts up to 10,000 characters per request. Most tickets are far shorter, but email threads and chat transcripts can exceed that. Split long text on paragraph boundaries, redact each chunk, and join the results. Keep chunks under the limit with some margin:
import { redactPII } from './redact.js';
const MAX = 9000;
export async function redactLong(text) {
if (text.length <= MAX) return redactPII(text);
const chunks = [];
let current = '';
for (const paragraph of text.split('\n\n')) {
if ((current + paragraph).length > MAX) {
chunks.push(current);
current = '';
}
current += paragraph + '\n\n';
}
if (current.trim()) chunks.push(current);
const results = [];
for (const chunk of chunks) {
results.push(await redactPII(chunk)); // sequential keeps you under rate limits
}
return {
text: results.map((r) => r.text).join(''),
found: results.flatMap((r) => r.found),
};
}
For back-filling an existing ticket archive, run the same helper from a script and keep concurrency low. Default rate limits are per minute and per endpoint, so a small worker pool (two to four requests at a time) is far safer than firing thousands of requests at once. If you do hit a limit the API answers with HTTP 429 and a rate_limit_exceeded error, and the fallback in the helper keeps the pipeline from storing unmasked text in the meantime.
The regex fallback only masks emails and phone numbers. If you cannot tolerate a ticket being stored with a name or address unmasked, mark fallback results with a flag such as
needs_redaction_retry and re-run them later instead of treating them as clean.
6. Keep logs and AI prompts clean
- Redact before calling
console.logor sending events to logging and analytics services. - Redact before passing ticket text to other AI tools such as summarizers or reply generators.
- Use the
foundlist to tag tickets that contained sensitive data, which helps with data-subject requests. - Each request accepts one text; process large exports in batches within your plan rate limit.
7. Test the redaction before relying on it
Build a small fixture of realistic tickets and check the output by eye before the middleware goes live. Include the awkward cases: names in the middle of a sentence, numbers split by spaces, and non-English text.
import { redactPII } from '../lib/redact.js';
const samples = [
'Hi, this is Priya Kapoor. My order #4471 never arrived. Mail me at priya.k@example.com.',
'Please call me on +91 98220 14455 after 6pm, I live at 12 Park Street, Pune.',
'My card 4111 2222 3333 4444 was charged twice, please refund.',
'Ticket text with no personal data at all, just a bug report.',
];
for (const sample of samples) {
const { text, found } = await redactPII(sample);
console.log(text);
console.log(' found:', JSON.stringify(found), '\n');
}
Check three things in the output: that nothing sensitive survived, that order numbers and other business data you need were left alone, and that the clean sample came back unchanged with an empty found list. If something you need is being masked, narrow the list with redact_types.
8. Common mistakes to avoid
- Redacting after storing. If the original is already in your database or logs, redacting later does not remove it. Redact first, store second.
- Forgetting
return_entities. Without it you cannot tell which tickets contained sensitive data. - Calling the API from the browser. The key must stay on your server. Use middleware or a backend route, never client-side code.
- Treating redaction as compliance. It reduces exposure, but retention rules, access control and a data-processing agreement still matter.
- Redacting too much. Masking everything makes tickets useless to agents. Use
redact_typesto keep what your team needs. - No monitoring. Log how often the fallback is used; a rising number means the API is failing and unmasked data may be building up.
Mask Personal Data with One API Call
PII redaction is a utility endpoint covered by the free monthly quota on every plan. It works from Node.js, PHP, Python or any backend.
View PII Redaction API →Key Takeaways
- Redact ticket text before it reaches databases, logs or analytics.
- Use the API for context-aware detection and regex as a fallback.
- Set return_entities to true to get the entities_redacted summary.
- Store redacted_text everywhere; keep originals only where strictly needed.
- Use redact_types and redaction_style to keep what agents need and mask the rest.
- Split text over 10,000 characters and keep concurrency low for back-fills.
- Fail safe: mask with regex if the API is unreachable, and retry flagged tickets later.
No comments yet
Be the first to share your thoughts on this article!