[nonimo]
EN
Download the app

How to redact PII from OpenAI and Claude API calls

· Updated

Fictional OpenAI API request body with names, email and card as labels, and a customer ID left in metadata: where PII hides in a call
Joaquín Trapero

in Founder of Nonimo, writing on AI and privacy.

When your code calls OpenAI, everything in the request body leaves your network: the user’s message, the system prompt your code wrote, the earlier turns, the tool results. Redaction means swapping the personal parts for placeholders before the call and swapping them back when the answer lands.

This guide follows one request through that loop, with code for openai-python and openai-node. The API is one of several exits; the other ways data slips out to AI tools need their own fixes. Everyone below is invented, and I want the loop visible in plain code before any product enters it.

Quick answer

Mask the whole request body before the SDK sends it, keep the placeholder table on your side, and restore values after the reply arrives. Streamed text needs a small buffer so split placeholders still match, and tool arguments get restored only after the JSON is complete.

What OpenAI keeps from each API request

Start with the provider’s side, because it sets the stakes. By OpenAI’s account, nothing sent through the API trains its models unless your organisation opts in. It does keep abuse monitoring logs, which may hold prompts and responses, for up to 30 days by default, and longer where the law or harm prevention requires it.

On top of that sits what OpenAI calls application state. Chat Completions keeps none by default. The Responses API keeps it for 30 days unless you send store: false. Zero Data Retention removes content from the abuse logs, but OpenAI grants it only after approval, and files, vector stores and conversations aren’t eligible.

I suspect some teams read all this as protection, and it isn’t. Thirty days is a copy you don’t control, and a retention setting doesn’t decide what goes in. Our page on what OpenAI does with business and consumer data covers the ChatGPT side; here the fix is to send less.

How long OpenAI holds API data, endpoint by endpoint

Defaults for an organisation without Zero Data Retention.

EndpointAbuse monitoring logsApplication stateZero Data Retention
/v1/chat/completionsUp to 30 daysNone (audio outputs: 1 hour)Eligible, with limits
/v1/responsesUp to 30 days30 days by default, or whenever store is trueEligible, with limits; store treated as false
/v1/conversationsUntil deletedUntil deletedNot eligible
/v1/embeddingsUp to 30 daysNoneEligible
/v1/files, /v1/vector_storesUp to 30 daysUntil deletedNot eligible

From OpenAI's API data controls page, accessed 10 November 2026. Zero Data Retention and Modified Abuse Monitoring need OpenAI's prior approval.

Which parts of a request can carry personal data?

Filtering the text box your users type into covers only part of the body. The system prompt often carries account details your own code looked up. Earlier turns are resent on every Chat Completions call. Tool arguments are written by the model, and tool results are rows from your own database.

Two fields are easy to forget because they aren’t text for the model. OpenAI’s safety guide asks you to hash the username or email you send as safety_identifier, and metadata tends to collect customer IDs. For the categories that count as personal in the first place, see our field guide to which details need removing.

Claude’s Messages API has the same holes under other names. Its retention terms differ too, as our page on how Anthropic treats API and chat data explains. A list of field names worries me, because it goes stale the day someone adds a field. Walking every string in the body doesn’t.

Where personal data rides in each API

Pick the API your code calls.

FieldWhat ends up thereWhy it gets missed
system or developer messageAccount owner, case notes pulled from your CRMYour code wrote it, so nobody calls it user input
instructionsThe same kind of context, as a top-level fieldIt sits outside input
Top-level systemThe same kind of contextIt isn't in messages
Earlier turns in messagesEverything said so far, resent each call"Already sent", so it skips the filter
previous_response_id or conversationEarlier turns held by OpenAI, not resent by youWhatever went out unmasked once stays in that state
tool_calls[].function.argumentsA JSON string with names, emails, order numbersThe model wrote it, not a person
function_call item argumentsThe same, keyed by call_idArrives as an output item, not text
tool_use block inputThe same, as a JSON objectNot a string, so string filters skip it
Tool results (tool message, function_call_output, tool_result)Database rows you hand backGoes in after the user's turn has been checked
Attached files and imagesCVs, contracts, scanned formsText inside a PDF never meets your regex
safety_identifier, metadataUser emails, customer IDsFeels like plumbing, not content

Field names from OpenAI's function calling guide and API reference, and Anthropic's streaming and tool-call pages. The right-hand column is our reading.

Masking emails, phones and card numbers with a regex

For fixed formats, the standard library is enough. The class below walks a whole request body, swaps each match for a numbered placeholder and remembers it. The same value always gets the same placeholder within a conversation, which keeps the model’s references straight. Python 3.9 or later, no packages:

import re

PATTERNS = {
    "EMAIL": re.compile(r"\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b"),
    "PHONE": re.compile(r"\+\d[\d ()-]{7,16}\d"),
    "CARD": re.compile(r"\b\d(?:[ -]?\d){12,18}\b"),
}
PLACEHOLDER = re.compile(r"\[[A-Z_]+_\d+\]")
KEEP_AS_IS = {"model", "role", "type", "id", "tool_call_id", "call_id"}


class Swapper:
    """Keep one per conversation, on your side only."""

    def __init__(self):
        self.out = {}    # real value -> placeholder
        self.back = {}   # placeholder -> real value
        self.seen = {}   # how many of each kind so far

    def placeholder(self, kind, value):
        if value not in self.out:
            self.seen[kind] = self.seen.get(kind, 0) + 1
            ph = f"[{kind}_{self.seen[kind]}]"
            self.out[value], self.back[ph] = ph, value
        return self.out[value]

    def mask(self, text):
        for kind, rx in PATTERNS.items():
            text = rx.sub(lambda m, k=kind: self.placeholder(k, m.group()), text)
        return text

    def unmask(self, text):
        return PLACEHOLDER.sub(lambda m: self.back.get(m.group(), m.group()), text)

    def walk(self, obj, fn):
        """Apply mask or unmask to every string in a request or a tool's arguments."""
        if isinstance(obj, dict):
            return {k: v if k in KEEP_AS_IS else self.walk(v, fn) for k, v in obj.items()}
        if isinstance(obj, list):
            return [self.walk(v, fn) for v in obj]
        return fn(obj) if isinstance(obj, str) else obj

swap.walk(messages, swap.mask) returns a masked copy of the list you pass to the SDK. Look hard at what it misses: names, street addresses, dates of birth, phone numbers written without a country code, any national ID number. The card pattern has no Luhn check, so a long order number can trip it.

Regex suits columns with a known shape, which is why masking a CSV file column by column works so well with it. For free prose, my preference is never to lean on it alone. The table shows both lines as the class handles them, exactly as it prints them.

Two lines through the regex Swapper

Highlighted text left your network unmasked.

Your code sendsOpenAI receives
Credit Ottoline Brackwater, card 4000 0566 5566 5556, and confirm to o.brackwater@example.org or +1 202 555 0164.Credit Ottoline Brackwater, card [CARD_1], and confirm to [EMAIL_1] or [PHONE_1].
Order 2024-0001-5567-8812 for Ms Brackwater, 14 Rowan Court, Springfield, phone (202) 555-0164, DOB 1981-04-03Order [CARD_2] for Ms Brackwater, 14 Rowan Court, Springfield, phone (202) 555-0164, DOB 1981-04-03

Output of the code above on Python 3.9, with invented data. In the second row the order number was taken for a card, a false positive that costs the model context.

Catching names: Presidio before the call

Names need something that reads context. Presidio is the open-source detector most teams reach for. It started at Microsoft and now lives with community maintainers under the data-privacy-stack organisation on GitHub, still MIT-licensed. Install presidio-analyzer and the spaCy model en_core_web_lg, then let it feed the same Swapper:

from presidio_analyzer import AnalyzerEngine

analyzer = AnalyzerEngine()  # first: python -m spacy download en_core_web_lg
KINDS = ["PERSON", "EMAIL_ADDRESS", "PHONE_NUMBER", "CREDIT_CARD", "IBAN_CODE"]


def mask_with_presidio(text, swap):
    hits = sorted(analyzer.analyze(text=text, entities=KINDS, language="en"), key=lambda h: h.start)
    kept, last_end = [], -1

    for h in hits:
        if h.start >= last_end:  # skip a hit that overlaps the one before
            kept.append((h.start, h.end, swap.placeholder(h.entity_type, text[h.start:h.end])))
            last_end = h.end
    for start, end, ph in reversed(kept):
        text = text[:start] + ph + text[end:]
    return text

We ran the test with presidio-analyzer 2.2.360 and en_core_web_lg on an invented line in British format: “Ottoline Brackwater rang from +44 20 7946 0958 about 14 Rowan Close, Leeds. Ask brackwater again tomorrow.” Name and phone were masked; “Rowan Close” became a second PERSON and the lower-case surname slipped through. Newer versions may differ.

Regex, Presidio or something else?

Do the requests contain names or addresses written in sentences?

Is nearly all of that text in English?

Will someone on your team own thresholds, recognisers and upgrades?

The regex Swapper is enough. Add a Luhn check for cards and a pattern for each ID number you handle, and test it on your real formats.
Presidio after the regex, with a threshold tuned on invented samples and a test sentence rerun on every upgrade.
Presidio needs a model and recognisers per language before it helps. Budget for that, or compare ready-made tools in our survey of software that redacts PII.
A detector nobody tunes drifts. A proxy maintained by someone else, covered in the last section, is the safer bet; if the worry is the provider itself, weigh a private LLM against a privacy proxy.

Our own sorting, based on Presidio's documentation on languages and its no-guarantee notice.

Presidio’s README warns that it can’t guarantee to find all sensitive information, and the default set-up is English only: every other language needs its own model and recognisers. Our look at Presidio and what to use instead goes deeper.

On English traffic, with someone paid to tune it, I’d run Presidio as a second layer after the regex, never as the only one, and rerun that test sentence after every upgrade.

Restoring placeholders in a streamed reply

With a whole response, swap.unmask(text) is the end of it. Streaming hands you text in fragments, and a placeholder can be cut anywhere: [EM in one chunk, AIL_1] in the next. Unmask each chunk alone and neither half matches, so hold back anything after an unclosed bracket:

def restore_stream(pieces, swapper, longest=24):
    """Yield restored text, holding back anything that may be half a placeholder."""
    held = ""

    for piece in pieces:
        held += piece
        cut = held.rfind("[")
        if cut != -1 and "]" not in held[cut:] and len(held) - cut <= longest:
            ready, held = held[:cut], held[cut:]
        else:
            ready, held = held, ""
        yield swapper.unmask(ready)
    yield swapper.unmask(held)


stream = client.chat.completions.create(model="gpt-5.5", messages=masked, stream=True)
pieces = (chunk.choices[0].delta.content or "" for chunk in stream if chunk.choices)
for text in restore_stream(pieces, swap):
    print(text, end="", flush=True)

The if chunk.choices guard matters if you ask for usage: OpenAI then sends a final chunk with no choices. In Node with the Responses API, the same idea sits inside the event loop. Save it as an .mjs file so top-level await works, and keep store: false if you don’t need the state:

import OpenAI from "openai";

const client = new OpenAI();
const back = { "[EMAIL_1]": "o.brackwater@example.org" }; // built when you masked the input
const unmask = (s) => s.replace(/\[[A-Z_]+_\d+\]/g, (t) => back[t] ?? t);

const stream = await client.responses.create({
  model: "gpt-5.5",
  input: "Write two lines confirming the payment to [EMAIL_1].",
  store: false,
  stream: true,
});

let held = "";
for await (const event of stream) {
  if (event.type !== "response.output_text.delta") continue;
  held += event.delta;
  const cut = held.lastIndexOf("[");
  const open = cut !== -1 && !held.slice(cut).includes("]") && held.length - cut <= 24;
  process.stdout.write(unmask(open ? held.slice(0, cut) : held));
  held = open ? held.slice(cut) : "";
}
process.stdout.write(unmask(held) + "\n");

Keep the placeholder table in memory or in a store with its own access rules, never in a log. Under the GDPR (EU and UK), that table is what makes this pseudonymisation rather than anonymisation, a line our guide to de-identified and anonymised data draws. I’d delete it when the conversation closes.

Three deltas, one placeholder cut in half

What the loop holds back and what the user sees at each step.

Delta arrivesHeld backShown to the user
Hello, we have sent it to [EMA[EMAHello, we have sent it to
IL_1[EMAIL_1(nothing yet)
]. Thanks.(nothing)o.brackwater@example.org. Thanks.

Invented deltas fed through the Node loop above, with a local stand-in for the API instead of OpenAI (Node 24). The 24-character cap stops a stray bracket from holding text back for ever.

Tool calls: real arguments for your code, tags for the model

Function calling turns the loop inside out. The model writes arguments containing placeholders; your function needs the real values; the result goes back to the model and must be masked again. OpenAI returns Chat Completions arguments as a JSON string, so parse first, then restore, then run:

import json

reply = client.chat.completions.create(model="gpt-5.5", messages=masked, tools=tools)
msg = reply.choices[0].message
masked.append(msg.model_dump(exclude_none=True))  # placeholders only: safe to resend

for call in msg.tool_calls or []:
    args = swap.walk(json.loads(call.function.arguments), swap.unmask)  # real values
    result = find_invoice(**args)  # your own function
    masked.append({
        "role": "tool",
        "tool_call_id": call.id,
        "content": swap.mask(json.dumps(result)),  # masked again on the way back
    })

When streaming, never restore half-built arguments. Collect every fragment until the call is complete (response.function_call_arguments.done in the Responses API), parse, and only then unmask. With Claude the arguments arrive as input_json_delta pieces of a tool_use block, and the same rule holds.

One gap remains: the regex won’t see a name inside the CRM row your tool returns. I’m comfortable with tools that return IDs and figures; free text in a tool result should be the exception. LiteLLM users face the same question; what its Presidio guardrail restores and what it leaves covers their side.

The tool round trip in each API

Where the arguments arrive and where the masked result goes.

Arguments: message.tool_calls[].function.arguments, a JSON string. Streamed as string fragments.

Result back: a message with role: "tool" and the matching tool_call_id.

Arguments: a function_call output item with call_id and JSON-encoded arguments. Streamed as response.function_call_arguments.delta events, closed by .done.

Result back: a function_call_output item with the same call_id.

Arguments: a tool_use content block whose input is an object. Streamed as input_json_delta events carrying partial_json.

Result back: a tool_result block in the next user message.

From OpenAI's function calling guide and Chat Completions reference, the openai-python streaming helpers, and Anthropic's streaming and tool-call documentation, accessed 10 November 2026.

Keeping prompts out of SDK logs and traces

A careful masking layer can still leak through logs. Turn on OPENAI_LOG=debug in Python and the SDK logs the request options, message bodies included; we checked _base_client.py in openai-python 2.48.0, and later releases may change it. The Node SDK’s README says it summarises bodies by format and length instead of printing them.

Mask before the SDK and its log holds placeholders. Put the masking in a proxy instead and your app’s debug log still holds the originals, so I’d keep that log level off in production either way. HTTP middleware, APM traces and error reporters capture bodies too; check which side of the masking each sits on.

A self-hosted proxy writes logs of its own; deploying a gateway with Docker, step by step shows where they end up. Then plant invented canary values in the system prompt, an earlier turn, a tool result and a tool argument. Assert that none reach a staging stand-in for the provider and all come back.

Before an API integration with personal data goes live

Tick what is already true.

0 of 6 in place

Not ready: at least one copy of the original text is still being written somewhere.

Our list. The SDK behaviour is from the openai-python source and the openai-node README; store from OpenAI's data controls page.

The placeholder [EMAIL_1] cut into two streamed chunks; restoring PII in a streamed reply needs a small buffer so it still matches

When to hand the swap to a proxy

For one script with one obvious field, I’d keep the code above. A third service needs its own walker, table, stream buffer, tool handling and canary test, and a new script calling OpenAI directly skips it all. That logic usually moves into an AI gateway, which we explain from the ground up.

Nonimo Gateway handles the swap from a container you install inside your own network. It speaks Chat Completions and Responses, streaming and function calling included, plus Claude’s Messages API. Your services change only the base_url. By default a request isn’t sent if detection fails, and the log counts data types without keeping values.

None of this covers staff pasting client files into ChatGPT in the browser; the Gateway never sees that traffic. Nonimo for organisations is built for that side.

Masking in your code or in front of it

Keep the code in this guide

  • One service, one or two fields that carry personal data
  • Fixed formats only, or English text with Presidio tuned by your team
  • No streaming, or one stream handler you already test
  • Someone owns the canary test

Point the SDKs at a proxy

  • Several services or scripts call OpenAI or Claude
  • Streaming chat, agents with tools, or both
  • Names in free text, and ID numbers from more than one country
  • A DPO wants a log of what went out, without the data in it

Our dividing line, for code that calls an API. If someone has already pasted client data into a chatbot, start with whether that counts as a data breach.

Sources

Accessed 10 November 2026.

Common questions

How long does OpenAI keep API prompts?

Up to 30 days in abuse monitoring logs, by default. Those logs can hold prompts and responses. Responses API calls also keep application state for 30 days unless you send store: false.

Does store: false stop OpenAI keeping my prompts?

No, it only switches off stored application state. The abuse monitoring logs still apply. Excluding content from those needs Zero Data Retention or Modified Abuse Monitoring, which OpenAI approves case by case.

Can I send a user's email address as safety_identifier?

No, hash it first. OpenAI's safety guide asks you to hash the username or email so nothing identifying reaches it. For visitors who aren't signed in, a session ID will do.

Can a regular expression redact PII before an OpenAI call?

Only fixed formats such as emails, card numbers and phone numbers with a country code. A regex can't tell a surname from a street name, so names need a model, a client list or both.

Does OPENAI_LOG=debug write prompts to my logs?

In the Python SDK, yes. At debug level it logs the request options, message bodies included. The Node SDK's documentation says it only summarises request bodies by format and length.

Is a prompt with placeholders anonymous under the GDPR?

No, not while you keep the table that maps placeholders back. Recital 26 treats pseudonymised data that can be re-linked to someone as information about an identifiable person.

Can Presidio find names before I call the OpenAI API?

Yes, its PERSON entity uses a spaCy model, English by default. Its own repository warns there is no guarantee it finds all sensitive information, so test it on text like yours.