Subscription cancel-save flows are easy to over-engineer into manipulative dark patterns, and easy to under-engineer into "your subscription has been cancelled, goodbye" experiences that lose revenue. A good cancel-save agent does three things: classifies the reason, offers one relevant save option, and respects a direct cancellation request.
The canonical code example is in the Telnyx code examples repo:
https://github.com/team-telnyx/telnyx-code-examples/tree/main/ai-subscription-cancel-save-retention-agent-python
What This Example Builds
This Python Flask example combines Telnyx Voice, AI Inference, and Messaging into a cancel-save workflow that:
- Verifies the customer by inbound caller ID
- Asks why they want to cancel
- Classifies the reason with AI Inference into one of seven categories:
too_expensive,not_using,missing_feature,support_issue,competitor_switch,temporary_pause,other - Detects angry customers (lawyer, sue, fraud, chargeback, BBB) and transfers immediately to a human
- Offers one save option based on the reason (discount, onboarding call, roadmap note, support callback, comparison call, pause)
- Records the outcome (
saved,cancelled,paused,transferred,needs_followup) and updates the customer record - Tracks call hangups as
needs_followupso reps can call back
Why This Is A Useful Voice AI Example
This example is small enough to read in one sitting, but it covers the moving parts a developer needs for a real cancel-save workflow:
- Single-prompt AI Inference classification that returns structured JSON (
reason,sentiment,wants_human,wants_pause,summary) - An offer policy that maps reason to a single offer and a default outcome
- Hard-coded override for urgent phrases so angry or threatening customers transfer immediately
- Direct-cancel short-circuit so a customer who says "cancel now" never sees an offer
- One clarifying prompt on ambiguous yes/no so the conversation does not loop
- Idempotent webhook handling so retries do not double-log cases
That makes it a good starting point for any inbound voice agent that needs to classify a request into a fixed set of buckets and apply a different policy per bucket.
Products Used
The example metadata lists:
telnyx_products: [Voice, AI Inference, Messaging]
language: python
framework: flask
Voice handles the call and the TTS. AI Inference classifies the reason. Messaging sends confirmation SMS.
Architecture
inbound phone call
-> telnyx voice api webhook
-> flask app
-> look up customer by caller id
-> if not found or already cancelled: end politely
-> ask why cancel
-> ai inference: classify reason + sentiment
-> urgent phrases (lawyer, sue, fraud) -> transfer immediately
-> direct cancel phrases (cancel now, etc) -> cancel immediately
-> else: offer one save option from OFFER_POLICY
-> yes: apply outcome (saved/paused/needs_followup) and update customer
-> no: cancel gracefully
-> ambiguous: ask one clarifying question, never loop
-> hangup before resolution: outcome = needs_followup
State lives in memory (customers, retention_cases, calls) for the demo. For production, wire the customer and case stores into your billing system (Stripe, Recurly, Chargebee) so a saved outcome actually applies the discount and a paused outcome defers the next invoice.
Run The Example
git clone https://github.com/team-telnyx/telnyx-code-examples.git
cd telnyx-code-examples/ai-subscription-cancel-save-retention-agent-python
cp .env.example .env
pip install -r requirements.txt
python app.py
Fill in the environment variables:
TELNYX_API_KEY=KEY...
TELNYX_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----..."
MAIN_NUMBER=+1...
CONNECTION_ID=...
HUMAN_ESCALATION_NUMBER=+1...
AI_MODEL=moonshotai/Kimi-K2.6
PORT=5000
Expose the local webhook:
ngrok http 5000
Configure the Voice API application webhook URL:
https://<ngrok-id>.ngrok-free.app/webhooks/voice
Demo Script
Seed a customer first:
curl -X POST http://localhost:5000/customers \
-H "Content-Type: application/json" \
-d '{"customer_id": "CUST-001", "name": "Jordan", "phone": "+15551112233", "plan": "pro"}'
Call the Telnyx number from +15551112233.
hi
i want to cancel my subscription
it's too expensive
yes please do that
Expected result:
- Case is recorded with
outcome: saved,reason: too_expensive - Customer status flips to
activewith the save applied
Try the no path next:
i want to cancel my subscription
it's too expensive
no thanks
Expected result:
- Case is recorded with
outcome: cancelled - Customer status flips to
cancelledwithcancelled_attimestamp
Try the transfer path:
i want to cancel my subscription
i want a lawyer this is fraud
Expected result:
- Case is recorded with
outcome: transferred - Call is transferred to
HUMAN_ESCALATION_NUMBER
Inspect the cases:
curl http://localhost:5000/retention-cases | python3 -m json.tool
Production Considerations
Before using this pattern in production, add:
- Persistent storage: replace the in-memory dicts with your billing-system integration
- Real customer verification: replace the caller-ID match with a one-time code flow
- Consent recording: add
record_channels: "dual"on the answer action for compliance audits - Real offer application: wire offer acceptance into billing so a
savedoutcome actually applies the discount - Real pause: wire
pausedinto billing to defer the next invoice - Multi-language system prompts and TTS voices per customer locale