Skip to content
RasaGet a free licence
Guides for AI teams

Guide · AI product engineer

How to redact spoken card numbers in a Rasa voice agent

If callers read card numbers to your Rasa voice agent, remove them in the speech-to-text engine before Rasa records the transcript.

by Rod Rivera

About 8 minutes

  • 2 of 2

    card-reading test calls that kept card details, with Rasa’s built-in Deepgram engine

  • 0 of 4

    card-reading test calls that kept card details, with the redacting engine

Source: Scripted test calls with synthetic callers to a sample Rasa voice agent, 30 September 2026
Key takeaways (3)
  • A refusal in the prompt comes too late. Rasa records what the caller said before the model reads it.
  • Remove card details in a small subclass of the speech-to-text engine, before anything is recorded.
  • Make the payment tools refuse to start when redaction is not running or the record holds card digits.

A caller rings your voice agent to pay a bill and starts reading out their card number. Your prompt says never to take card details, and the agent obeys. It asks the caller to stop and offers a secure payment link instead.

But the card number is already stored. Here is the same caller turn from two scripted test calls to a sample payment agent. The caller’s line, the voice and the model were the same. Only the speech-to-text engine changed:

The same caller turn in the conversation record, with each speech-to-text engine

Avoid: Rasa’s built-in Deepgram engine

user  My card number is four one one one, one one one, one one one, one one one one.
bot   I can’t take card details on this call, so please don’t read any more card information. I can send a secure payment link by text or email.

The refusal is right, and the digits sit one line above it.

Prefer: The redacting engine

user  My card number is [card details removed].
bot   I can’t take card details on this call, so please don’t read any more of them. I can send a secure payment link by text or email.

The same refusal, nearly word for word. The record never held the number.

These are the user and bot events from each call’s tracker, text unedited. The scripted caller read four groups of four digits. The transcript held “one one one” in two of the groups.

The fix is to remove card details inside the speech-to-text engine, before Rasa writes the transcript anywhere. That matters if your payments team needs card numbers kept out of conversation records and model requests. A rule in the prompt cannot do that for you.

What you need:

  • The sample is the companion project examples/mantle-voice-payment-boundary-gpt at commit 4aa0c44: a Rasa voice agent taking order payments for a fictional shop. It pins rasa-pro 3.21.0.dev5 and runs on GPT-5.5, with Deepgram Flux for speech-to-text and Rime for speech.
  • The offline tests need uv, but no licence or keys.
  • Live calls are billed. In the project folder, make env copies .env.example to .env. Fill in RASA_LICENSE, OPENAI_API_KEY, DEEPGRAM_API_KEY and RIME_API_KEY there.
  • Removing card details from a record is not a payment-security certification. Deepgram still receives the caller’s audio.

Why the agent’s refusal comes too late

On a voice call, the order of events decides what gets stored. The diagram shows one caller turn. The tracker is Rasa’s record of the conversation, and each model request is built from it. Memory discovery is a separate model call that reads the conversation and stores facts from it.

The prompt rule only acts when the model reads the request. By then the record already holds the digits.

Deepgram Flux words heard speech-to-text engine engine_event_to_asr_event tracker user event model request history memory discovery system.__discovered__ GPT-5.5 refuses in words
  1. The redaction goes here, in a subclass of Rasa’s Deepgram engine. Everything to its right gets the placeholder.
  2. The first write: Rasa records the transcript as the caller’s message.
  3. The prompt rule acts here, after the write.
  4. Memory discovery reads the same turn.
FigureWhere a spoken card number is written, and where the redaction goes

The same ordering problem applies to any component that saves or forwards text before your policy layer runs, such as a log, a trace or a memory store. This guide covers the tracker, model requests and memory.

In the two test calls with the built-in engine, the agent never repeated a digit. Even so, card details reached three places:

  • the tracker, in both calls;
  • six model requests in each call, including requests in later turns;
  • memory, in the call where the caller read an expiry date and a security code.

In that second call, memory discovery stored this fact:

{
  "key": "card_expiry_and_cvv_disclosed",
  "value": "expiry 04/29; security code 731",
  "description": "Customer disclosed payment card expiry date and CVV during the call despite secure-payment restrictions.",
  "turn_idx": 6,
  "confidence": 0.98,
  "pii": true,
  "captured_by_skill": "pay_order_balance",
  "potential_mappings": []
}

That is one fact from the value list of a memory_set event in the call’s tracker, trimmed: the list holds two facts, and the event adds metadata. Keys and values are unedited, line breaks added. Memory discovery did its job. It kept what the caller said and marked it as personal data.

How to filter the transcript before Rasa records it

Rasa picks the speech-to-text engine from the asr setting in integrations.yml. A built-in name such as deepgram loads Rasa’s own engine. Any other name is loaded as a Python class path, which lets you supply your own engine.

The redacting engine is a subclass of Rasa’s Deepgram engine, DeepgramASR. It keeps everything the built-in engine does: the connection, the settings and the turn handling. It changes two methods. Do these three steps in your own project.

Add the redaction function

Copy lib/pci.py from the sample into your project. It is plain Python with no Rasa imports. Its redact function takes a transcript and returns the cleaned text and the number of removals. The next section explains what it removes.

Add the engine subclass

Create engines/deepgram_pci.py. Below are two excerpts from the sample’s file, without its docstring. First, the imports:

from __future__ import annotations

import os
from typing import Any, List, Optional

import structlog

from rasa.core.channels.voice_stream.asr.asr_event import NewTranscript, UserIsSpeaking
from rasa.core.channels.voice_stream.asr.deepgram import DeepgramASR

from lib.pci import redact
from lib.payments import REDACTION_FLAG, REDACTION_VALUE

structlogger = structlog.get_logger()

In the sample, the two constants are:

REDACTION_FLAG = "WILLOWSHOP_PCI_TRANSCRIPT_REDACTION"
REDACTION_VALUE = "on"

Then add the class:

engines/deepgram_pci.py, excerpt
class DeepgramRedactingCardDetails(DeepgramASR):
    """``DeepgramASR`` whose transcripts never carry card numbers, security codes or expiry dates."""

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        super().__init__(*args, **kwargs)
        self._after_removal = False
        self.removals = 0
        os.environ[REDACTION_FLAG] = REDACTION_VALUE

    def engine_event_to_asr_event(self, e: Any) -> Optional[Any]:
        event = super().engine_event_to_asr_event(e)
        if isinstance(event, NewTranscript) and event.text:
            text, removed = redact(event.text, continuation=self._after_removal)
            self._after_removal = removed > 0
            if removed:
                self.removals += removed
                structlogger.info("willowshop.pci_redaction", kind="final", spans=removed,
                                  call_removals=self.removals)
            return NewTranscript(text=text)
        if isinstance(event, UserIsSpeaking) and event.text:
            text, removed = redact(event.text, continuation=self._after_removal)
            return UserIsSpeaking(text=text) if removed else event
        return event
  1. The class extends Rasa’s DeepgramASR, imported from rasa.core.channels.voice_stream.asr.deepgram.
  2. Creating the engine sets a flag in this server process. The payment tools read it later.
  3. Rasa’s own parsing runs first, so the subclass sees the transcript Rasa would have recorded.
  4. The final transcript goes through redact. The continuation setting is on when the previous transcript had a removal, for a card read in two breaths.
  5. The log line carries counts only, never the text.
  6. Partial transcripts, sent while the caller is still speaking, are redacted too.

Point the voice channel at the subclass

In integrations.yml, change the name under asr to the class path. Do this in every channel that takes voice. The sample sets it under both browser_audio and inspector. Only the name line changes. These excerpts show one channel, with its other settings left out:

Before: built-in engine
channels:
  browser_audio:
    asr:
      name: deepgram
      language_map:
        en:
          model: flux-general-en
      eot_threshold: 0.7
      eot_timeout_ms: 5000
After: redacting engine
channels:
  browser_audio:
    asr:
      name: engines.deepgram_pci.DeepgramRedactingCardDetails
      language_map:
        en:
          model: flux-general-en
      eot_threshold: 0.7
      eot_timeout_ms: 5000

The test calls with the built-in engine used the “Before” form, with everything else the same. These Flux settings match the banking build in Why a Rasa voice agent on Deepgram Flux can miss a short yes.

One override is enough: in the pinned version, Rasa’s base engine passes every message from Deepgram through engine_event_to_asr_event.

Decide what to remove

Callers do not read a card number the way a pattern expects. They say “four one one one” or “forty one eleven”. They mix digits and words, add “um”, or pause halfway. The redact function reads number words and digits as one run, skipping fillers and separators. These settings at the top of lib/pci.py control what it removes:

PLACEHOLDER = "[card details removed]"
MIN_DIGITS_ANYWHERE = 8
MIN_DIGITS_AFTER_CUE = 3
CUE_WINDOW_WORDS = 5
# A card read in two breaths goes on in groups of four (or a 3-digit code).
# A 5-digit order number in the next breath is kept.
CONTINUATION_GROUP_DIGITS = (3, 4)

A run of digits is removed when any of these rules applies:

  • It has 8 or more digits, wherever it is.
  • It has 3 or more digits and comes within five words after a card cue. Cues are phrases such as “card number”, “security code”, “CVV” or “expires”. This catches an expiry date or a security code said on its own.
  • It has 3 or 4 digits and comes in the transcript right after one that had a removal. This catches the second half of a card read in two breaths.

The rules remove more than card numbers, on purpose. A phone number of eight or more digits goes too, and so does “card is 731”. The module’s own comment states the trade: “a false removal costs the caller one ‘please use the link’, a missed one puts a card number in the conversation record.”

The cue rule worked in a live test call. With the redacting engine, the caller who read an expiry date and a security code reached the tracker as:

Let the card expires [card details removed] and the security code is [card details removed]. Can you just put it through?

Neither value is long enough for the 8-digit rule. Both followed a cue.

The sample’s tests show what the rules catch, and that order numbers and prices are kept. To see what gets through, we ran spoken forms through redact, each on its own, as the first turn of a call:

What the caller said, as textResultWhy
my card number is double four one one, triple one one, …removed“double” and “triple” repeat the next digit
it expires oh four twenty nineremoved“oh” reads as zero, after the cue “expires”
four one one one, one one one, one one one, one one one oneremovedtwo lost digits still leave fourteen
call me on 555 010 4242removed (a false removal)ten digits, over the 8-digit rule
four won won won, one one one one, …first group kept, rest gone“won” is not a number word
it’s four one one onekeptfour digits with no cue
four one one one and then one one one onekept“and then” splits it into two short runs
my card is four thousand one hundred elevenkept“thousand” and “hundred” are not read
my card ends in 731keptbare “card” is not a cue

A card read one group per turn, with no cue, gets through whole. The two-breaths rule only applies after a removal. If the first group is kept, the later groups are kept too.

Show the probe and its output

Run with plain python3 from the sample’s project folder, at companion commit 4aa0c44, on 2 October 2026. It imports only lib.pci.

"""Spoken forms against lib.pci.redact at companion 4aa0c44. Run from the project directory."""
import sys
sys.path.insert(0, ".")
from lib.pci import redact

CASES = [
    "my card number is double four one one, triple one one, one one one one, one one one one",
    "my card number is four one one one, one one one one, one one one one, one one one one",
    "it expires oh four twenty nine",
    "the code on the back is seven three one",
    "four one one one, one one one, one one one, one one one one",
    "four one one one one one one",
    "it's four one one one",
    "four one one one and then one one one one",
    "my card is four thousand one hundred eleven",
    "for one one one, one one one one, one one one one, one one one one",
    "four won won won, one one one one, one one one one, one one one one",
    "seven three one",
    "I'll text you at 555 0142",
    "call me on 555 010 4242",
    "card is 731",
    "my card ends in 731",
]
for text in CASES:
    out, n = redact(text)
    print(f"{n} | {text}\n  -> {out}")
1 | my card number is double four one one, triple one one, one one one one, one one one one
  -> my card number is [card details removed]
1 | my card number is four one one one, one one one one, one one one one, one one one one
  -> my card number is [card details removed]
1 | it expires oh four twenty nine
  -> it expires [card details removed]
1 | the code on the back is seven three one
  -> the code on the back is [card details removed]
1 | four one one one, one one one, one one one, one one one one
  -> [card details removed]
0 | four one one one one one one
  -> four one one one one one one
0 | it's four one one one
  -> it's four one one one
0 | four one one one and then one one one one
  -> four one one one and then one one one one
0 | my card is four thousand one hundred eleven
  -> my card is four thousand one hundred eleven
1 | for one one one, one one one one, one one one one, one one one one
  -> for [card details removed]
1 | four won won won, one one one one, one one one one, one one one one
  -> four won won won, [card details removed]
0 | seven three one
  -> seven three one
0 | I'll text you at 555 0142
  -> I'll text you at 555 0142
1 | call me on 555 010 4242
  -> call me on [card details removed]
1 | card is 731
  -> card is [card details removed]
0 | my card ends in 731
  -> my card ends in 731

Refuse to start a payment if the record is not clean

Redaction is one layer. The payment tools add a second. In the sample, no tool takes card details. There is no card, expiry or security-code parameter, and payment happens only through a link sent by text or email.

Before a payment starts, both payment tools pass two things to one check: the caller’s messages from the tracker, and the flag the engine set. This excerpt is the check from the sample’s lib/payments.py:

def _request_facts(channel: Optional[str], user_texts: list[str], redaction_on: bool,
                   *arguments: Any) -> dict:
    return {
        "secure_channel_selected": channel in APPROVED_CHANNELS and not _carries_card_data(*arguments),
        "recorder_excluded": redaction_on and not record_holds_secret(user_texts),
    }

The payment can start only when both facts are true. recorder_excluded needs two things:

  • The redacting engine was created in this server process, so the flag is set.
  • No caller message in this call’s tracker holds anything redact would remove.

If either fails, both tools return blocked with the reason recording_not_excluded, and nothing is sent.

The two parts answer different questions. The flag says whether redaction is running on this server. The tracker scan says whether card details reached this call’s record. The scan checks each message on its own, without the two-breaths rule. So a lone four-digit group in a later turn passes it.

In your own project, call the same kind of check at the start of every tool that starts a payment. With the built-in engine, the check blocked the payment in both test calls. This is what the caller heard after asking for a text link instead:

bot   I can’t start a payment link on this call now. You can pay from the order page in the Willow Shop app or website, under Orders, then Pay balance.

That is the bot event from the tracker, unedited.

Check the fix works

Start with the offline tests. In the sample’s project folder, make install installs the pinned rasa-pro into .venv. Then make proof runs all the tests in tests/, with no licence, model or network. On 2 October 2026 it ran 48 tests, and they passed.

Five of those tests check the engine. Three of them feed it Deepgram Flux messages as Rasa’s connection would. Here are two of the five:

$ uv run --locked python -m unittest tests.test_pci_engine.RedactingEngineTests.test_card_number_never_reaches_rasa tests.test_pci_engine.RedactingEngineTests.test_creating_it_marks_the_recorder_excluded -v
test_card_number_never_reaches_rasa (tests.test_pci_engine.RedactingEngineTests.test_card_number_never_reaches_rasa) ...
2026-10-02 00:37:41 [info     ] willowshop.pci_redaction       call_removals=1 kind=final spans=1
ok
test_creating_it_marks_the_recorder_excluded (tests.test_pci_engine.RedactingEngineTests.test_creating_it_marks_the_recorder_excluded) ...
ok

----------------------------------------------------------------------
Ran 2 tests in 0.001s

OK

That output is trimmed: the two asr.engine.initialized log lines, one per test, are left out. It was run offline on 2 October 2026.

The first test sends “my card number is 4111 1111 1111 1111”. It checks that both the partial and the final transcript read “my card number is [card details removed]”. The second checks that creating the engine sets the flag. Neither test covers what a model does.

Next, check what the model was sent. The tracker scan shows what the record holds, not what reached the model provider. The sample registers a hook in hooks.py that runs the same detector over every message of every model request. It logs counts only and changes nothing in the request. This excerpt is the hook without its imports and helpers:

@on_model_request()
async def scan_model_request(payload: ModelRequestPayload) -> None:
    # The conversation id itself ends in a run-date stamp (8 digits) and Mantle
    # puts it in the prompt; it is not card data.
    texts = [(str(m.get("role")), _ENGINE_DATETIME_RE.sub("<datetime>",
              _text(m).replace(payload.sender_id, "<conversation id>"))) for m in payload.messages]
    flagged = [role for role, t in texts if contains_payment_secret(t)]
    log.info("willowshop.model_request_scan", sender_id=payload.sender_id, messages=len(texts),
             messages_with_card_details=len(flagged), card_details_roles=sorted(set(flagged)),
             messages_with_placeholder=sum(1 for _, t in texts if PLACEHOLDER in t))

Here is what the live test calls showed, with each engine:

Where card details wereBuilt-in engine (2 card calls)Redacting engine
Call records holding card details2 of 20 of 4 card calls, 0 of all 16 calls
Model requests carrying the card turn6 in each call0 of 17, in a separate two-call rescan
Card details in memoryone fact, marked as personalnone
Payment stepblocked in both callslink sent in all four card calls

With your keys in .env, make inspect lets you talk to the agent in the Rasa Inspector through your microphone. Those calls are billed.

Trade-offs

  • Over-removal: a long phone number goes, and so does a short number after a cue. The caller is asked to use the link.
  • A blocked call: once card details reach the record, the payment tools refuse for the rest of the call. The caller is sent to the app or website.
  • A missing engine: a server without the redacting engine refuses every payment, even when the record is clean. That is by design.
  • Beta status: rerun the offline engine tests after each Rasa upgrade.

Limits

  • The test calls were scripted, with synthetic voices reading the card networks’ published test numbers. They used one model on one day, and the payment processor was simulated. Willow Shop and its customers are fictional.
  • This is not a PCI DSS claim. Deepgram still receives the caller’s audio. This build did not test redaction on the speech service’s side.
  • The redaction misses the readings in the table above. The record check uses the same detector, so it misses them too.
  • Only Deepgram Flux on rasa-pro 3.21.0.dev5 was tested.