Ken Insurance
14 intents, one state machine, and the Stripe checkout that binds the policy.
Ken is a separate business. Separate legal entity (Jamboree Insurance), separate codebase, separate compliance requirements. HO4 renters insurance underwritten by a Fortune 500 carrier. The product: $100,000 liability coverage + $10,000 personal property protection, delivered via SMS to renters who need proof of insurance before their leasing office hands over keys.
The critical rule: NEVER hardcode a price. Every dollar amount comes from the carrier's rating engine. If Ken quotes a premium before calling the carrier API, that price is a hallucination. If it is too low, the renter signs, discovers the real price at checkout, cancels, and files a complaint with the state insurance commissioner. If it is too high, they walk. Either direction: legal liability. The classifier, the state machine, the templates, the followup scheduler -- all of it exists to collect an address, call the API, and deliver the real number.
Six files. classifier.py, ken.py, templates.py, routes.py, checkout.py, followup.py.
Intent Classification -- 14 Intents
Every inbound SMS passes through a regex classifier. No LLM in the loop. The classifier returns an intent, a sentiment, a confidence score, and extracted data. Here are the patterns from production, grouped by category:
# TERMINAL STOP — r'\bstop\b', r'\bunsubscribe\b', r'\bopt\s*out\b', r'\bremove\s*me\b', r'\bcancel\b', r'\bleave\s*me\s*alone\b' → Opt out immediately. No reply. TCPA. Confidence: 0.99 WRONG_NUMBER — r'\bwrong\s*(number|person)\b', r"\bdon'?t\s*know\s*(what|who)\b" → Same as STOP. Lead gets opted out. # ESCALATION WANT_CALL — r'\bcall\s*me\b', r'\bspeak\s*(to|with)\s*(someone|a\s*person)\b' → Creates escalation ticket. Priority: high. # OBJECTIONS ALREADY_HAS — r'\balready\s*(have|got|covered)\b', r'\b(using|through|with)\s*(state\s*farm|geico|lemonade)\b' → Verify they have $100k liability. Most don't. PRICE_QUESTION — r'\bhow\s*much\b', r'\bwhat\'?s?\s*(the\s*)?(cost|price)\b' → NEVER answer with a number. Redirect to address collection. IS_THIS_SCAM — r'\bscam\b', r'\bfraud\b', r'\bspam\b', r'\bsketchy\b' WHO_IS_THIS — r'\bwho\s*(is\s*this|are\s*you)\b', r'\bwhat\s*company\b' IS_THIS_AI — r'\b(are\s*you|is\s*this)\s*(a\s*)?(ai|bot|robot)\b' → Mandatory disclosure. "I'm Ken, an AI assistant." FOXEN_MENTION — r'\bfoxen\b', r'\bbuilding\s*(provides?|has|offers?)\b' → "Foxen is a waiver, not insurance. Covers the building, not you." WILL_DO_LATER — r'\b(maybe\s*)?later\b', r'\bbusy\b', r'\bnot\s*a\s*good\s*time\b' NOT_INTERESTED — r'\bnot?\s*interested\b', r'\bno\s*thanks?\b', r'\bnope\b' # POSITIVE YES_INTERESTED — r'^yes+\b', r'^yeah+\b', r'^sure\b', r'^ok(ay)?\b', r'^let\'?s?\s*do\s*it\b', r'^send\s*(it|the\s*link)\b' # INFO EXTRACTION GAVE_EMAIL — r'\b[\w\.-]+@[\w\.-]+\.\w+\b' Confidence: 0.95 GAVE_DATE — r'\b(\d{1,2})[/\-](\d{1,2})\b', r'\b(tomorrow|today)\b' GAVE_UNIT — r'\b(unit|apt|#)\s*(\d+\w*)\b' Confidence: 0.7 GAVE_BUILDING — short message, no other match Confidence: 0.6
Priority order matters. STOP always wins. Email is checked before YES because "send it to john@gmail.com" should extract the email, not classify as positive. The classify function walks each pattern set in sequence and returns the first match:
def classify_message(message: str) -> Classification:
text = message.lower().strip()
extracted = {}
# STOP first (highest priority)
for pattern in STOP_PATTERNS:
if re.search(pattern, text, re.IGNORECASE):
return Classification(Intent.STOP, Sentiment.NEGATIVE, 0.99, {})
# Email early (before YES steals "send it to john@...")
for pattern in EMAIL_PATTERNS:
match = re.search(pattern, text, re.IGNORECASE)
if match:
return Classification(Intent.GAVE_EMAIL, Sentiment.POSITIVE,
0.95, {'email': match.group(0)})
# WRONG_NUMBER → WANT_CALL → YES → ALREADY_HAS → PRICE
# → LATER → WHO_IS_THIS → SCAM → AI → FOXEN
# → NOT_INTERESTED → DATE → UNIT → BUILDING
# Default: unclear
return Classification(Intent.UNCLEAR, Sentiment.NEUTRAL, 0.3, {})
Confidence scores descend as intents become ambiguous. STOP at 0.99. A 3-digit number guessed as a unit? 0.7. A short phrase guessed as a building name? 0.6.
The State Machine -- 14 States, 16 Events
If a renter texts "stop" after receiving a quote, they are opted out. If they text "yes" before giving an address, they get asked for an address. If they text "yes" after a quote but the payment link is missing, the system re-triggers quote generation instead of sending a template with a literal "[LINK]" placeholder. Every path is a state transition.
class LeadStatus(Enum):
NEW = "new"
CONTACTED = "contacted"
ENGAGED = "engaged"
INFO_GATHERED = "info_gathered"
QUOTED = "quoted"
PAYMENT_SENT = "payment_sent"
CONVERTED = "converted"
NO_RESPONSE = "no_response"
FOLLOWUP_1 = "followup_1"
FOLLOWUP_2 = "followup_2"
FOLLOWUP_3 = "followup_3"
EXPIRED = "expired"
OPTED_OUT = "opted_out"
HUMAN_ESCALATION = "human_escalation"
TRANSITIONS = {
# Normal flow
(NEW, MESSAGE_SENT): CONTACTED,
(CONTACTED, MESSAGE_RECEIVED): ENGAGED,
(CONTACTED, TIMEOUT_4H): NO_RESPONSE,
(CONTACTED, OPTED_OUT): OPTED_OUT,
# Follow-up sequence
(NO_RESPONSE, FOLLOWUP_SENT): FOLLOWUP_1,
(FOLLOWUP_1, MESSAGE_RECEIVED): ENGAGED,
(FOLLOWUP_1, TIMEOUT_24H): FOLLOWUP_2,
(FOLLOWUP_2, MESSAGE_RECEIVED): ENGAGED,
(FOLLOWUP_2, TIMEOUT_48H): FOLLOWUP_3,
(FOLLOWUP_3, MESSAGE_RECEIVED): ENGAGED,
(FOLLOWUP_3, TIMEOUT_72H): EXPIRED,
# Conversion flow
(ENGAGED, INFO_COLLECTED): INFO_GATHERED,
(ENGAGED, OPTED_OUT): OPTED_OUT,
(ENGAGED, ESCALATION_REQUESTED): HUMAN_ESCALATION,
(INFO_GATHERED, QUOTE_SENT): QUOTED,
(QUOTED, PAYMENT_LINK_SENT): PAYMENT_SENT,
(QUOTED, OPTED_OUT): OPTED_OUT,
(PAYMENT_SENT, PAYMENT_COMPLETED): CONVERTED,
(PAYMENT_SENT, OPTED_OUT): OPTED_OUT,
}
The happy path is a river: NEW, CONTACTED, ENGAGED, INFO_GATHERED, QUOTED, PAYMENT_SENT, CONVERTED. Seven states, straight line. Every other state is a tributary -- places the lead gets stuck, and mechanisms to pull them back.
OPTED_OUT is reachable from CONTACTED, ENGAGED, QUOTED, and PAYMENT_SENT. Four entry points. The moment someone says "stop," the current just stops. That is not a design choice. That is federal law.
A lead that responds after FOLLOWUP_2 goes straight back to ENGAGED. The system does not hold grudges.
The Followup Schedule
Five attempts. Intervals from the database query:
(status = 'contacted' AND last_outbound_at < NOW() - INTERVAL '4 hours') (status = 'followup_1' AND last_outbound_at < NOW() - INTERVAL '24 hours') (status = 'followup_2' AND last_outbound_at < NOW() - INTERVAL '48 hours') (status = 'followup_3' AND last_outbound_at < NOW() - INTERVAL '120 hours') (status = 'no_response' AND last_outbound_at < NOW() - INTERVAL '168 hours') AND (last_inbound_at IS NULL OR last_inbound_at < last_outbound_at) ORDER BY CASE urgency_tier WHEN 'emergency' THEN 1 WHEN 'critical' THEN 2 WHEN 'red' THEN 3 WHEN 'orange' THEN 4 WHEN 'yellow' THEN 5 ELSE 6 END, last_outbound_at
4 hours. 24 hours. 48 hours. 120 hours. 168 hours. After 168 hours (7 days) with no response, the lead expires. The AND clause prevents followups to leads who already responded. The ORDER BY prioritizes by urgency first -- a lead moving in tomorrow gets the followup before a lead moving in 30 days.
Pre-quote followups (no address yet) focus on requirement urgency:
# 4h: Requirement "{first_name} - following up on the renters insurance. It's required in {state} before your landlord will hand over keys. Send me the address and I'll get you a quote in 2 minutes." # 24h: Consequence "Hey {first_name}, circling back. I've seen people get stuck at key pickup because they didn't have insurance ready." # 48h: Easy "{first_name} - still need to get you sorted. It's required either way, let's just knock it out. Send me the address." # 5d: Last minute "Your leasing office will ask for proof of insurance before keys. I can get you covered same-day - just need the address." # 7d: Soft close "{first_name}, last message from me on this. If you've got coverage handled, ignore me. If not, send the address."
Post-quote followups echo the renter's own statements and include the payment link:
# Post-quote 1: Echo + link "Link's still ready for {address}. You said {move_date_statement} - let's get this crossed off. {payment_link}" # Post-quote 2: Checklist pressure "You're {days_until} days out. Leasing office is gonna ask for: proof of insurance, signed lease, deposit, ID... ${monthly}/mo, 30 seconds: {payment_link}" # Post-quote 3: Urgency "You mentioned {move_date_statement} - that's coming up fast. I've seen people get stuck at key pickup. Your quote's still locked at ${monthly}/mo: {payment_link}"
"I've seen people get stuck at key pickup" appears twice across the template set. It is the single most effective line in the system. Not because it is clever. Because it is true.
Message Templates
Ken's templates follow reverse selling. Instead of pushing insurance onto the renter, take it off their plate. "Let me handle this so you're not scrambling at key pickup." Three initial outreach variations rotate:
INITIAL_OUTREACH = """Hey {first_name} - we got flagged that
you're moving into a rental in {city}. I'm reaching out to
get you set up with renters insurance - it's required before
you can get keys in {state}. Apologies if this info is out
of date, just let me know."""
The "apologies if this info is out of date" is not politeness. It is compliance. We are texting people who did not ask to hear from us. The out gives them a non-confrontational exit before they say "stop" and trigger the opt-out machinery.
The quote template. Only fires AFTER the carrier API returns a real premium:
QUOTE_STANDARD = """{first_name}, here's your quote for {address}:
${monthly}/mo (${annual}/year)
$100k liability + $10k personal property
Moving is stressful enough - let me get this off your plate.
Apple Pay takes 30 seconds: {payment_link}
Once done, you get the signed policy instantly and I'll send
it to your leasing office. One less thing."""
The payment link is always included with the quote. The system never asks "ready for the link?" That extra round trip kills conversions. Apple Pay reduces payment to 30 seconds.
Objection responses redirect back to address collection:
# "Already have insurance" "Got it. Just make sure your policy has $100k liability - that's what most {state} landlords require." # "Is this a scam?" "We're licensed in {state} and work with one of the largest insurance carriers in the country." # "Foxen / building provides it" "What buildings offer is usually a liability waiver, not actual renters insurance. It covers the building, not your stuff." # "Too expensive" (after quote) "That's ${monthly}/mo - works out to about ${daily} a day. And it's required by your landlord. The alternative is not getting your keys." # STOP "Got it - you won't hear from me again. Take care."
The STOP_CONFIRM is 9 words. It does not try to retain. It does not ask why. Anything more is a TCPA violation.
The Carrier API Flow
Two steps: authenticate, then quote. Card data never touches our servers.
Step 1: OAuth 2.0 authentication. Client credentials grant. Token lasts 7200 seconds. Cached with a 300-second buffer to avoid mid-request expiry:
async def _get_token(self) -> str:
if self._token and self._token_expires and datetime.now() < self._token_expires:
return self._token
auth = base64.b64encode(
f"{self.config.client_id}:{self.config.client_secret}".encode()
).decode()
response = await client.post(self.config.token_url, headers={
"Authorization": f"Basic {auth}",
"Content-Type": "application/x-www-form-urlencoded",
}, data={"grant_type": "client_credentials", "scope": self.config.scope})
data = response.json()
self._token = data["access_token"]
self._token_expires = datetime.now() + timedelta(
seconds=data.get("expires_in", 7200) - 300
)
return self._token
Step 2: Create quote. Address, state, coverage levels, underwriting defaults. Standard tier: $100,000 liability, $10,000 personal property (or $20,000 default), $250 deductible, dwelling type "A" (apartment):
quote_request = {
"transaction": {
"clientId": "JAM", # Jamboree
"referralId": "SMS", # Track channel
"productType": "RI",
"policyEffectiveDate": effective_date
},
"policyHolder": {"insured": {"address": {
"address1": address, "city": city,
"state": state, "postalCode": zipcode
}}},
"underWriting": {"questions": [
{"questionName": "dwellingType", "answer": "A"},
{"questionName": "mustHaveInsurance", "answer": "Y"},
{"questionName": "howManyLosses", "answer": "0"},
{"questionName": "hadAnimalInjury", "answer": "N"},
]},
"coverage": {"parameters": [
{"name": "personalPropertyAmount", "value": "10000"},
{"name": "liabilityAmount", "value": "100000"},
{"name": "deductible", "value": "250"},
{"name": "replacementCost", "value": "Y"},
]}
}
response = await client.post(
f"{api_base}/renters/quote/v2/",
headers=self._get_headers(token),
json=quote_request,
timeout=30.0
)
The response returns a workItemId, premium amount, and payment schedules. The parser extracts the full-pay option:
def parse_quote_response(response):
tx = response.get("transactionDetails", {})
premium = response.get("premiumDetails", {})
payments = tx.get("payments", {}).get("schedules", [])
return {
"work_item_id": tx.get("workItemId"),
"premium": premium.get("premiumAmount"),
"monthly_estimate": f"{float(premium.get('premiumAmount', 0)) / 12:.2f}",
"payment_options": [{"payments": p["numberOfPayments"],
"down_payment": p["downPaymentAmount"]}
for p in payments],
"denied": tx.get("denialNotice") is not None,
}
What happens when the API is down. The route handler has a fallback: state-based premium estimates (TX: $168, IL: $156, MI: $144). Marked "estimated": True in metadata. The renter sees a real number, but the system knows it might shift. If they complete checkout at the estimated price and the real price differs, the system absorbs the delta. The alternative -- "our pricing system is down, try again later" -- loses the lead forever.
The Checkout Flow
The payment link points to /checkout/{lead_id}. Server-rendered HTML with the renter's name, building, coverage breakdown, and price pre-filled. No login. No account creation. Click, see price, tap Apple Pay.
# Personalized header "Hey {first_name}, you're almost covered!" # Coverage $100,000 liability + $10,000 personal property # Price (from carrier API, NEVER hardcoded) ${monthly}/mo (${annual}/year) # Trust badges Secure Payment | Carrier Backed | Instant Coverage # Payment via PCI Level 1 tokenizer # Card data never touches our servers
The payment gateway is a PCI Level 1 tokenization service. It tokenizes card details client-side and sends a payment method token to our backend. Our backend forwards that token to the carrier for policy issuance. At no point does raw card data pass through our servers.
The success page has confetti. The renter just made a purchase they did not want to make, for a product they are legally required to have. The confetti makes it feel like they accomplished something instead of paying a bill. Measurable impact on support ticket volume.
Webhook Handling
The Twilio webhook is the front door. Every inbound text hits /twilio/incoming. Three things in order: validate signature, check for opt-out, process message. Signature validation and opt-out are synchronous. Message processing is async (background task) because Twilio has a 15-second timeout -- exceed it and Twilio retries, generating duplicate messages.
@router.post("/twilio/incoming")
async def twilio_incoming(request, background_tasks):
form = await request.form()
data = dict(form)
if not sms.validate_webhook(url, data, signature):
raise HTTPException(status_code=403)
from_number = data.get('From', '')
body = data.get('Body', '')
# TCPA GATE: opt-out check is SYNCHRONOUS
if compliance and compliance.is_opt_out_message(body):
await compliance.process_opt_out(phone=from_number)
if lead:
await db.update_lead(lead['id'], {
'status': 'opted_out',
'opted_out_at': datetime.utcnow(),
})
return Response(content="", media_type="text/xml")
# Non-opt-out: process in background
lead = await db.get_lead_by_phone(from_number)
if lead:
background_tasks.add_task(
process_inbound_message,
lead['id'], body, message_sid, request.app
)
return Response(content="", media_type="text/xml")
The payment webhook handles success and failure. Payment success triggers policy creation and a confirmation SMS. Failure increments a retry counter. Signature verification happens before processing -- without it, anyone could POST fake payment confirmations and generate fraudulent policies.
Policy Generation
Policy number format: JMB-YYYY-{hex}. JMB = Jamboree. YYYY = bind year. Hex = first 8 characters of the lead UUID, uppercased. Deterministic from the lead ID -- trace any policy back to its lead without a database lookup.
async def process_successful_payment(lead_id, transaction_token, app):
effective_date = datetime.utcnow().date()
expiration_date = effective_date + timedelta(days=365)
policy_number = f"JMB-{datetime.utcnow().strftime('%Y')}-{str(lead_id)[:8].upper()}"
policy_id = await db.create_policy({
'lead_id': lead_id,
'policy_number': policy_number,
'premium_monthly': (lead_data.get('quote_premium') or 180) / 12,
'premium_annual': lead_data.get('quote_premium') or 180,
'effective_date': effective_date,
'expiration_date': expiration_date,
'payment_transaction_id': transaction_token,
})
await db.update_lead(lead_id, {
'status': 'converted',
'converted_at': datetime.utcnow(),
'policy_number': policy_number,
})
# Schedule LTV lifecycle (welcome, check-ins, renewal)
if ltv:
await ltv.schedule_touchpoints(policy_id, lead_id,
effective_date, expiration_date)
# SMS: "Done! You're covered, {first_name}."
confirmation = ken.handle_payment_completed(lead)
The LTV scheduler fires after policy creation. Welcome check-in at day 7, satisfaction pulse at day 30, mid-term at 6 months, renewal notice 45 days before expiration. Ken's job ends when the policy is bound. Everything after is retention.
The Followup Job
Runs every 15 minutes. Pulls up to 100 leads, ordered by urgency tier then staleness. For each lead: load full object with metadata, generate context-aware followup through Ken, send via Twilio with compliance checking, log, update status.
async def process_followups(db, sms, ken):
leads = await db.get_leads_needing_followup(limit=100)
sent = 0
for lead_data in leads:
lead = Lead(
id=lead_data['id'],
phone=lead_data['phone'],
status=LeadStatus(lead_data['status']),
metadata=lead_data.get('metadata') or {},
# ... all fields loaded for personalization
)
message = ken.handle_timeout(lead)
if message:
result = await sms.send_sms_checked(
to_number=lead.phone, message=message,
state=lead.state or 'TX', lead_id=lead.id)
await db.log_message(lead_id=lead.id,
direction='outbound', content=message,
template_id=f'followup_{lead.followup_count}')
await db.update_lead(lead.id, {
'status': lead.status.value,
'followup_count': lead.followup_count,
'last_outbound_at': datetime.utcnow()})
sent += 1
await asyncio.sleep(1) # Rate limit
return sent
The asyncio.sleep(1) is our rate limit, not Twilio's. If 100 followups fire in 2 seconds and 30 reply immediately, the inbound handler floods. The 1-second pause spreads load. 100 leads in under 2 minutes.
A separate daily job, expire_past_move_ins, finds every active lead whose move-in date has passed and marks them EXPIRED. Ken stops texting.
Statement Tracking
Every inbound message is scanned for trackable statements. When a renter says "I'm moving next week," that phrase gets stored in lead.metadata['statements_made']['move_date']. When the followup fires, the template echoes it: "You said next week -- that's coming up fast."
def _extract_statements(self, lead, message):
message_lower = message.lower()
# Move date mentions
for phrase in ['next week', 'this week', 'tomorrow',
'in a few days', 'end of month']:
if phrase in message_lower:
self._track_statement(lead, 'move_date', phrase)
break
# Staff name mentions
name_match = re.search(r'(?:with|to|from)\s+([A-Z][a-z]+)', message)
if name_match:
self._track_statement(lead, 'contact_name', name_match.group(1))
# Busy/stress signals
for phrase in ['busy', 'lot going on', 'swamped', 'stressed']:
if phrase in message_lower:
self._track_statement(lead, 'busy', message)
break
def build_urgency_from_statements(statements, days_until_move=None):
parts = []
if statements.get('move_date') and days_until_move:
parts.append(f"You said {statements['move_date']} - "
f"that's {days_until_move} days.")
if statements.get('busy'):
parts.append("I know you've got a lot going on. "
"Let me take this off your plate.")
if not parts:
parts.append("Moving is stressful enough - "
"let's cross this off the list.")
return " ".join(parts)
"You said next week" is five words and it changes the psychology of the conversation. The renter is no longer being sold to by a stranger. They are being held to something they said themselves. Their own words become the urgency mechanism.
Database Schema
Three tables. PostgreSQL. UUIDs for primary keys, JSONB for metadata.
-- insurance_leads: one row per renter CREATE TABLE insurance_leads ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), phone TEXT NOT NULL UNIQUE, first_name TEXT, email TEXT, building_name TEXT, building_address TEXT, unit TEXT, city TEXT, state TEXT NOT NULL, zipcode TEXT, move_in_date DATE, status TEXT NOT NULL DEFAULT 'new', urgency_tier TEXT NOT NULL DEFAULT 'green', last_outbound_at TIMESTAMPTZ, last_inbound_at TIMESTAMPTZ, followup_count INT DEFAULT 0, quote_premium NUMERIC(10,2), payment_link_url TEXT, policy_number TEXT, metadata JSONB DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT NOW(), converted_at TIMESTAMPTZ, opted_out_at TIMESTAMPTZ ); CREATE INDEX idx_leads_phone ON insurance_leads(phone); CREATE INDEX idx_leads_status ON insurance_leads(status); -- Partial index: only active leads, keeps followup query fast CREATE INDEX idx_leads_followup ON insurance_leads(status, last_outbound_at) WHERE status NOT IN ('converted', 'expired', 'opted_out'); -- conversations: every SMS sent or received CREATE TABLE conversations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), lead_id UUID NOT NULL REFERENCES insurance_leads(id), direction TEXT NOT NULL, -- 'inbound' or 'outbound' content TEXT NOT NULL, twilio_sid TEXT, intent TEXT, sent_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX idx_conv_lead ON conversations(lead_id); -- policies: one row per bound policy CREATE TABLE policies ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), lead_id UUID NOT NULL REFERENCES insurance_leads(id), policy_number TEXT NOT NULL UNIQUE, carrier TEXT, premium_monthly NUMERIC(10,2), premium_annual NUMERIC(10,2), liability INT DEFAULT 100000, personal_property INT DEFAULT 10000, effective_date DATE NOT NULL, expiration_date DATE, payment_transaction_id TEXT, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX idx_policies_lead ON policies(lead_id); CREATE INDEX idx_policies_expiry ON policies(expiration_date);
The partial index idx_leads_followup is the one that matters most. The followup job runs every 15 minutes and queries by status and last_outbound_at. Without it, the query scans the entire table. With it, only active leads get touched. As converted and expired leads accumulate, the partial index keeps the followup query fast.
The metadata JSONB column holds everything that does not deserve its own column: statements_made, building_contact, property_lookup results, quote_data from the carrier. It is the scar tissue of the conversation -- everything Ken learned about this person, stored in a format that survives schema changes.
What Fails
Three failure modes that matter.
First: carrier API timeout. The 30-second timeout. If the carrier is slow during maintenance windows (Sunday 2-6 AM CT), the renter gets "One sec, getting your numbers..." and then the fallback estimate fires. But if they already closed the conversation, that quote lands in a dead thread. The followup job picks it up 4 hours later. By then, momentum is gone.
Second: the renter gives a building name, not an address. "The Lydian." Ken triggers a property lookup in the background and asks for the unit number. If the lookup returns 3 matches, the renter gets a numbered list. If it returns 0 -- misspelling, new construction not in Maps -- they get "Can you send me the full address?" That round trip adds 2-4 messages, each one a chance to disengage.
Third: TCPA liability. The opt-out gate in the webhook is the last line of defense. But the real risk is upstream: did the building have proper consent to share this renter's phone number? If a renter says "how did you get my number" and the answer is "your building gave it to us," we are one complaint away from a $500-$1,500 per-message fine. The WHO_IS_THIS handler deflects. It does not eliminate the consent question. That is a business problem, not a code problem.