Bottom Linear Gradient  Lines image

Article

10 min

read

What Your AI Agent Should Never Hear: Card Numbers and CVVs

Some data shouldn't just stay out of memory. It shouldn't get in the room at all.

Aparna Mishra

Product Manager

What Your AI Agent Should Never Hear: Card numbers and CVVs

In this article

Share

Angular Gradient Image

Build it free.

Create a space and ship your first call flow in minutes.

Subscribe

Tags

Voice AI

AI Agents

Compliance & Security

Developer Tutorials

The card number nobody should have heard

In Part 1, we talked about data that wanders into your AI agent’s context uninvited. An account number tagging along on a tool response. An auth token that slipped in because the query happened to scoop it up on the way out. Annoying, a little risky, and very fixable!

Payment data is a different kind of guest. It doesn’t sneak in through the side door. It gets a personal invitation, a reserved seat, and someone to announce its name at the door.

It usually starts with a prompt that sounds perfectly sensible: collect the card number, the expiration date, and the CVV, then read the number back so the caller can confirm it. The agent, ever the obliging host, does exactly that. The caller recites sixteen digits. The model takes them in and reads them right back, one digit at a time, on a live call, like it’s calling out winning lottery numbers. Now those sixteen digits have made themselves at home in the model’s context, the call recording, the transcript, and every request your agent fires off to its LLM provider for the rest of the call.

So the prompt did exactly what it said but the real trouble is who it asked. The agent that was running the conversation is the last one who should be anywhere near the card.

Why the AI is the wrong place for a card number

Once a card number lands in the context window, it doesn’t leave when the conversation moves on. It stays put for the rest of the call. Every word the model says next gets generated with those sixteen digits sitting right there in the room. And models hallucinate. So a model that has seen a card number can cheerfully produce a slightly mangled version of it later, tucked into a summary or a confirmation line, with no intent at all.

Then there’s the paper trail. Everything the model touches gets written down somewhere: conversation logs, post-call summaries, the context you ship off to your LLM provider. You could go back and scrub all of it after the fact, but that runs into the same wall as Part 1. But cleaning the log only cleans the log.

So the fix is the same, too. Don’t let the data into the model’s context in the first place. For payments, that means the AI never hears the card at all.

Hearing the card number vs. handling the payment

Picture paying at a restaurant. Your server brings the card terminal to the table, hands it over, and politely steps back. You tap or insert your card and punch in your PIN. The terminal has a quick private chat with the bank. A few seconds later, the server gets exactly one piece of news: approved or declined.

The server ran the whole show. They knew what you ordered, what you owed, and when it was time to settle up. At no point did they need your card number or your PIN to do any of that. Their job was to know the bill got paid, not how it got paid.

Your AI agent should work the same way. The agent runs the conversation and decides when it’s time to pay. The platform collects the card. Your payment processor runs the charge. The agent gets the outcome and picks the conversation right back up.

That’s what SignalWire Markup Language (SWML) pay is for.

The wrong way to handle payments with AI

This is the version that feels fine until someone reads the logs:

- ai:
    prompt: |
      Collect the customer's credit card number, expiration date,  and CVV.
      Repeat the card number back to them for confirmation

- ai:
    prompt: |
      Collect the customer's credit card number, expiration date,  and CVV.
      Repeat the card number back to them for confirmation

- ai:
    prompt: |
      Collect the customer's credit card number, expiration date,  and CVV.
      Repeat the card number back to them for confirmation

- ai:
    prompt: |
      Collect the customer's credit card number, expiration date,  and CVV.
      Repeat the card number back to them for confirmation

And it works! The agent collects the card, reads it back, and the caller walks away happy. It also drops the full card number and CVV straight into the model’s context, then asks a language model to recite cardholder data out loud on a live call. No amount of redaction afterward makes that safe. By then, the card has already been heard.

The right way to handle payments with AI: agent handoff

Start with the prompt. The agent's job is to run the order and know when it's time to pay. It should never ask for card details, and it should know what to do if a caller starts reading them out anyway:

- ai:
    prompt:
      text: |
        You take pizza orders for Mario's Pizza. Help the caller build their
        order, then use calculate_total and read them the total.
        When they're ready to pay, call collect_payment.
        Never ask for card details out loud. If a caller starts reading a card
        number, stop them politely and explain they'll enter it on their keypad.
- ai:
    prompt:
      text: |
        You take pizza orders for Mario's Pizza. Help the caller build their
        order, then use calculate_total and read them the total.
        When they're ready to pay, call collect_payment.
        Never ask for card details out loud. If a caller starts reading a card
        number, stop them politely and explain they'll enter it on their keypad.
- ai:
    prompt:
      text: |
        You take pizza orders for Mario's Pizza. Help the caller build their
        order, then use calculate_total and read them the total.
        When they're ready to pay, call collect_payment.
        Never ask for card details out loud. If a caller starts reading a card
        number, stop them politely and explain they'll enter it on their keypad.
- ai:
    prompt:
      text: |
        You take pizza orders for Mario's Pizza. Help the caller build their
        order, then use calculate_total and read them the total.
        When they're ready to pay, call collect_payment.
        Never ask for card details out loud. If a caller starts reading a card
        number, stop them politely and explain they'll enter it on their keypad.

That last line is doing real work. Until the payment step kicks in, the agent is still listening. A caller who helpfully reads their card number out loud walks it right back into the model’s context. The prompt is your first line of defense.

Next up, pricing. A calculate_total function prices the order from your own menu, and the total goes into metadata, not just the response. The model sends item names. It never gets a say in prices:

import express from 'express';
import { FunctionResult } from '@signalwire/sdk';
const app = express();
app.use(express.json());
// Prices live on your server. The model only sends item names.
const MENU = { 'large pepperoni': 18.50, 'large cheese': 16.00, 'soda': 2.25 };
app.post('/swaig', (req, res) => {
  const { function: funcName, argument, meta_data } = req.body;
  // SWAIG wraps the values the AI collected: they live in argument.parsed[0]
  const args = argument?.parsed?.[0] ?? {};
  // Pricing: the model gets the total to read out, metadata keeps the source of truth
  if (funcName === 'calculate_total') {
    if (!Array.isArray(args.items) || args.items.length === 0) {
      return res.json({ response: "I didn't catch the order. What would you like?" });
    }
    const unknown = args.items.find(item => MENU[item.name] === undefined);
    if (unknown) {
      return res.json({ response: `Sorry, we don't have ${unknown.name}. Want something else?` });
    }
    const subtotal = args.items.reduce((sum, item) => sum + MENU[item.name], 0);
    const total = Math.round(subtotal * 1.08 * 100) / 100;
    const order = db.createOrder({ items: args.items.map(item => item.name), total });
    return res.json({
      response: `Your total is $${total.toFixed(2)} including tax.`,
      action: [
        {
          set_meta_data: {
            order_id:    order.id,
            order_total: total
          }
        }
      ]
    });
  }
  // collect_payment (below) goes here
  // Anything else: always reply, so the request never hangs
  return res.json({ response: "Sorry, I can't help with that one." });
});
import express from 'express';
import { FunctionResult } from '@signalwire/sdk';
const app = express();
app.use(express.json());
// Prices live on your server. The model only sends item names.
const MENU = { 'large pepperoni': 18.50, 'large cheese': 16.00, 'soda': 2.25 };
app.post('/swaig', (req, res) => {
  const { function: funcName, argument, meta_data } = req.body;
  // SWAIG wraps the values the AI collected: they live in argument.parsed[0]
  const args = argument?.parsed?.[0] ?? {};
  // Pricing: the model gets the total to read out, metadata keeps the source of truth
  if (funcName === 'calculate_total') {
    if (!Array.isArray(args.items) || args.items.length === 0) {
      return res.json({ response: "I didn't catch the order. What would you like?" });
    }
    const unknown = args.items.find(item => MENU[item.name] === undefined);
    if (unknown) {
      return res.json({ response: `Sorry, we don't have ${unknown.name}. Want something else?` });
    }
    const subtotal = args.items.reduce((sum, item) => sum + MENU[item.name], 0);
    const total = Math.round(subtotal * 1.08 * 100) / 100;
    const order = db.createOrder({ items: args.items.map(item => item.name), total });
    return res.json({
      response: `Your total is $${total.toFixed(2)} including tax.`,
      action: [
        {
          set_meta_data: {
            order_id:    order.id,
            order_total: total
          }
        }
      ]
    });
  }
  // collect_payment (below) goes here
  // Anything else: always reply, so the request never hangs
  return res.json({ response: "Sorry, I can't help with that one." });
});
import express from 'express';
import { FunctionResult } from '@signalwire/sdk';
const app = express();
app.use(express.json());
// Prices live on your server. The model only sends item names.
const MENU = { 'large pepperoni': 18.50, 'large cheese': 16.00, 'soda': 2.25 };
app.post('/swaig', (req, res) => {
  const { function: funcName, argument, meta_data } = req.body;
  // SWAIG wraps the values the AI collected: they live in argument.parsed[0]
  const args = argument?.parsed?.[0] ?? {};
  // Pricing: the model gets the total to read out, metadata keeps the source of truth
  if (funcName === 'calculate_total') {
    if (!Array.isArray(args.items) || args.items.length === 0) {
      return res.json({ response: "I didn't catch the order. What would you like?" });
    }
    const unknown = args.items.find(item => MENU[item.name] === undefined);
    if (unknown) {
      return res.json({ response: `Sorry, we don't have ${unknown.name}. Want something else?` });
    }
    const subtotal = args.items.reduce((sum, item) => sum + MENU[item.name], 0);
    const total = Math.round(subtotal * 1.08 * 100) / 100;
    const order = db.createOrder({ items: args.items.map(item => item.name), total });
    return res.json({
      response: `Your total is $${total.toFixed(2)} including tax.`,
      action: [
        {
          set_meta_data: {
            order_id:    order.id,
            order_total: total
          }
        }
      ]
    });
  }
  // collect_payment (below) goes here
  // Anything else: always reply, so the request never hangs
  return res.json({ response: "Sorry, I can't help with that one." });
});
import express from 'express';
import { FunctionResult } from '@signalwire/sdk';
const app = express();
app.use(express.json());
// Prices live on your server. The model only sends item names.
const MENU = { 'large pepperoni': 18.50, 'large cheese': 16.00, 'soda': 2.25 };
app.post('/swaig', (req, res) => {
  const { function: funcName, argument, meta_data } = req.body;
  // SWAIG wraps the values the AI collected: they live in argument.parsed[0]
  const args = argument?.parsed?.[0] ?? {};
  // Pricing: the model gets the total to read out, metadata keeps the source of truth
  if (funcName === 'calculate_total') {
    if (!Array.isArray(args.items) || args.items.length === 0) {
      return res.json({ response: "I didn't catch the order. What would you like?" });
    }
    const unknown = args.items.find(item => MENU[item.name] === undefined);
    if (unknown) {
      return res.json({ response: `Sorry, we don't have ${unknown.name}. Want something else?` });
    }
    const subtotal = args.items.reduce((sum, item) => sum + MENU[item.name], 0);
    const total = Math.round(subtotal * 1.08 * 100) / 100;
    const order = db.createOrder({ items: args.items.map(item => item.name), total });
    return res.json({
      response: `Your total is $${total.toFixed(2)} including tax.`,
      action: [
        {
          set_meta_data: {
            order_id:    order.id,
            order_total: total
          }
        }
      ]
    });
  }
  // collect_payment (below) goes here
  // Anything else: always reply, so the request never hangs
  return res.json({ response: "Sorry, I can't help with that one." });
});

When the caller is ready to pay, the agent calls collect_payment. That function hands the call over to pay, and it pulls the amount from metadata, not from whatever the model remembers:

// Payment: the model decides it's time to pay, the platform collects the card
if (funcName === 'collect_payment') {
  if (!meta_data?.order_total) {
    return res.json({ response: "Let's finish the order before we take payment." });
  }
  // true = the agent says this line out loud first, then pay starts
  const result = new FunctionResult(
    "Great. You'll enter your card details using your phone's keypad.",
    true
  ).executeSwml({
    version: '1.0.0',
    sections: {
      main: [
        {
          pay: {
            payment_connector_url: 'https://payments.example.com/connector',
            input:          'dtmf',
            payment_method: 'credit-card',
            charge_amount:  meta_data.order_total.toFixed(2),
            currency:       'usd',
            description:    `Order ${meta_data.order_id}`,
            max_attempts:   '2',
            security_code:  'true',
            postal_code:    'true',
            token_type:     'one-time'
          }
        },
        {
          // Runs after pay, so ${pay_result} holds this payment's outcome
          set: {
            ai_response: 'The payment status is ${pay_result}. If it is success, confirm the order and give a delivery estimate. If not, apologize and offer to try again or pay another way. Do not discuss card details.'
          }
        }
      ]
    }
  });
  return res.json(result.toDict());
}
// Payment: the model decides it's time to pay, the platform collects the card
if (funcName === 'collect_payment') {
  if (!meta_data?.order_total) {
    return res.json({ response: "Let's finish the order before we take payment." });
  }
  // true = the agent says this line out loud first, then pay starts
  const result = new FunctionResult(
    "Great. You'll enter your card details using your phone's keypad.",
    true
  ).executeSwml({
    version: '1.0.0',
    sections: {
      main: [
        {
          pay: {
            payment_connector_url: 'https://payments.example.com/connector',
            input:          'dtmf',
            payment_method: 'credit-card',
            charge_amount:  meta_data.order_total.toFixed(2),
            currency:       'usd',
            description:    `Order ${meta_data.order_id}`,
            max_attempts:   '2',
            security_code:  'true',
            postal_code:    'true',
            token_type:     'one-time'
          }
        },
        {
          // Runs after pay, so ${pay_result} holds this payment's outcome
          set: {
            ai_response: 'The payment status is ${pay_result}. If it is success, confirm the order and give a delivery estimate. If not, apologize and offer to try again or pay another way. Do not discuss card details.'
          }
        }
      ]
    }
  });
  return res.json(result.toDict());
}
// Payment: the model decides it's time to pay, the platform collects the card
if (funcName === 'collect_payment') {
  if (!meta_data?.order_total) {
    return res.json({ response: "Let's finish the order before we take payment." });
  }
  // true = the agent says this line out loud first, then pay starts
  const result = new FunctionResult(
    "Great. You'll enter your card details using your phone's keypad.",
    true
  ).executeSwml({
    version: '1.0.0',
    sections: {
      main: [
        {
          pay: {
            payment_connector_url: 'https://payments.example.com/connector',
            input:          'dtmf',
            payment_method: 'credit-card',
            charge_amount:  meta_data.order_total.toFixed(2),
            currency:       'usd',
            description:    `Order ${meta_data.order_id}`,
            max_attempts:   '2',
            security_code:  'true',
            postal_code:    'true',
            token_type:     'one-time'
          }
        },
        {
          // Runs after pay, so ${pay_result} holds this payment's outcome
          set: {
            ai_response: 'The payment status is ${pay_result}. If it is success, confirm the order and give a delivery estimate. If not, apologize and offer to try again or pay another way. Do not discuss card details.'
          }
        }
      ]
    }
  });
  return res.json(result.toDict());
}
// Payment: the model decides it's time to pay, the platform collects the card
if (funcName === 'collect_payment') {
  if (!meta_data?.order_total) {
    return res.json({ response: "Let's finish the order before we take payment." });
  }
  // true = the agent says this line out loud first, then pay starts
  const result = new FunctionResult(
    "Great. You'll enter your card details using your phone's keypad.",
    true
  ).executeSwml({
    version: '1.0.0',
    sections: {
      main: [
        {
          pay: {
            payment_connector_url: 'https://payments.example.com/connector',
            input:          'dtmf',
            payment_method: 'credit-card',
            charge_amount:  meta_data.order_total.toFixed(2),
            currency:       'usd',
            description:    `Order ${meta_data.order_id}`,
            max_attempts:   '2',
            security_code:  'true',
            postal_code:    'true',
            token_type:     'one-time'
          }
        },
        {
          // Runs after pay, so ${pay_result} holds this payment's outcome
          set: {
            ai_response: 'The payment status is ${pay_result}. If it is success, confirm the order and give a delivery estimate. If not, apologize and offer to try again or pay another way. Do not discuss card details.'
          }
        }
      ]
    }
  });
  return res.json(result.toDict());
}

Here's what happens next. The true passed to FunctionResult tells the agent to say its line first, so the caller hears "You'll enter your card details using your phone's keypad." Then the agent stops listening and waits. pay prompts the caller to enter their card on the keypad and sends the details to your payment connector. The set step writes the agent's follow-up line, and the agent picks back up with one piece of new context: the payment status.

These examples use a plain Express server to keep them readable, and need Node 22 or later. In production, the Agents SDK's built-in server handles request security for you. Running SWML from functions is allowed by default; if you've turned off swaig_allow_swml, the pay step will be skipped.

Three details that keep the card number out of the model

The amount comes from the backend, not the conversation. The model gets to decide when to charge. It doesn’t get to decide how much. A caller who sweet-talks the agent into “just charge me five bucks” won’t get very far, because the agent was never holding the number in the first place. Same goes for prices: the model names the items, and your server decides what they cost.

pay runs first, then the follow-up line. SWML fills in variables like ${pay_result} at the moment a step runs, not before it. If the set step came before pay, there would be no result to fill in yet, and the agent would come back without knowing how the payment went. So order really matters here.

The model learns the outcome, not the details. Once pay wraps up, the model knows exactly two things: the line your function said before payment started, and the ai_response text with ${pay_result} filled in. It knows whether the payment went through but it has no clue what card was used, because nobody ever told it.

The one-line mistake to avoid

The ai_response text can reference any SWML variable. That includes pay_payment_results, which holds detailed payment data. The card number and CVV are masked there, but the expiration date and postal code are sitting in plain sight.

So this one innocent-looking line would undo everything above:

// Don't do this
set: {
  ai_response: 'Payment finished. Details: ${pay_payment_results}'
}
// Don't do this
set: {
  ai_response: 'Payment finished. Details: ${pay_payment_results}'
}
// Don't do this
set: {
  ai_response: 'Payment finished. Details: ${pay_payment_results}'
}
// Don't do this
set: {
  ai_response: 'Payment finished. Details: ${pay_payment_results}'
}

Whatever goes into ai_response goes into the conversation and the logs. Same story for your post-call summary prompt, and for any later functions set up to receive call variables, since pay_payment_results hangs around on the call until it ends. After all that work showing the card the side door, this would be holding the front door wide open.

The agent only needs to know whether the payment worked. ${pay_result} covers exactly that.

What the model knows, start to finish

Here’s the same call from the model’s point of view.

It helped the caller build an order and read them the total. When they were ready to pay, it called collect_payment and told them to use their keypad. Then it waited. The next thing it heard was the payment status, so it confirmed the order and gave a delivery time.

As far as the AI is concerned, that’s the whole call. The card never appears in its context or its conversation log.

Your backend, meanwhile, has what it needs. The order ID and total are sitting in metadata, and the card details went to your payment connector and nowhere else. That makes the connector the one part of this setup that actually handles card data, so keep it separate from the server that runs your agent’s functions.

Where payment security goes next: transcription, translation, and streaming

This post is about one thing: keeping payment data out of your AI agent. That's where most of the risk lives in an AI-driven payment flow, and it's where the metadata pattern does its best work.

A call can have more going on than the agent, though. If your calls also use live transcription, translation, or audio streaming, those deserve their own look. We'll cover payment security across the whole call in Part 3.

A short checklist

Collect cards with pay, never with speech in the AI conversation. Tell your agent never to ask for card details and to redirect callers who start reading them out. Take prices and the charge amount from the backend state, never from the model's arguments. Run pay first, then set the agent's follow-up line, and keep that line to ${pay_result}. Then run the flow in your processor's test mode and read the agent's conversation log end to end. If you can't find a single digit of card data in it, you built it right.

Build a payment flow the model never hears

Have questions about adding the pay handoff to your own agent, or keeping card data out of your logs? Bring them to the SignalWire Discord, where the developer community and the SignalWire team can help you work through it.

Top Linear Gradient  Lines image

Frequently asked questions

Frequently asked questions

The questions we hear most, answered.

The questions we hear most, answered.

Why should an AI agent never hear a credit card number?

Once a card number enters the model’s context, it stays there for the rest of the call and flows into transcripts, recordings, and every request sent to your LLM provider. A model that has seen a card can also repeat a mangled version later in a summary or confirmation line. Keeping the card out of context is the only reliable fix.

What happens if an AI voice agent reads a card number back to confirm it?

The digits enter the model’s context, the call recording, the transcript, and every downstream LLM request for the rest of the call. Redacting the logs afterward does not solve this, because the model already heard the number and may reproduce it elsewhere.

How do you collect a payment without the AI hearing the card?

Hand the call off to a dedicated payment step. The agent decides when to charge, then a platform feature like SWML pay collects the card over the keypad and sends it to your payment connector. The agent picks the conversation back up knowing the outcome, not the digits.

Can you scrub card data from AI logs after the call?

Cleaning the log only cleans the log. The card still passed through the model’s context and every LLM request made during the call, and the model may have repeated it somewhere else. The safer approach keeps the card out of context from the start.

What is the most common mistake that leaks payment data back into the AI?

Passing detailed payment results into the agent’s follow-up text. A variable that holds the full results still exposes the expiration date and postal code. Keep the follow-up line to the payment status alone, which tells the agent whether the charge worked and nothing more.

Bottom Linear Gradient  Lines image

The Communications Stack for What's Next

APIs built for speed. Infrastructure built for scale. AI built in from day one.

The Communications Stack for What's Next

APIs built for speed. Infrastructure built for scale. AI built in from day one.

The Communications Stack for What's Next

APIs built for speed. Infrastructure built for scale. AI built in from day one.

The Communications Stack for What's Next

APIs built for speed. Infrastructure built for scale. AI built in from day one.