← Back to Assets

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.