Payments (PCI)¶
Status: In progress | Last reviewed: 2026-09-30
Payments is a per-agent capability that takes a card or bank payment from a caller without cardholder data ever reaching a language model. Read this page when you touch the capability card, the conversation-api payments package, the orchestrator's payment tools, payment-vault, or the schema behind them. For the end-user view, see the user guide page.
Payment permission uses the question present when caller speech began, so an
S2S acknowledgement cannot replace the payment question before the answer
arrives. An active unfinished answer cannot reuse an earlier payment request.
PAYMENT_ACTIVE_REPLY_GRACE_SECONDS allows up to 15 seconds for an already
active caller turn; ordinary final-transcript jitter retains the 2.5-second
grace. The wait ends on recorded caller or UI evidence. Missing evidence still
refuses capture, and one affirmative answer remains consumable only once.
Payment reads validate transaction UUIDs before querying PostgreSQL. A
placeholder such as null or a variable name returns the usual not-found
outcome rather than aborting the database transaction. Tenant and conversation
ownership checks remain in force. The live and evaluation paths share this
store behavior.
Phases 1 to 4 and the S2S handoff part of Phase 6 are complete. The capability is authorable, gated, validated at publish, and its three tools reach conversation-api end to end; payment-vault exists, so refunds and status reads work against a real provider; both cascaded and speech-to-speech voice callers can enter payment details on the keypad through the secure capture flow; and the hosted-fields page is real (frame_page.py — one constant string, CSP-hashed inline script, no interpolated session data), reached through the frame_url a capture returns when a form surface is configured. Typed card numbers are still not caught (Phase 5), so a chat surface can carry a PAN the guard would have stopped. With PAYMENT_VAULT_URL unset, every capture path answers 503 payments_not_configured and the read paths keep working. The Status by phase table says what runs today.
Architecture in one line¶
conversation-api is the payments authority (policy, transaction records, refund limits, audit); payment-vault is the only component that ever sees a PAN; the orchestrator exposes LLM tools and a secure non-LLM capture mode; ai-manager provides the capability card, publish validation, and tenant settings.
┌─ PCI SCOPE ────────────────────────────────┐
voice DTMF ─────────► │ orchestrator PaymentModeController │
│ digits in RAM, streamed out per field │
│ │ internal HTTPS │
browser iframe ─PAN─► │ payment-vault :3530 │
(hosted fields, │ PAN in memory only, never logged/stored │ page: Phase 4
served BY the vault) │ adapters/ (Stripe first) │
│ │ TLS │
└────────┼───────────────────────────────────┘
│ token + masked (last4/brand/exp) ONLY
▼
conversation-api /conversations/{id}/payments/* ← the authority, OUT of scope
├── payment_transactions (masked details only)
├── payment_events (append-only audit)
└── policy: tenant_settings['payments'] ∧ node builtin_tool_configs.payments
Out of PCI scope and deliberately so: conversation-api, ai-manager, the orchestrator's LLM path, surface-app, Postgres, Redis, and the model itself.
Two gates, both required¶
- Per-agent —
builtin:paymentsin the stage'sconfig.tools[], with settings atconfig.builtin_tool_configs.payments. As with Customer Authentication, enabled means presence intools[]; there is noenabledfield, and an emptybuiltin_tool_configs.paymentsobject is a node that opted in and accepted every default. - Per-tenant — the
paymentskey intenant_settings(Settings → Tenant Settings → Payments, payments-settings.ts). A missing row is the disabled default.
A request against a conversation whose agent or tenant never opted in is a 409, not a 500. A request against a tenant that opted in but has no vault wired is a 503 — the distinction matters because the first is a policy answer and the second is an outage.
The tenant bounds a node may lower but never raise¶
Tenant settings answer whether; the node answers how. Both are read on every payment and neither reaches into a capture already in flight. Where they overlap, conversation-api composes them so the tenant is a ceiling:
| Setting | Composition |
|---|---|
max_amount_minor, max_refund_amount_minor |
min() of the two, treating 0 as "no ceiling" on either side |
allowed_method_types, allowed_currencies |
intersection; an empty intersection is a real outcome the caller is refused on, never a reason to fall back to a default |
default_integration_id |
the fallback for a node that names no integration of its own |
The composition lives in payments/config.py and is covered by test_payments_node_policy.py.
Publish refuses; the runtime clamps. ai-manager blocks a publish outside the authored bounds (5–120 s field timeout, 0–5 retries per field); conversation-api clamps into them. So ai-manager's window must never be the wider one, or a config publishes cleanly and then runs at a number the designer never chose. payments-mirror.test.ts asserts both sides name the same four numbers, in the check and in the message shown to the designer.
Which version's config the policy comes from.
find_payments_configresolves the stage config throughNODE_POLICY_SQL(config.py) and follows the same two rules as the Customer Authentication query, for the same reason: it mustsplit_part(c.agent_id, ':', 1)(an editor-started conversation stores3032:draft;agent_versionsis keyed on the bare id) and pin toconversations.config_version, with published-first surviving only as a widened candidate set in theWHERE. Full rule and the sibling sites in conversation-api.md.
The three tools¶
Trusted terminal payment injections publish payment_outcome_status to the
session variable store before selecting continuation guidance, including when
private capture consumes the event. Procedures can use this to select an
explicit failed/cancelled branch. Capture the new collect_payment status into
the same variable to reset it for a new attempt. This is a branching hint, not a
receipt: recording success still requires the actual matching payment record.
Untrusted or pending injections do not establish a terminal outcome.
The orchestrator registers them from payment_tool_helper.py. Every one is an HMAC-stamped call to conversation-api; none of them accepts or returns cardholder data.
| Tool | What it does | Offered when |
|---|---|---|
collect_payment |
Hands the session to the secure capture flow. Returns a handle split into tool_result (for the model) and runtime (for the controller — prompts and session id, never a model context). |
always |
process_refund |
No capture needed. Refused unless refunds are enabled on the node, the parent charge resolves inside the allowed scope, it succeeded, and the amount fits the remainder. | refunds.enabled is explicitly true |
get_payment_status |
Reads the transaction rows, not the vault — a status the vault knows and the database does not shows up as a discrepancy instead of being smoothed over. | unless read-back is explicitly false |
payment_tool_names_for_policy() filters through PAYMENT_TOOL_NAMES rather than rebuilding a list, so the advertised order stays stable. Refunds are off unless explicitly on: taking money is a narrower risk than giving it back.
Money crosses the model boundary as a decimal string parsed with Decimal, never float, and _ZERO_DECIMAL (the 16 ISO-4217 codes with no minor unit) is duplicated in the orchestrator and conversation-api — a mismatch there charges 100×.
Authorization before capture¶
collect_payment has a runtime precondition before it calls conversation-api. A model-generated tool argument is not authorization. The callback accepts a fresh explicit request to pay, a short affirmative answer only when it followed the agent's payment question, or a deliberate payment action (pay, confirm_payment, process_payment, submit_payment). It refuses before creating a transaction when the latest caller interaction is unrelated, declined, or is an older “yes” followed by another UI action. Authorization evidence is consumed once, so one answer cannot start two captures. The refusal tells the model to state the exact amount and purpose, ask whether to take it now, wait, and retry only after yes. One timing rule is load-bearing: on S2S the model calls collect_payment the instant the caller answers, racing the final transcript — judged on the stale "latest" utterance the gate refused a plainly-given yes three times in one call, each refusal costing a spoken re-ask. A would-be payment_authorization_required refusal therefore waits out that race (up to PAYMENT_AUTHORIZATION_GRACE_SECONDS, default 2.5 s) and re-evaluates once on the first new caller utterance or UI action — whatever it says. A decline or a grant stands immediately, and silence keeps the refusal, so the window never authorizes anything the evidence rules wouldn't. Same pattern as customer auth's consent grace window.
Explicit caller statements such as “I authorize the twenty-five dollar payment” or “You can charge the twenty-five dollars now” are also current payment requests. Recognition accepts numeric or spoken amounts and an authorization sentence following another completed sentence. It uses a bounded payment-object vocabulary, so authorizing cancellation or identity verification is not a payment request. A subsequent voice backchannel does not erase that self-contained authorization. It is still consumed once. Recognized permission phrases containing a question, condition or deferral are refused; a conditional “yes” is not converted into unconditional permission. This also covers explicit forms such as “go ahead and charge the ten dollars,” “you can take the payment now,” “I agree to pay the adjusted copay,” “I approve the copay,” “I'd like to authorize payment now,” “I'd like to pay the copay,” and “I authorize the collection of the twelve dollars.” These still require a payment object, are refused when conditional or deferred, and cannot reuse previously consumed permission. A bare affirmative still needs the current payment question.
A bounded completion-notification clause, such as “Please let me know when it's complete,” does not make an otherwise current authorization conditional. The gate checks the remaining text for questions, conditions and deferrals; “pay when the review is complete” and “yes, after approval” remain refusals. A notification request on its own never authorizes a payment.
Five copies of the same vocabulary¶
Four processes that cannot import each other, plus a generated schema:
| Copy | Drifts as |
|---|---|
| ai-manager (payments-settings-shared.ts) | authoring offers an instrument nothing runs |
| ai-orchestrator (payment_tool_helper.py) | an unknown {{TOOL:}} token stays literal in the prompt, and the model reads it aloud |
| conversation-api (payments/config.py) | unrecognized method types and surfaces are dropped, so a switched-on instrument is silently never offered |
| payment-vault (adapters/base.py) | a method type with no instrument branch reaches create_payment_method with neither a card nor a bank account and raises AdapterError("no instrument supplied") — the loudest of the five, and the only one that fails after the caller has entered their details |
| the generated JSON Schema (generate-config-schemas.ts) | drifts by omission, because builtin_tool_configs is a hand-authored literal and additionalProperties: true swallows an unregistered builtin without failing the generator's description gate |
payments-mirror.test.ts reads the Python sources as text and compares what is written in each place — a test that needed a Python interpreter would be skipped in the Node run and never catch the drift it exists for. When you add a method type or capture surface, edit all five.
What publish validation refuses¶
From validate-node-capabilities.ts:
- Non-conversational node — only an LLM-owning stage can hand the session to a capture flow.
- No method type selected — the agent has nothing to collect.
- No capture surface selected — the caller has no way to enter anything. An empty list is rejected rather than defaulted, because a default here would invent a compliance posture.
- More than one node with Payments — the payment flow has one owner per session.
- A parallel or swimlane pipeline — secure capture is session-wide and must gate audio and DTMF before branches fan out. Linear cascaded and S2S pipelines are supported; a branch-local controller would leave sibling models exposed.
Warnings (non-blocking) cover the transports with no DTMF ingestion (Genesys AudioHook, Five9 — voice capture is unsupported there in v1, though multimodal and chat still work), a node relying on the multimodal form with no multimodal surface, and refunds scoped beyond the current conversation.
Voice capture: the secure keypad flow¶
payment_mode_controller.py is the non-LLM capture mode. collect_payment opens a vault session and begin_capture puts the controller into secure mode; from there until it exits, the caller's keypad input goes to the vault and nowhere else. The prompts are spoken with TTSSpeakFrame through the authored TTS or a provider-matched private TTS — there is no inference in the loop, so nothing the caller enters can influence what is said next. payment_speech_input_gate.py separately detects voice activity so the flow can explain this boundary without transcribing or understanding what the caller said.
For a bare S2S stage, handoff ownership is resolved from the _payments_policy:<stage-id> runtime stash captured during tool-schema resolution. ServiceRegistry strips builder-only keys such as builtin_tool_configs from the stage config in place; chain assembly must not re-derive payment ownership only from that sanitized object or it will omit the private TTS/output gate and reject an otherwise valid collection as capture_unavailable. Secure mode also holds the idle processor's secure_payment suspension from entry through exit, so normal pauses while the caller uses the keypad do not compete with idle prompts.
How it is entered — no observer¶
There is no payment_tool_handler.py. Customer Authentication needs an observer because its KBA factor is attested from the tool's own result frame, but payments has nothing to attest: _do_collect in payment_tool_helper.py awaits on_capture_started synchronously inside the tool callback, so the controller is already in secure mode before the callback returns.
The real gap was the other end of the same call — pipecat runs inference on a tool result by default, which would have put the model narrating "sure, I'll take that now" over the top of "please type your card number", each interrupting the other, with the caller's digits landing in a field the controller has not asked for yet. That is closed with FunctionCallResultProperties(run_llm=False), and it is keyed on status == "capture_started", not on the tool's name: a refused collect_payment (bad amount, over the ceiling, no capture surface wired) returns an error the model has to relay out loud, and suppressing that would leave the caller in silence.
That result flag cannot retract a sibling tool call the provider already emitted in the same batch. In the 3032 failure, Nova issued collect_payment and record_copay(payment_status="succeeded") together; the latter ran before the vault had settled, making the agent announce success early. payment_capture_tool_guard.py is applied last to every registered model callback. It marks collect_payment as starting before its first await and refuses all other model tools while startup or secure capture is active, returning payment_capture_in_progress with run_llm=False. After the controller's terminal handback, tools run normally and can record only the stored outcome. Regression: test_payment_capture_tool_guard.py, including the case where the sibling task is scheduled first.
The two halves of a capture, and the id that must be on both¶
CaptureHandle in payments/service.py splits into to_tool_result() (what the model sees, gets transcribed, and is persisted) and to_runtime_directive() (what the controller drives the capture with). Each half is defined by what it must not carry: the vault session id, the session nonce, the frame URL and the prompt scripts stay off the model's copy — a model that can see the script can be talked into reciting it.
transaction_id belongs on both, and it was missing from the directive. The controller reports the capture outcome against that id, and the orchestrator's outcome reporter returns silently when it is blank, so every voice capture stayed at status=pending: the charge settled in the vault, the caller heard "payment received", and the agent's own get_payment_status then answered "still pending" truthfully, forever — which is what a caller hears as "it kept saying it would check the status of my payment but never reported back". There is no error line anywhere on that path, because nothing failed; a report was simply never attempted. payment_tool_helper.py also carries the id defensively from the tool result onto the directive, so an older conversation-api cannot reintroduce the gap. Regression: test_payments_capture_handle.py (plain-assert, .venv/bin/python test_payments_capture_handle.py) plus the superset check in the orchestrator's test_payment_tools.py.
Where it sits, and why there are two gates¶
PaymentSpeechInputGate is global and goes immediately after transport input, before STT or any realtime provider. During secure capture it runs VAD and replaces every caller audio frame with equal-duration silence, so neither a cascaded transcription service nor an S2S model receives the utterance. The transport has already applied its configured input filter by this point, so Krisp-filtered agents detect speech on the cleaned audio. The gate accepts the transport's existing UserStartedSpeakingFrame events and carries a private Silero VAD fallback for providers that do not emit them. It sends the controller only an activity event: no audio, transcript, or NLU result is retained.
The downstream PaymentModeController remains the second brace. On a cascaded pipeline it goes after STT, before the user context aggregator, and before DTMFInputHandler; on S2S it remains before the user aggregator/provider handoff. This catches delayed or alternate-path transcription frames and owns DTMF. Downstream of the aggregator or DTMF handler it would be gating a leak that had already happened.
InputDTMFFrame is a SystemFrame, so it bypasses the queue and is intercepted in process_frame regardless of where the controller sits — the digits alone would not fix its position. What fixes it is the transcriptions: a caller who reads their card number aloud, or a transport that bleeds in-band tones into the audio, produces TranscriptionFrames that must be swallowed before the aggregator folds them into the LLM context and before DTMFInputHandler turns a digit into a transcription of its own.
Insertion is deliberately independent of assembly.dtmf_tool_enabled. That flag tracks the builtin:send_dtmf tool — the agent sending digits out — which a payments agent has no reason to enable. Gating on it is how the controller would end up absent from exactly the agents that need it.
Three frame kinds are swallowed while secure mode is active:
| Swallowed | Why |
|---|---|
InputDTMFFrame |
the keypad digits themselves; the comment in process_frame records that there is no queued path to gate instead, so the interception has to live there |
TranscriptionFrame / InterimTranscriptionFrame |
spoken digits and in-band tone bleed |
upstream TextFrame |
belt and braces — a transport or processor that reaches the LLM by another route |
Nothing downstream of the controller sees them: DTMFInputHandler is the next hop, and the digits never reach it.
On every voice pipeline, InputAudioRawFrame is not dropped: PaymentSpeechInputGate replaces its audio bytes with an equal-length zero buffer before STT/provider processing. Cascaded STT and Nova/OpenAI/Deepgram therefore keep the same live stream cadence and the recorder keeps the same timeline, while receiving silence. The controller retains its S2S silencing as a fail-safe. Sensitive values are accepted only as DTMF; VAD answers only whether speech is present.
Keypad conventions¶
# ends the current field. * clears it, and * twice in a row cancels the payment — twice in a row meaning with no intervening digit, tracked by clearing the marker on any other keypress rather than by a stopwatch, so a caller who thinks about it for ten seconds and presses star again still cancels. At the read-back, 1 pays and * starts the whole instrument again rather than the last field (the caller has just heard the wrong card read back); anything else at the confirmation is ignored, because guessing between "pay" and "start again" on an ambiguous keypress is not a guess worth making.
voice.return_to_agent_key is different: it defaults to *, but takes precedence only after speech has been detected and the explanation has invited the caller to use it. Before and after that choice, star retains the clear/twice-cancel behaviour above. If the caller presses the return key, the controller cancels the unfinished vault session with reason caller_requested_main_agent, clears local capture state, speaks returning_to_agent, and sends the model one synthetic handback asking it to have the caller repeat the unheard question. A later payment always starts a fresh vault session. Any other key dismisses the choice and continues through its normal keypad meaning.
The authored prompts are wording, not prompting — no inference reads them — and exactly four tokens are substituted: {{amount}}, {{last4}}, {{instrument}} and {{return_key}}. {{last4}} is substituted spelled digit-by-digit (spell_digits: 4444 → 4 4 4 4), because TTS reads a bare four-digit run as a NUMBER — "four thousand four hundred and forty-four" — and a card tail is digits, not a quantity. The same spelling is applied to the tail in the handback _summary (the model repeats the tail in whatever form the summary gave it, and an S2S model's reply is spoken directly), and the model-facing _PROMPT_HEADER Rule 4 now says to read tails digit by digit. {{instrument}} resolves through INSTRUMENT_NOUN to "card" or "account"; {{return_key}} becomes a speakable value such as "star" or "hash" in the speech_detected script. The authoring form also exposes returning_to_agent. The list is duplicated in the authoring form's help text (PaymentsForm.tsx) and in the user guide — an unknown token is spoken aloud verbatim, so the three lists have to agree.
Each field is validated locally — length, Luhn, MMYY — with per-field retries and a silence timeout, both bounded by the authored field_timeout_seconds / max_retries_per_field. Over-length input is refused, not truncated: a truncated 16-digit PAN can still pass Luhn, so dropping the last digits would let a wrong card reach the provider looking valid. A completed field is streamed to the vault as it is finished, so the process never holds more than one field's digits at a time.
The silence timer starts when the prompt is finished being heard, not when it is pushed¶
field_timeout_seconds is the caller's thinking time, so it can only start once they have heard the question. BotStoppedSpeakingFrame does not mean that — it means the pipeline finished sending the audio, and the transport's playout queue drains after it (an S2S provider streams a 12 s utterance in ~2 s; the idle-prompt clock measures playout for the same reason). Arming the timer when the prompt is pushed is worse still, and that is what _ask_current_field used to do: the whole prompt plays inside the caller's own timeout window, so a caller who starts keying the moment it ends is timed out mid-entry. The report was "it immediately said it didn't catch that right after I started typing DTMF and repeated itself" — the re-prompt was racing the prompt's own playout, on a capture that was working.
So _restart_timer defers while self._speaking is true (_timer_pending), and the BotStoppedSpeakingFrame branch arms it then, with PROMPT_PLAYOUT_GRACE_SECONDS (2 s) added for the queued tail. Two details keep the deferral from wedging the capture: an armed-but-deferred timer carries PROMPT_BACKSTOP_SECONDS (20 s on top of the authored timeout) so a prompt that never produces a stop frame — a TTS that synthesizes nothing, e.g. the unsupported-Polly-engine case above — still times out rather than waiting forever; and _cancel_timer clears _timer_pending, or a cancelled field could be resurrected by a late stop frame.
A digit is barge-in — the agent stops talking when the caller starts typing¶
A caller who starts keying has taken the floor, the same as speaking. Two handlers cover the two states (added 2026-08-13): during secure capture, PaymentModeController._on_digit interrupts its own scripted prompt so the first digit of a card number is never dropped under a talk-over; outside capture, DTMFInputHandler broadcasts the same interruption a voice barge-in uses — once per digit burst (2 s debounce), never per digit — before injecting [DTMF: n] as the user turn. A failed interruption logs and continues: losing barge-in costs a talk-over, raising would cost the digit.
The controller's gate is not self._speaking alone, for the same reason the timer can't be: that flag clears on the stop frame while the queued audio is still audible, so a digit pressed during the tail found nothing to interrupt and the caller heard the prompt keep going over their own keypresses — the second half of the same report. _on_digit also interrupts inside _playout_tail_until (the same 2 s grace) and consumes the window on the first digit, so one burst broadcasts once. Regressions: test_dtmf_input_barge_in.py for the outside-capture handler, and the four playout cases at the end of test_payment_mode_controller.py for the controller's — including a negative control that a digit arriving long after the tail interrupts nothing, so the fix cannot degenerate into "always interrupt".
Getting a digit there at all — one transport per surface¶
InputDTMFFrame is the only input the secure keypad flow accepts, and there is no shared path that produces one. Each surface carries a keypress its own way, so keypad capture can be completely functional on three of them and inert on the fourth with nothing to show for it: the controller is in secure mode, the prompt was spoken, the caller presses digits and #, and nothing happens — no error in the browser, none in the orchestrator, and the capture sits in its field timeout until it cancels itself.
| Surface | Hook / component | How a digit travels |
|---|---|---|
| SIP — Genesys, Connect BYOC, the emulator's janus leg | useJanusSIPCall |
janus SIP dtmf_info → Asterisk ChannelDtmfReceived → ari-controller → media-proxy POST /session/{id}/dtmf-in → SendDtmfToGrpc |
| Amazon Connect WebRTC | useAmazonConnectWebRTCCall |
POST /api/emulator/amazon-connect/dtmf → the Connect Streams API |
| Direct WebRTC — agent-editor test panel | auth-context.tsx sendDTMF |
POST /api/proxy/webrtc/dtmf → media-proxy POST /v1/webrtc/dtmf → Session.SendDTMF |
| Direct WebRTC — embedded emulator | useWebRTCCall |
same route, derived from the hook's proxyUrl |
The two direct-WebRTC rows are the ones that had to be built. A direct WebRTC call has no SIP leg, so there is no SIP INFO or RFC 4733 path for a digit, and FindSessionByID walks the RTP port table — WebRTC sessions live in their own map, so /session/{id}/dtmf-in 404s for one. Both failed silently for their own reason: the test panel called janus's dtmf() API on a session that has no janus at all (its error callback is Janus.noop, so the failure is a console line at most), and the embedded emulator passed call.sendDTMF from a hook that never returned one — undefined, invoked through the optional-call operator in PhoneEmulator, which is indistinguishable from a working handler.
Two rules follow. Never route a digit through janus's dtmf() — dtmf_info is the SIP path, and the other transports are not janus. And when adding a call transport, sendDTMF is part of the contract: CallHookReturn.sendDTMF is optional, so a hook that omits it typechecks and the keypad it feeds is dead. The browser-facing /api/proxy/webrtc/dtmf route owns the digit-charset guard (/^[0-9A-Da-d*#]$/) and the session-ownership check; media-proxy validates presence only, matching the contract the SIP dtmf-in handler already had.
Suppression is two layers, and the setters are deliberately different¶
The controller swallowing frames is the first layer. The second stops anything already captured from being written down:
ConversationRecorderProcessor.set_suppressedwrites silence of equal duration rather than skipping the audio, because the stereo mix is positional and a shortened caller track would slide out of alignment with the agent's. DTMF tones of a card number are a recording of a card number, and PCI DSS 3.2 forbids storing the CVV at all — so this is not a question that redaction could answer later.ConversationAPIPublisher.set_secure_capture_activecomposes with the permanent per-branch_suppress_user_transcriptsviaor. The names differ on purpose: a sharedset_suppressedwould invite an implementation that clears the permanent flag when a capture ends and duplicates every user row from then on.
Only the caller's side is suppressed. The agent's audio and transcripts keep recording and publishing — the scripted prompts, the masked confirmation and the outcome are exactly what an audit or a disputed charge needs, and a transcript pane going silent mid-payment would read as a broken session at the moment the caller most needs to trust it.
The four-digit tail is derived here, not by the vault¶
The vault's mid-capture surface is counts-only, and it publishes last4 only after charging. So _submit_current_field takes value[-4:] into a local variable, committed to _masked_last4 only after the vault accepts the field — a rejected value must not leave a tail behind — and only for the confirm field of that method type, so a bank routing number never becomes a spoken tail. note_masked is the other way in, for Phase 4's form, where the digits never enter this process at all.
Exit, and the one message the model gets¶
_exit has three orderings that matter:
- The outcome reaches conversation-api before the model is told, so the transaction row is settled by the time the agent starts talking about it — otherwise a caller asking "did that go through?" in the next breath races the write.
- Suppression is lifted after the closing line is spoken, so that line is not recorded as part of the capture.
- The local copy of the directive is taken first, because
_leave_secure_modedrops it and both the outcome and the cancel reports need the transaction id. Reporting after the reset is how a cancelled payment would be left live until the vault reaped it.
It then emits exactly one LLMMessagesAppendFrame(messages=[…], run_llm=True) carrying a masked summary — status, masked tail, amount. run_llm=True is what makes the agent speak next; without it the caller is left in silence after the capture's last prompt. _leave_secure_mode drops every trace of the capture from the process, is safe to call twice, and clears the tail with the rest.
begin_capture returns False (logged) when PAYMENT_VAULT_URL or the internal token is missing, rather than opening a capture with nowhere to put the digits — and also when a capture is already running, since honouring a second collect_payment would interleave two sets of digits into one buffer. That boolean is load-bearing in on_capture_started: a False cancels the capture rather than reporting a start that did not happen, so the caller is told the payment could not be taken while the agent is still talking (and, per the rule above, the model is run in that case).
The controller's leg goes straight to the vault and never through conversation-api, whose PaymentVaultClient deliberately has no set_field/finalize so a PAN can never transit that process. INTERNAL_SERVICE_TOKEN is accepted as a fallback for the bearer, the same as in conversation-api.
The S2S handoff¶
An S2S stage in a linear pipeline keeps its provider session connected but temporarily stops using it as the conversation path. The input-side controller replaces caller audio with silence and consumes DTMF; PaymentS2SOutputGate drops model audio, text, or speaking lifecycle travelling toward the caller once secure mode starts. Upstream lifecycle from the private TTS remains visible to the controller, and TTSSpeakFrame is the one scripted downstream allow through the gate.
If the authored pipeline already has a downstream TTS stage, the handoff reuses it. A bare S2S stage gets a private prompt-only TTS derived from the same integration: Nova Sonic uses Polly; OpenAI Realtime uses OpenAI TTS; Deepgram Voice Agent uses Aura; Google uses Cloud TTS; and an ElevenLabs stage can reuse its authored voice_id. PaymentPromptOnlyGate keeps ordinary S2S transcript text from reaching that private TTS and producing duplicate speech. The controller's set_s2s_handoff_ready latch is set only after both output gating and prompt TTS exist; begin_capture returns False before opening a usable capture if assembly is incomplete. This whole machinery — controller placement, both gates, the private TTS derivation (handoff_tts_stage_for_owner) — is also the substrate for Secure Data Capture (collect_secure_field), which subclasses the controller and shares the prompt lane; a change to any of it is a change to both capabilities. Since 2026-09-02 the per-provider TTS table itself lives in DATA — shared/service-definitions/aux-services.json behind the aux resolution ladder (stage aux.handoff_tts override → stack manifest → tenant aux_services default → that table; see scripted-subagents.md) — with the resolved fallback stage byte-identical to the old if/elif chain, pinned by tests/test_aux_resolution.py. Every value in that JSON carries an incident (the sections below); treat edits to it as behavior changes.
At exit, the controller lifts suppression and emits one LLMMessagesAppendFrame with the masked outcome. That frame reaches the still-connected realtime provider, which resumes normal S2S conversation without reconnecting or receiving any captured value.
The handback message only reaches the provider because the aggregator forwards it¶
LLMMessagesAppendFrame is consumed by pipecat's LLMUserAggregator: _handle_llm_messages_append adds the messages to the context, optionally pushes a context frame, and never re-pushes the frame itself. For a text LLM that is right — the context is the request. For an S2S provider whose session is already open, the context is not the wire: the message has to travel to the service, which converts it into a live turn. So the stock aggregator silently deleted the entire handback, with no error, no dropped-frame warning, and a perfectly healthy pipeline.
What the caller experienced was the second half of the user's report: the capture succeeded, the vault settled, the controller reported the outcome — and then the agent had nothing to say, so the caller had to speak first before being greeted back, and every subsequent "let me check on that payment for you" reported nothing back because the model had never been told the first result either. S2SUserAggregator subclasses the aggregator to add the messages to context and push the frame downstream; the empty-messages form (a bare "run now" trigger) still delegates to the base class. It is used wherever a realtime provider sits behind an aggregator. Regression: test_s2s_user_aggregator.py, whose negative control asserts the stock aggregator would have swallowed it — without that case the suite passes on the broken code.
Parallel/swimlane pipelines are rejected at publish and fail closed at runtime. Supporting them requires moving the secure input controller ahead of the parallel fan-out and applying one output gate to every speaking branch; putting the controller only inside the payment owner's branch is not a secure handoff.
The private prompt TTS runs at the provider's own rate, not the pipeline's¶
Every other stage is constructed with audio.pipeline_sample_rate, so the handoff's private TTS was too. For Polly that is correct — pipecat's PollyTTSService genuinely resamples to whatever rate it is handed. OpenAITTSService does not resample at all: its constructor warns (OpenAI TTS only supports 24000Hz sample rate) and then keeps the value anyway, run_tts always requests pcm (24 kHz is the only rate the API returns), and the frames are tagged with self.sample_rate. On a 16 kHz pipeline that produced 24 kHz audio labelled 16000, which the output transport then resampled 16000 → 24000 — stretching every scripted prompt 1.5×, slow and pitched down. Only inside secure mode, because nothing else in the call comes from that service, which is why it reads as a payments bug.
service_constants.py holds OPENAI_TTS_SAMPLE_RATE = 24000 and the openai entry of the aux handoff_tts table requests it via the {"$rate": "openai_tts"} token in place of the pipeline rate. The evidence that it is right is the absence of that warning next to 'sample_rate': 24000 on the __payment_handoff_tts stage in the build log. The evidence that it was wrong is a byte count, not a listen: the pipeline returned 341,760 bytes of ask_iban prompt where a direct synthesis of the identical text returns 312,000 — and a 1.5× stretch of the audio would have been 468,000, so the stretch is in the label and the resampler, not in the bytes the provider sent.
Be careful measuring this over the wire. The orchestrator forces 24 kHz output for an OpenAI-Realtime or Google-S2ST-native pipeline regardless of the rate the client asked for (uses_24k_output in grpc_provider.py), and AudioChunk carries no per-frame rate — so a client that measures durations against the rate it requested reports every utterance 1.5× long whether the bug is present or not. Use the rate ServerHello.output_audio_format reports.
The rule is per-service, not per-provider: a TTS service that resamples takes the pipeline rate, one that does not takes its own. Read the service's run_tts before threading a rate into it.
The private Polly voice must match the voice the caller has been hearing¶
The handoff TTS defaulted to Polly's neural engine while the Nova Sonic stage it hands off from speaks a generative Matthew. The caller hears one voice for the whole call and then a recognisably different, flatter one for the part where they are asked to key in a card number — precisely the moment the call has to sound like it is still the same system. It is not an error at any layer: neural is a valid engine, the synthesis succeeds, and only a listener notices.
The aux handoff_tts table's aws entry asks for generative, and aws_polly_services.py resolves it at create() time rather than trusting it: _resolve_engine calls describe_voices(LanguageCode=…, Engine="generative") once and downgrades to neural only when that voice genuinely lacks generative in that region. The probe matters because generative is region- and voice-limited and an unsupported engine fails at synthesis time, not at construction — the caller would be handed into secure capture and hear nothing at all. The failure directions are deliberately opposite: an unsupported voice downgrades (a different voice beats silence), while a describe_voices that cannot be reached leaves the request untouched rather than silently downgrading a setup that works. The build log line is the record of what was resolved: [Payments] Creating private Polly handoff TTS (region=…, voice=…, engine=…). Note the region is the integration's, not the host's ambient one — in us-east-1 Matthew supports ['generative','neural','standard']; us-west-1 supports neither of the first two.
When the form finishes, the voice side is told — through the capability-handler route¶
A capture that offers both surfaces (capture_surfaces: ["voice_dtmf", "multimodal_form"]) has two ways to finish, and until 2026-09-02 only one of them told the controller. PaymentModeController.complete_externally() existed for exactly this — the vault finalises through the form, so the controller must stop waiting for digits it will never receive — but it had zero production call sites: the voice half stayed in secure mode after the form had completed, burned max_retries_per_field + 1 field timeouts (measured 90.99 s against field_timeout_seconds: 30), and then _timeout_body → _exit(cancel="timeout") injected a cancellation the model dutifully announced — the caller was told the payment was cancelled after paying. Nothing in the logs objected: the tell was a contradiction between two truths — a settled payment_transactions row and a spoken cancellation — not an error anywhere.
The route that closes it has three parts, each deliberately additive:
- conversation-api ships the structured half beside the prose.
_announce(payments/service.py) already published asource_capability: "payments"system injection for an out-of-band settlement (announceis false on the synchronous voice path, so a keypad settle never takes this route); it now also carriesevent: {kind: "payment_outcome", transaction_id, status, succeeded, masked}in the same payload (http_api.py_publish_capability_injection(event=…)).statusis the STORED word, never derived fromsucceeded— the SEPA rule, again. - The injection consumer has a capability-handler seam.
ExternalInjectionProcessor.register_capability_handlergives a registered controller first claim on a TRUSTED capability injection, after the preempt/activity handling and before LLM dispatch. Consumed = the controller tells the model itself; not consumed (or a handler fault) = the ordinary dispatch, byte-identical to before. This seam is generic on purpose — it is also the external-write path the Guided-subagents design builds on. - The payments handler resolves the controller lazily.
register_payments_capability_handler(builders/universal/payments.py) is registered when the injection processor is created and readsruntime_context["_payment_mode_controller"]at CALL time — registration order doesn't matter, and a session with no payment capability simply never consumes. With a live capture it callscomplete_externally(status=…, masked=…), which exits quietly and appends the handback summary; the prose instruction is consumed, because delivering both would tell the model the same fact twice. With no live capture (the ACH-clearing-hours-later case) the handler declines and the prose reaches the model exactly as before.
Regressions: the capability-handler group in test_external_injection_processor.py (consume, fall-through, lazy resolution, untrusted, prose-only, raising handler) and test_announce_ships_the_structured_event_beside_the_prose in test_payments_record_outcome.py.
A tool that passes properties= must survive being wrapped¶
collect_payment returns FunctionCallResultProperties(run_llm=False) on a successful capture start — that is what stops the model narrating over the first scripted digit prompt. tool_input_healing.py wraps every registered tool and substitutes its own proxy for pipecat's result_callback, and that proxy stood exactly where pipecat's does while accepting only (value). So the kwarg was not ignored — it raised TypeError: _intercept() got an unexpected keyword argument 'properties' inside the tool, which pipecat reports as Error executing function call [collect_payment] and turns into an ErrorFrame.
The result: the capture had already started (on_capture_started is awaited before the callback returns), the controller owned the session and collected all 20 IBAN digits, the vault settled the transaction — and the model was told only that its tool had failed, so it apologised for a payment that was underway. Both proxies (_intercept on the first call, _capture on a retry) now take **kwargs and forward them untouched; the healing layer has no business rewriting result properties. Where a healed issue replaces the result, the properties are deliberately dropped — the model must be run on the issue, which is the entire point of healing it.
Two things make this failure hard to place. It is on the success path only, so every error-handling test passes; and any tool that never sets properties is unaffected, so it looks like a payments bug rather than a wrapper bug. Regression: the two properties-passing cases in test_tool_input_healing.py, which fail on the old signature with the production error verbatim.
How the leak is tested, and the two ways such a test passes while broken¶
test_payment_mode_controller.py drives a seeded canary PAN through the controller and asserts it appears nowhere. Two arrangements in it exist because the obvious version of that test passes on a controller that gates nothing:
- The sink is one hop past the gate, on the real leak path. The harness chains a genuine
DTMFInputHandlerbehind the controller rather than only capturingpush_frame, because that handler is what turns a digit into aTranscriptionFramefor the aggregator. It is paired with a positive control (test_inert_controller_lets_the_dtmf_handler_leak_digits) asserting the digits do arrive as[DTMF: 1]… when no capture is running — without which "no digits in the sink" is equally true of a harness that was never wired up. - Scanning is
repr-wide, not per-frame-type. Every frame the harness saw is scanned against the PAN, its prefix, the account and routing numbers and the CVV. A leak through a field nobody thought to check is the one worth catching. The canaries are chosen so a leak and a legitimate mention cannot be confused: the card'slast4is not a prefix of the PAN, and the account and routing tails deliberately differ, so a confirmation reading back the wrong field fails rather than passes.
FakeVault records (field_name, len(value)) and never the value — the same discipline as the vault's real mid-capture surface, and the reason a pytest -vv failure dump cannot print a card number.
Two properties are asserted on the builder's syntax tree, because they are facts about assembly that no built pipeline records:
test_the_controller_is_appended_before_the_dtmf_handler_and_the_aggregatorclassifies the calls inside each_build_*and requires everychain.append(user_aggregator)andDTMFInputHandler()to be preceded by an_insert_payment_controllerin the same builder. It checks every aggregator rather than just the first event, because_build_branchhas two insertion points and gating only the first is the realistic mistake. It also pins the set of builders that append an aggregator, so a fourth one is a test failure rather than an ungated path.- The S2S tests assert that caller audio becomes equal-length silence, capture refuses until the prompt lane is ready, buffered model output is dropped while
TTSSpeakFrameis allowed, normal transcript text cannot trigger the private TTS, and a bare Nova stage derives Polly from the same integration. - The speech-return tests assert that pre-model audio becomes equal-length silence, both audio VAD and transport speech events carry activity only, the default/custom return key cancels with no partial capture state retained, and ordinary star/hash behaviour remains unchanged until the return choice is armed.
Matching on source strings would confirm those lines exist somewhere in a 7,000-line module, not that they are in the same function in that order.
Storage¶
030_payments.sql — registered in the migrate-city-db.sh Phase C loop, without which it applies on fresh boots and CI but is silently skipped on every populated database.
payment_transactions— tenant/conversation/agent ids, provider,provider_payment_id,method_type,direction(charge|refund),amount_minor,currency,status,masked_detailsjsonb,reference, error code. Tenant RLS.payment_events— append-only audit (anUPDATE/DELETEtrigger refuses), surviving the deletion of the transaction it describes. The sequence is the evidence.
Two defences in the database itself, so a bug in any service cannot land a PAN: masked_details accepts only a fixed key set, and payments_reject_long_digit_runs() refuses any detail object containing a run of seven or more digits.
conversation_id on both tables is free text matched with =, not a uuid FK — so the routes' _guard canonicalizes to the conversation's session_id before any store call. A capture opened on a phone call (addressed by session id) and a form submitted from the browser surface (addressed by the uuid its share code resolved to) must land on the same rows. Same seam, same reasoning, as Customer Authentication: conversation-api.md.
The provider needs a row as well as a declaration¶
stripe is declared in integration-capabilities.ts and seeded into the integration_types table, and both are load-bearing. The catalog a tenant browses is served from the TypeScript (GET /api/integrations/types reads INTEGRATION_SOURCES and never touches the table), but POST /api/integrations validates the submitted type_id against the table and answers 400 Invalid integration type: stripe for an id it does not find. Declaring the provider in TypeScript alone therefore makes it offerable and not creatable: it appears in the picker, and saving fails with an error naming the type rather than the missing row — the same shape as the Genesys form whose section list was hardcoded, where the declaration was correct and a second, unrelated list had to agree with it.
The seed lands in two files for two different audiences. 001_reference_data.sql is the materialized canonical set a fresh boot applies and pg_dump re-emits; 030_payments.sql is what reaches an already-populated database, because migrate-city-db.sh replays a hand-listed set from deploy/init-scripts/ and 001 is not in it. A seed added only to 001 works locally and in CI and is silently absent on vanguard and every city EC2. The 030 copy uses ON CONFLICT (id) DO UPDATE rather than DO NOTHING: there is no UI that edits this row, so there is no operator change to clobber, and converging is what stops a first-boot copy drifting from the catalog forever. The TypeScript schema still wins at validation time (source?.schema ?? integrationType.schema), so the table's copy is the fallback for a caller arriving with a bare type_id and the data dictionary for anyone reading the database instead of the catalog.
category is payments, a new value beside ai_service/ccaas/crm/event_source/infrastructure. The column is a free varchar(50) with no CHECK, and nothing switches on the value except the optional ?category= list filter.
Status by phase¶
| Phase | Ships | State |
|---|---|---|
| 1 | Capability card, tenant settings, Stripe integration source, payment_transactions/payment_events, three tools wired end to end, publish validation, generated schema |
done |
| 2 | apps/payment-vault/ + Stripe adapter + charts (payment-vault: {enabled: false}); refunds and status go live; PAN canary suite |
done |
| 3 | Voice DTMF secure flow: pre-STT/realtime speech activity + silence gate, configurable speech-return key/scripts, payment_mode_controller.py upstream of DTMFInputHandler, recorder set_suppressed, publisher suppression window, leak + placement tests |
done (static-prompt TTS cache deferred) |
| 4 | Multimodal hosted-fields iframe (frame_page.py), frame_url on an opened capture |
form done; voice↔form sync done (2026-09-02) — see When the form finishes, the voice side is told. payment_status SSE event deferred (the outcome reaches the caller through the agent's own speech; a surface reads it with get_payment_status) |
| 5 | Chat text guard at conversation-api post_response |
pending |
| 6 | S2S secure handoff, vault webhooks (ACH settlement, disputes), compliance docs | S2S handoff done; webhooks/compliance pending |
Limits you cannot engineer away¶
- Upstream CCaaS recordings. Amazon Connect and Genesys record upstream of the TrueServe media path, so suppressing the platform's own recorder does not suppress theirs. Connect's
SuspendContactRecordingis a future hook; the scv-transcript-gate lambda is the precedent that per-CCaaS suppression is possible. - Vault sessions are in memory. A restart mid-capture loses the session and the caller retries. Multi-replica needs sticky routing; start single-replica.
- Non-code PCI work is out of scope of this repository: SAQ attestation, ASV scans, penetration test, key ceremony, provider AOC, staff training.
See also¶
- payment-vault — the capture service: memory-only sessions, the four redaction layers, adapters, deployment
- User guide: Taking payments
- Customer Authentication — the capability this one is modelled on
- Universal pipeline builder — where the tools and prompt are wired
- Secret storage — how the Stripe credentials are encrypted
Payment failure notices report the stored outcome and defer recovery to the owning agent's configured procedure. They do not unconditionally tell the agent to offer a retry, since some procedures require settlement with an office. Failure itself never authorizes another charge; the structured transaction/status/masked-details event remains unchanged.