Skip to main content
 
Field Guides

Handling payments with PCI compliance

Learn how to handle payments in LiveKit voice agents using a hybrid architecture that keeps sensitive payment data off the voice channel.

Last Updated:


Need to collect payments during voice calls? Don't let cardholder data touch your voice agent. Here's how to stay PCI-compliant with a hybrid architecture.

The core principle: keep payment data off the voice channel

PCI-DSS compliance becomes dramatically simpler when cardholder data never enters your voice agent's scope. Instead of trying to secure every component that handles card numbers, you isolate payment collection entirely.

Your voice agent handles:

  • Conversation flow and context
  • Customer qualification and intent detection
  • Order details and confirmation
  • Handoff orchestration

A separate PCI-compliant system handles:

  • Card number collection
  • Payment processing
  • Tokenization

Hybrid architecture patterns

Pattern 1: Secure payment link

The most common approach—send customers a link to complete payment:

from livekit.agents import Agent, function_tool, RunContext
class PaymentAgent(Agent):
def __init__(self):
super().__init__(
instructions="""You are a helpful sales assistant. When the customer
is ready to pay, use the send_payment_link tool. Never ask for or
accept credit card numbers directly."""
)
@function_tool()
async def send_payment_link(
self,
amount: float,
description: str,
customer_phone: str,
) -> str:
"""Send a secure payment link to the customer's phone."""
# Generate payment link via your PCI-compliant provider
# (Stripe, Square, PayPal, etc.)
payment_link = await create_payment_session(
amount=amount,
description=description,
metadata={"session_id": self.session.id}
)
# Send via SMS
await send_sms(
to=customer_phone,
body=f"Complete your payment securely: {payment_link}"
)
return "I've sent a secure payment link to your phone. Let me know once you've completed the payment."

The agent continues the conversation while the customer completes payment on their device:

@function_tool()
async def check_payment_status(self, context: RunContext, session_id: str) -> str:
"""Check if the customer has completed their payment."""
status = await get_payment_status(session_id)
if status == "completed":
return "Payment received successfully."
elif status == "pending":
return "Payment is still pending."
else:
return f"Payment status: {status}"

Pattern 2: Transfer to PCI-compliant IVR

For phone-based payments, transfer the caller to a dedicated payment IVR using the TransferSIPParticipant API:

from livekit import api
from livekit.agents import Agent, function_tool, RunContext, get_job_context
from livekit.rtc import ParticipantKind
class SalesAgent(Agent):
def __init__(self):
super().__init__(
instructions="""You are a sales agent. When ready to collect payment,
transfer the caller to our secure payment system. Never collect
card details yourself."""
)
@function_tool()
async def transfer_to_payment_ivr(
self,
context: RunContext,
amount: float,
order_id: str,
) -> str:
"""Transfer caller to secure payment IVR."""
job_ctx = get_job_context()
# Find the SIP participant
sip_participant = None
for participant in job_ctx.room.remote_participants.values():
if participant.kind == ParticipantKind.PARTICIPANT_KIND_SIP:
sip_participant = participant
break
if not sip_participant:
return "Unable to transfer - no phone connection found."
# Transfer using the SIP API
# Note: Your SIP trunk must have call transfers enabled
try:
await job_ctx.api.sip.transfer_sip_participant(
api.TransferSIPParticipantRequest(
room_name=job_ctx.room.name,
participant_identity=sip_participant.identity,
transfer_to="sip:payment-ivr@your-pci-provider.com",
)
)
except Exception as e:
return f"Transfer failed: {e}"
return "Transferring you to our secure payment line now."

Note: You must enable SIP REFER on your SIP trunk provider. For Twilio, enable "Call Transfer (SIP REFER)" and "Enable PSTN Transfer" in your trunk settings. See the Call forwarding documentation for details.

Why DTMF payment collection doesn't work in-call

You might be tempted to have callers enter card numbers via DTMF tones while staying connected to your LiveKit agent. This doesn't work for PCI compliance.

If the caller enters DTMF tones while connected to LiveKit:

  • LiveKit receives and processes those tones
  • The tones could appear in logs, recordings, or event streams
  • This puts LiveKit (and your agent) in PCI scope

The only compliant approach for DTMF-based payment is to transfer the call entirely to a PCI-compliant IVR system (Pattern 2). The caller must leave the LiveKit room before entering any card data.

Some providers like Twilio Pay and Plivo offer PCI-compliant IVR systems that handle DTMF payment collection. Use SIP REFER to transfer the caller to these systems when payment is needed.

Preventing accidental PCI scope creep

Even with a hybrid architecture, you need guardrails to prevent card data from leaking into your agent.

1. Disable recording for payment sessions

Prevent any possibility of card numbers appearing in transcripts by passing record=False to the start method:

await session.start(
agent=agent,
room=ctx.room,
record=False, # Disables audio, transcripts, traces, and logs upload
)

See Redacting PII from agent logs and transcripts for complete guidance on protecting sensitive data.

2. Add explicit guardrails in your prompt

instructions="""CRITICAL COMPLIANCE RULES:
1. NEVER ask for credit card numbers, CVV, or expiration dates
2. NEVER repeat back any numbers that sound like card numbers
3. If a customer tries to give you card details, immediately redirect:
"I can't accept card details directly. Let me send you a secure link instead."
4. For payments, either send a secure payment link OR transfer to our payment line
5. NEVER collect card details while the caller is connected to you
"""

3. Implement real-time filtering

Block card patterns from reaching the LLM by overriding the stt_node:

import re
from typing import AsyncIterable, Optional
from livekit import rtc
from livekit.agents import Agent, ModelSettings, stt
class PCICompliantAgent(Agent):
CARD_PATTERN = r'\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b'
async def stt_node(
self,
audio: AsyncIterable[rtc.AudioFrame],
model_settings: ModelSettings
) -> Optional[AsyncIterable[stt.SpeechEvent]]:
"""Filter potential card numbers from transcription before LLM."""
async for event in Agent.default.stt_node(self, audio, model_settings):
# Check if this is a final transcript event
if isinstance(event, stt.SpeechEvent) and event.type == stt.SpeechEventType.FINAL_TRANSCRIPT:
for alt in event.alternatives:
if re.search(self.CARD_PATTERN, alt.text):
# Replace with placeholder
alt.text = re.sub(
self.CARD_PATTERN,
"[CARD NUMBER BLOCKED]",
alt.text
)
# Queue a redirect message
self.session.say(
"I noticed you're trying to share card details. "
"For your security, I can't accept those directly. "
"Let me send you a secure payment link instead."
)
yield event

Example: Complete payment flow

Here's a full example combining conversation handling with secure payment handoff:

from livekit.agents import Agent, AgentSession, AgentServer, JobContext, function_tool, RunContext
from livekit.plugins import openai, deepgram, silero
import httpx
class SecurePaymentAgent(Agent):
def __init__(self):
super().__init__(
instructions="""You are a friendly order assistant for Acme Corp.
Your job:
1. Help customers with their orders
2. Confirm order details (items, quantities, shipping)
3. Calculate totals and explain charges
4. When ready to pay, send a secure payment link
NEVER collect card details directly. Always use send_payment_link."""
)
self.order_total = 0
self.order_items = []
@function_tool()
async def add_to_order(
self,
context: RunContext,
item: str,
quantity: int,
price: float
) -> str:
"""Add an item to the customer's order."""
self.order_items.append({
"item": item,
"quantity": quantity,
"price": price
})
self.order_total += price * quantity
return f"Added {quantity}x {item} at ${price:.2f} each. Current total: ${self.order_total:.2f}"
@function_tool()
async def send_payment_link(self, context: RunContext, customer_phone: str) -> str:
"""Send secure payment link when customer is ready to pay."""
if self.order_total <= 0:
return "No items in the order yet. Please add items first."
# Create Stripe payment session
async with httpx.AsyncClient() as client:
response = await client.post(
"https://api.stripe.com/v1/payment_links",
auth=("sk_live_xxx", ""),
data={
"line_items[0][price_data][currency]": "usd",
"line_items[0][price_data][product_data][name]": "Order",
"line_items[0][price_data][unit_amount]": int(self.order_total * 100),
"line_items[0][quantity]": 1,
}
)
payment_link = response.json()["url"]
# Send SMS (implement your SMS provider here)
await send_sms(customer_phone, f"Complete your ${self.order_total:.2f} payment: {payment_link}")
return f"I've sent a secure payment link for ${self.order_total:.2f} to {customer_phone}. The link is valid for 24 hours. Let me know once you've completed the payment!"
server = AgentServer()
@server.rtc_session()
async def entrypoint(ctx: JobContext):
session = AgentSession(
llm=openai.LLM(model="gpt-4o"),
stt=deepgram.STT(),
tts=openai.TTS(),
vad=silero.VAD.load(),
)
await session.start(
agent=SecurePaymentAgent(),
room=ctx.room,
record=False, # Disable recording for payment flows
)

Key takeaways

DoDon't
Use payment links or IVR transfersCollect card numbers via voice
Disable recording during payment flowsStore or log potential card data
Add explicit prompt guardrailsTrust the LLM to self-censor
Filter transcripts in real-timeLet raw audio reach your systems
Use established PCI providersBuild your own payment handling

Further reading