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 apifrom livekit.agents import Agent, function_tool, RunContext, get_job_contextfrom 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 dates2. NEVER repeat back any numbers that sound like card numbers3. 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 line5. 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 refrom typing import AsyncIterable, Optionalfrom livekit import rtcfrom 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, RunContextfrom livekit.plugins import openai, deepgram, sileroimport 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
| Do | Don't |
|---|---|
| Use payment links or IVR transfers | Collect card numbers via voice |
| Disable recording during payment flows | Store or log potential card data |
| Add explicit prompt guardrails | Trust the LLM to self-censor |
| Filter transcripts in real-time | Let raw audio reach your systems |
| Use established PCI providers | Build your own payment handling |
Further reading
- Redacting PII from agent logs and transcripts — Essential guidance for protecting sensitive data
- Disabling recording at the session level — How to turn off audio, transcripts, traces, and logs
- Call forwarding (SIP REFER) — How to transfer calls to external systems
- PCI DSS Quick Reference Guide — Official PCI standards documentation
- Stripe's PCI compliance guide — How payment links reduce scope