# PII redaction

**The problem.** Call transcripts end up in a CRM, a data warehouse, an LLM prompt. If they contain card numbers,
phone numbers or names, all of those systems now hold personal data, with the compliance work that comes with it.
PII redaction removes that data from the final transcripts before you receive them.

**Where it works.** Every model.

> **Use the SDK.** The Gladia SDKs stream the audio, keep it until the API has transcribed it, and reconnect after a network drop
without losing a word. Most integration issues come from hand-written WebSocket clients: start from the SDK
examples below.

## Step by step

### 1. Turn it on

```json
{ "pii": true }
```

By default, the `pii` preset is redacted: names, contact details, identity, card and account numbers, dates of
birth and the like.

### 2. Start a session with it

Each program streams a 16-bit WAV file and prints what comes back. Put your API key in the `GLADIA_API_KEY` environment variable, then run `python pii.py meeting.wav`.

**Python SDK**

```python title="pii.py"
# pip install gladiaio-sdk requests
import os
import sys
import threading
import wave

import requests
from gladiaio_sdk import GladiaClient, LiveV2ConnectSessionOptions

API_URL = os.environ.get("GLADIA_API_URL", "https://eu1.api.gladia.io")
API_KEY = os.environ["GLADIA_API_KEY"]

with wave.open(sys.argv[1]) as wav:  # a 16-bit PCM WAV file
    rate, channels = wav.getframerate(), wav.getnchannels()
    audio = wav.readframes(wav.getnframes())

config = {
    "encoding": "wav/pcm",
    "sample_rate": rate,
    "bit_depth": 16,
    "channels": channels,
    "model": "solaria-3",
    "language_config": {
        "languages": [
            "en"
        ]
    },
    "numerals": True,
    "pii": {
        "entities": [
            "pii",
            "pci"
        ],
        "mode": "marker"
    }
}

client = GladiaClient(api_key=API_KEY, api_url=API_URL)
# `pii` is a Gladia Enterprise option the Python SDK does not send yet
# (gladiaio/sdk#61): create the session over HTTP, then let the SDK stream it.
created = requests.post(f"{API_URL}/v2/live", headers={"x-gladia-key": API_KEY}, json=config, timeout=10)
created.raise_for_status()
info = created.json()
session = client.live_v2().connect_session(LiveV2ConnectSessionOptions(id=info["id"], url=info["url"]))
ended, errors = threading.Event(), []

@session.on("message")
def on_message(message):
    if message.type == "transcript" and message.data.is_final:
        print(message.data.utterance.text)

def on_error(error):
    if "Unsupported websocket message type" not in str(error):  # pii messages: gladiaio/sdk#61
        errors.append(error)

session.on("error", on_error)
session.once("ended", lambda _: ended.set())

step = rate * channels * 2 // 10  # 100 ms of audio per chunk
for start in range(0, len(audio), step):
    session.send_audio(audio[start : start + step])  # live audio: send each chunk as it is captured
session.stop_recording()

ended.wait(timeout=60)
sys.exit(1 if errors or not ended.is_set() else 0)
```

**JavaScript SDK**

```js title="pii.mjs"
// npm install @gladiaio/sdk
import { readFileSync } from "node:fs";
import { GladiaClient } from "@gladiaio/sdk";

const { sampleRate, channels, pcm } = readWav(process.argv[2]); // a 16-bit PCM WAV file

const config = {
  encoding: "wav/pcm",
  sample_rate: sampleRate,
  bit_depth: 16,
  channels,
  model: "solaria-3",
  language_config: {
    languages: [
      "en"
    ]
  },
  numerals: true,
  pii: {
    entities: [
      "pii",
      "pci"
    ],
    mode: "marker"
  }
};

const client = new GladiaClient({
  apiKey: process.env.GLADIA_API_KEY,
  apiUrl: process.env.GLADIA_API_URL ?? "https://eu1.api.gladia.io",
});
// `pii` is a Gladia Enterprise option not in the SDK's types yet: the SDK sends it as is.
const session = client.liveV2().startSession(config);

session.on("message", (message) => {
  if (message.type === "transcript" && message.data.is_final) console.log(message.data.utterance.text);
  if (message.type === "pii") {
    for (const item of message.data.results) console.log(`  redacted ${item.entity_type} #${item.id}`);
  }
});
session.on("error", (error) => {
  console.error(error);
  process.exitCode = 1;
});
session.once("ended", () => process.exit());

const step = (sampleRate * channels * 2) / 10; // 100 ms of audio per chunk
for (let start = 0; start < pcm.length; start += step) {
  session.sendAudio(pcm.subarray(start, start + step)); // live audio: send each chunk as it is captured
}
session.stopRecording();

function readWav(path) {
  const file = readFileSync(path);
  let fmt, pcm;
  for (let pos = 12; pos < file.length; ) {
    const id = file.toString("ascii", pos, pos + 4);
    const size = file.readUInt32LE(pos + 4);
    if (id === "fmt ") fmt = { channels: file.readUInt16LE(pos + 10), sampleRate: file.readUInt32LE(pos + 12) };
    if (id === "data") pcm = file.subarray(pos + 8, pos + 8 + size);
    pos += 8 + size + (size % 2);
  }
  return { ...fmt, pcm };
}
```

**HTTP**

Create the session, then stream its audio to the returned `url` ([Live WebSocket](/api-reference/v2/live/websocket/)):

```bash
curl -X POST https://eu1.api.gladia.io/v2/live \
  -H "x-gladia-key: $GLADIA_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "encoding": "wav/pcm",
    "sample_rate": 16000,
    "bit_depth": 16,
    "channels": 1,
    "model": "solaria-3",
    "language_config": {
      "languages": [
        "en"
      ]
    },
    "numerals": true,
    "pii": {
      "entities": [
        "pii",
        "pci"
      ],
      "mode": "marker"
    }
  }'
```

### 3. Read the redacted transcript

```json
{
  "type": "transcript",
  "data": {
    "is_final": true,
    "utterance": {
      "text": "My card number is <pii:credit_card id=\"1\"/> and my email <pii:email_address id=\"1\"/>.",
      "words": [{ "word": "", "start": 3.1, "end": 3.4, "confidence": 0.95 }]
    }
  }
}
```

Right after it, a `pii` message lists what was redacted (WebSocket only):

```json
{
  "type": "pii",
  "error": null,
  "data": {
    "utterance_id": "00_00000005",
    "results": [
      { "entity_type": "credit_card", "id": 1, "action": "marker", "char_start": 18, "char_end": 45 },
      { "entity_type": "email_address", "id": 1, "action": "marker", "char_start": 59, "char_end": 88 }
    ]
  }
}
```

## Options

```json
{ "pii": { "entities": ["pii", "pci"], "mode": "mask", "mask_character": "#" } }
```

| option | default | what it does |
|---|---|---|
| `entities` | `pii` | What to redact: presets (`pii`, `pci`, `phi`, `contact`, `financial`, `name`, `location`…), types, or `{"enable": [...], "disable": [...]}`. |
| `mode` | `marker` | `marker`: an element in place of the data (`<pii:phone_number id="1"/>`); `mask`: mask characters inside the element; `tag`: the data kept inside the element (tagging only, nothing hidden). |
| `threshold` | `0.5` | Confidence under which a span is not redacted. Lower: more redacted, more false positives. |
| `mask_character` | `*` | The character of `mask` mode. |
| `coreference` | `heuristics` | How mentions of the same data get the same `id`: `heuristics`, `exact` (same text only) or `off`. |
| `return_entities` | `false` | Also return the redacted text in the `pii` message (`text`), for your own vault. |

## Limits and gotchas

- **No partial transcripts.** Partials are never redacted, so with redaction on none is sent, on the WebSocket or
  to callbacks, even with `receive_partial_transcripts: true`. Plan your live display on finals. (`mode: tag`
  hides nothing and keeps partials.)
- **Words lose their text.** A redacted final's words keep their times and speakers, their `word` is empty, and
  `text_raw` is not sent.
- **The audio is not redacted.** If your account records its audio, the recording still contains what was said.
  Ask for recording to be turned off if that matters to you ([Audio recordings](/data/audio-recordings/)).
- **`custom_metadata` is not redacted.** Do not put personal data in it.
- **Not with translation.** A session with both `pii` and `translation` is refused when it starts (WebSocket `1011`,
  `rejected: ...`).
- **Finals may come a little later** while a number or an email is still being dictated, so it is redacted whole.
- **Detection is statistical.** A rare format, a name spelled letter by letter, or poor audio can slip through, and
  ordinary words can be redacted. Tune `entities` and `threshold` on your own calls before production.
- **`return_entities` brings the data back.** Use it only to store the values somewhere safer than your
  transcripts.
- **The `pii` message is not stored** with the session's result and is not sent to callbacks.

## Troubleshooting

| symptom | cause | fix |
|---|---|---|
| no partial transcripts anymore | redaction on | expected; use finals, or `mode: tag` if you only need tagging |
| WebSocket closed `1011` "rejected: … pii redaction" | `translation` also on | drop one of them |
| a card number is not redacted | spoken digit by digit with long pauses, or another preset | keep `numerals` and the number hold on, add `pci` |
| ordinary words are redacted | low confidence spans | raise `threshold`, narrow `entities` |
