How Caller Identity + History Should Be Stored
Comprehensive architectural guide for storing caller identity and conversation history at scale.
Introduction
Storing caller identity and conversation history is deceptively complex. You need to:
- Identify the same person across different phone numbers
- Match with high confidence but avoid false positives
- Store context efficiently while complying with privacy laws
- Retrieve data in under 200ms for real-time call handling
- Scale to millions of callers
- Handle edge cases (number recycling, family plans, spoofing)
This guide covers:
- Identity resolution fundamentals
- Data model design patterns
- Storage and retrieval strategies
- Privacy and compliance
- Scaling considerations
- Build vs. buy decision framework
Target audience: Technical architects, CTOs, senior engineers planning caller memory systems.
Identity Resolution Fundamentals
What is Caller Identity?
Caller identity is a probabilistic determination that caller X calling now is the same person as caller Y who called before.
It's NOT:
- Simple phone number matching
- Authentication (that's separate)
- 100% certain (unless you have biometric proof)
It IS:
- Multi-signal analysis
- Confidence-scored matching
- Temporal pattern recognition
- Behavioral consistency checking
Single-Signal vs. Multi-Signal Identification
Single-signal (naive approach):
SELECT * FROM callers WHERE phone_number = '+14155551234'
Problems:
- Number recycling (old number reassigned to new person)
- Shared numbers (family plans, business lines)
- Spoofing (caller ID manipulation)
- Low confidence
Multi-signal (robust approach):
Signals:
1. Phone number (ANI)
2. External customer ID (from CRM)
3. Recency (called recently = higher confidence)
4. Intent continuity (same unresolved issue)
5. Behavioral patterns (call time, duration)
6. Device fingerprint (if available)
Confidence = weighted_average(all_signals)
Confidence Scoring
Every identity match should have a confidence score (0.0 to 1.0):
Confidence tiers:
- 0.9-1.0: Very high (same phone + customer ID + recent call)
- 0.7-0.9: High (same phone + recent OR same customer ID)
- 0.5-0.7: Medium (same phone, older call)
- 0.3-0.5: Low (weak signal match)
- 0.0-0.3: Very low (treat as new)
Confidence calculation example:
def calculate_confidence(signals):
weights = {
'phone_match': 0.4,
'customer_id_match': 0.3,
'recency': 0.2,
'intent_continuity': 0.1
}
score = 0.0
if signals['phone_matches']:
score += weights['phone_match']
if signals['customer_id_matches']:
score += weights['customer_id_match']
# Recency: higher for recent calls
days_since_last_call = signals['days_since_last_call']
if days_since_last_call <= 1:
score += weights['recency']
elif days_since_last_call <= 7:
score += weights['recency'] * 0.7
elif days_since_last_call <= 30:
score += weights['recency'] * 0.4
# Intent continuity
if signals['has_open_intent']:
score += weights['intent_continuity']
return min(score, 1.0)
Data Model Design
Core Entities
1. Caller (Identity)
CREATE TABLE callers (
caller_id UUID PRIMARY KEY,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
first_seen TIMESTAMP NOT NULL,
last_seen TIMESTAMP NOT NULL,
call_count INTEGER DEFAULT 0,
metadata JSONB -- extensible attributes
);
2. Identity Signal (Phone, Email, Customer ID)
CREATE TABLE identity_signals (
signal_id UUID PRIMARY KEY,
caller_id UUID REFERENCES callers(caller_id),
signal_type VARCHAR(50) NOT NULL, -- 'phone', 'email', 'customer_id'
signal_value VARCHAR(255) NOT NULL,
confidence DECIMAL(3,2) DEFAULT 1.0,
first_seen TIMESTAMP NOT NULL,
last_seen TIMESTAMP NOT NULL,
verified BOOLEAN DEFAULT FALSE,
INDEX idx_signal_lookup (signal_type, signal_value)
);
3. Call (Interaction)
CREATE TABLE calls (
call_id UUID PRIMARY KEY,
caller_id UUID REFERENCES callers(caller_id),
external_call_id VARCHAR(255), -- From phone system
started_at TIMESTAMP NOT NULL,
ended_at TIMESTAMP,
duration_seconds INTEGER,
direction VARCHAR(20), -- 'inbound', 'outbound'
INDEX idx_caller_calls (caller_id, started_at DESC)
);
4. Context (Variables)
CREATE TABLE caller_context (
context_id UUID PRIMARY KEY,
caller_id UUID REFERENCES callers(caller_id),
key VARCHAR(100) NOT NULL,
value TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
expires_at TIMESTAMP, -- TTL
source_call_id UUID REFERENCES calls(call_id),
INDEX idx_caller_context (caller_id, key)
);
5. Intent (Open Issues)
CREATE TABLE open_intents (
intent_id UUID PRIMARY KEY,
caller_id UUID REFERENCES callers(caller_id),
intent_name VARCHAR(100) NOT NULL,
status VARCHAR(50) DEFAULT 'open', -- 'open', 'resolved', 'abandoned'
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
attempt_count INTEGER DEFAULT 1,
last_attempt_call_id UUID REFERENCES calls(call_id),
INDEX idx_caller_intents (caller_id, status)
);
Relationships
Caller (1) → (M) Identity Signals
Caller (1) → (M) Calls
Caller (1) → (M) Caller Context (key-value pairs)
Caller (1) → (M) Open Intents
Call (1) → (1) Caller Context (created during call)
Call (1) → (M) Open Intents (mentioned in call)
NoSQL Alternative (MongoDB)
// Caller document
{
_id: ObjectId("..."),
caller_ref: "cust_abc123",
identity_signals: [
{
type: "phone",
value: "+14155551234",
confidence: 0.95,
first_seen: ISODate("2025-01-01"),
last_seen: ISODate("2025-02-15")
},
{
type: "customer_id",
value: "CUST-12345",
confidence: 1.0,
verified: true
}
],
call_history: [
{
call_id: "call_xyz",
started_at: ISODate("2025-02-15T10:30:00"),
ended_at: ISODate("2025-02-15T10:35:00"),
duration: 300
}
// Keep only recent 10-20 calls in document
],
context: {
last_topic: {
value: "Billing issue about $50 charge",
updated_at: ISODate("2025-02-15"),
ttl: ISODate("2025-03-15")
},
preferred_name: {
value: "Sarah",
updated_at: ISODate("2025-02-15")
}
},
open_intents: [
{
intent: "refund_requested",
status: "open",
created_at: ISODate("2025-02-15"),
attempt_count: 1
}
],
metadata: {
first_call: ISODate("2025-01-01"),
last_call: ISODate("2025-02-15"),
total_calls: 5
}
}
// Indexes
db.callers.createIndex({ "identity_signals.value": 1 })
db.callers.createIndex({ "identity_signals.type": 1, "identity_signals.value": 1 })
db.callers.createIndex({ "metadata.last_call": -1 })
Identity Signal Weighting
Primary Signals
1. Phone Number (ANI)
- Weight: 0.4-0.5
- Reliability: Medium (numbers get recycled, shared, spoofed)
- Storage: E.164 format (+14155551234)
- Considerations:
- Mobile vs landline (mobile more reliable)
- Age of association
- Number recycling detection
2. External Customer ID
- Weight: 0.3-0.4
- Reliability: High (if verified)
- Source: CRM, e-commerce platform, authentication system
- Considerations:
- Must be verified at some point
- Can be passed from authenticated sessions
3. Email Address
- Weight: 0.2-0.3
- Reliability: Medium-high
- Considerations:
- Disposable email detection
- Corporate vs personal domain
- Email verification status
Secondary Signals
4. Recency
- Weight: 0.1-0.2
- Calculation:
recency_score = max(0, 1 - (days_since_last_call / 30)) - Logic: Recent calls = higher confidence same person
5. Intent Continuity
- Weight: 0.05-0.1
- Logic: If calling about same unresolved issue, likely same person
- Example: Customer called yesterday about refund, calling again → probably same person
6. Behavioral Patterns
- Weight: 0.05-0.1
- Signals:
- Typical call time (always calls at 2 PM)
- Call duration pattern
- Preferred language
- Geographic location (area code consistency)
Confidence Calculation
class IdentityMatcher:
def __init__(self):
self.weights = {
'phone': 0.45,
'customer_id': 0.30,
'email': 0.10,
'recency': 0.10,
'intent_continuity': 0.05
}
def calculate_confidence(self, candidate, query):
score = 0.0
# Phone match
if candidate.phone == query.phone:
score += self.weights['phone']
# Customer ID match
if candidate.customer_id and candidate.customer_id == query.customer_id:
score += self.weights['customer_id']
# Email match
if candidate.email and candidate.email == query.email:
score += self.weights['email']
# Recency
days_since = (datetime.now() - candidate.last_call).days
recency_score = max(0, 1 - (days_since / 30))
score += self.weights['recency'] * recency_score
# Intent continuity
if query.open_intents and candidate.open_intents:
if set(query.open_intents) & set(candidate.open_intents):
score += self.weights['intent_continuity']
return min(score, 1.0)
Context Storage Strategy
What to Store
Essential context (always store):
- Last conversation summary
- Open issues/intents
- Completion status of multi-step processes
- Customer preferences (name, contact method)
Optional context (business-specific):
- Product interests
- Support history
- Transaction history
- Agent notes
Never store (privacy/compliance):
- Credit card numbers
- Passwords
- Social security numbers
- Medical diagnoses (unless HIPAA-compliant system)
- Anything not necessary for next call
Structured vs. Unstructured
Structured (key-value):
{
"preferred_name": "Sarah",
"language": "en",
"contact_method": "email",
"account_type": "premium",
"last_purchase_date": "2025-02-10"
}
Pros: Queryable, type-safe, efficient storage Cons: Rigid schema, hard to evolve
Unstructured (free text):
{
"conversation_summary": "Customer called about billing charge of $50 on 2/15. Verified transaction was fraudulent. Processing refund. Customer should see refund in 3-5 business days. Follow-up call scheduled for 2/20 to confirm receipt."
}
Pros: Flexible, human-readable, easy to evolve Cons: Not queryable, larger storage, harder to parse
Recommended: Hybrid approach
{
"structured": {
"issue_type": "billing",
"status": "pending_refund",
"refund_amount": 50.00,
"follow_up_date": "2025-02-20"
},
"unstructured": {
"summary": "Customer called about...",
"agent_notes": "Customer was very polite..."
}
}
Time-to-Live (TTL)
Set appropriate TTLs based on data type:
| Data Type | TTL | Reasoning |
|---|---|---|
| Active issue | 30-90 days | Resolve or go stale |
| Preferences | 1-2 years | Change slowly |
| Conversation summary | 30-60 days | Recent is relevant |
| Temporary notes | 1-7 days | Short-term context |
| Transaction history | Don't expire | Permanent record |
Implementation:
-- SQL with TTL
UPDATE caller_context
SET expires_at = NOW() + INTERVAL '30 days'
WHERE key = 'last_issue';
-- Cleanup job (run daily)
DELETE FROM caller_context
WHERE expires_at < NOW();
// MongoDB with TTL index
db.callers.createIndex(
{ "context.*.ttl": 1 },
{ expireAfterSeconds: 0 }
)
// Set TTL when writing
context: {
last_topic: {
value: "Billing issue",
ttl: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30 days
}
}
Retrieval Patterns
Performance Requirements
Target: < 200ms for real-time call handling
Breakdown:
- Database lookup: < 50ms
- Confidence calculation: < 20ms
- Context assembly: < 30ms
- Network latency: < 100ms
Query Patterns
1. Lookup by Phone Number (most common)
-- Optimized query
SELECT c.caller_id, c.metadata, ctx.key, ctx.value
FROM callers c
JOIN identity_signals s ON c.caller_id = s.caller_id
LEFT JOIN caller_context ctx ON c.caller_id = ctx.caller_id
WHERE s.signal_type = 'phone'
AND s.signal_value = '+14155551234'
AND (ctx.expires_at IS NULL OR ctx.expires_at > NOW())
LIMIT 1;
-- Index required
CREATE INDEX idx_phone_lookup ON identity_signals(signal_type, signal_value);
2. Lookup by Customer ID
SELECT c.*
FROM callers c
JOIN identity_signals s ON c.caller_id = s.caller_id
WHERE s.signal_type = 'customer_id'
AND s.signal_value = 'CUST-12345'
LIMIT 1;
3. Lookup by Multiple Signals (highest confidence)
-- Find best match
SELECT c.caller_id,
COUNT(DISTINCT s.signal_type) as signal_matches
FROM callers c
JOIN identity_signals s ON c.caller_id = s.caller_id
WHERE (s.signal_type = 'phone' AND s.signal_value = '+14155551234')
OR (s.signal_type = 'customer_id' AND s.signal_value = 'CUST-12345')
GROUP BY c.caller_id
ORDER BY signal_matches DESC
LIMIT 1;
Caching Strategy
L1 Cache: In-memory (Redis)
import redis
cache = redis.Redis()
def get_caller_context(phone_number):
# Check cache first
cache_key = f"caller:{phone_number}"
cached = cache.get(cache_key)
if cached:
return json.loads(cached)
# Cache miss - query database
context = query_database(phone_number)
# Cache for 5 minutes
cache.setex(
cache_key,
300, # 5 min TTL
json.dumps(context)
)
return context
When to cache:
- High-traffic scenarios (>100 calls/sec)
- Read-heavy workloads
- Database response time > 50ms
When NOT to cache:
- Low call volume
- Highly dynamic data (changes every call)
- Strict data consistency requirements
Privacy & Compliance
GDPR Compliance
Key requirements:
1. Right to Access Provide API to retrieve all stored data:
def get_user_data(phone_number):
"""
Return all data associated with this phone number
"""
caller = find_caller_by_phone(phone_number)
return {
'caller_id': caller.id,
'identity_signals': caller.signals,
'call_history': caller.calls,
'context': caller.context,
'open_intents': caller.intents
}
2. Right to be Forgotten Implement deletion API:
def delete_user_data(phone_number):
"""
Delete all data for this phone number
GDPR requires completion within 30 days
"""
caller = find_caller_by_phone(phone_number)
# Delete context
delete_caller_context(caller.id)
# Delete intents
delete_open_intents(caller.id)
# Anonymize call history (keep aggregated stats)
anonymize_calls(caller.id)
# Delete identity signals
delete_identity_signals(caller.id)
# Delete caller record
delete_caller(caller.id)
3. Data Minimization Only store what's necessary:
# Bad - storing everything
context = {
'full_transcript': '...', # Not needed
'agent_id': '...', # Not needed
'internal_notes': '...', # Not customer-facing
}
# Good - minimal necessary data
context = {
'summary': 'Customer called about billing',
'status': 'pending_refund',
'follow_up_date': '2025-02-20'
}
CCPA Compliance
Key differences from GDPR:
- Opt-out (vs GDPR opt-in)
- Do Not Sell disclosure
- Financial incentive transparency
Implementation:
# Add opt-out flag
ALTER TABLE callers ADD COLUMN opted_out BOOLEAN DEFAULT FALSE;
# Respect opt-out
def should_use_context(caller):
if caller.opted_out:
return False
if caller.jurisdiction == 'CA' and not caller.consent:
return False
return True
Encryption
At Rest:
# Use database-level encryption
# PostgreSQL: transparent data encryption (TDE)
# MongoDB: encryption at rest
# Or application-level encryption for sensitive fields
from cryptography.fernet import Fernet
key = Fernet.generate_key()
cipher = Fernet(key)
def encrypt_context(data):
return cipher.encrypt(json.dumps(data).encode())
def decrypt_context(encrypted):
return json.loads(cipher.decrypt(encrypted).decode())
In Transit:
- TLS 1.2+ for all API calls
- Certificate pinning for mobile apps
- No unencrypted transmission
Audit Logging
Log all access and modifications:
def log_access(user_id, caller_id, action):
audit_log.insert({
'timestamp': datetime.now(),
'user_id': user_id,
'caller_id': caller_id,
'action': action, # 'read', 'write', 'delete'
'ip_address': request.remote_addr
})
Scaling Considerations
Read vs. Write Patterns
Typical ratio: 10:1 (reads:writes)
- Every call start: 1 read
- Every call end: 1 write
- 1,000 calls/day = 1,000 reads, 100 writes
Optimization:
- Read replicas for call start lookups
- Async writes for call end (can tolerate slight delay)
- Caching for frequently accessed callers
Database Choices
SQL (PostgreSQL, MySQL)
Pros:
- ACID transactions
- Complex queries (JOIN across tables)
- Strong consistency
- Mature tooling
Cons:
- Vertical scaling limits
- Schema changes can be slow
- Sharding complexity
Best for: < 10M callers, need complex queries, strong consistency
NoSQL (MongoDB, DynamoDB)
Pros:
- Horizontal scaling
- Flexible schema
- Fast writes
- Document model fits context storage
Cons:
- Eventual consistency
- Limited JOIN support
- Potentially higher costs at scale
Best for: > 10M callers, global distribution, schema flexibility
Horizontal Scaling
Sharding by caller_id:
def get_shard(caller_id):
# Hash-based sharding
shard_count = 4
return hash(caller_id) % shard_count
def query_caller(caller_id):
shard = get_shard(caller_id)
db = connect_to_shard(shard)
return db.query('SELECT * FROM callers WHERE caller_id = ?', caller_id)
Challenges:
- Cross-shard queries (lookup by phone when don't know caller_id)
- Rebalancing when adding shards
- Consistent hashing
Build vs. Buy Decision
Build Your Own
When to build:
- Unique requirements not met by existing solutions
- Very high call volume (millions/day)
- Existing infrastructure you can leverage
- Strong engineering team available
- Budget for 6-12 months development + ongoing maintenance
Estimated effort:
- Initial build: 3-6 engineer-months
- Ongoing maintenance: 1-2 engineers full-time
- Infrastructure: $5,000-$20,000/month (depending on scale)
Technology stack recommendations:
- Database: PostgreSQL (< 10M callers) or DynamoDB (> 10M)
- Cache: Redis
- API: Python/FastAPI or Node.js/Express
- Deployment: Kubernetes or AWS ECS
Use Sticky Calls API (Managed Solution)
When to use:
- Want to ship fast (days, not months)
- Don't have engineering resources
- Standard use case (contact center caller memory)
- Want guaranteed uptime and support
Cost:
- Free tier: 100 calls
- Paid: ~$0.01-$0.05 per call (volume discounts)
- No infrastructure costs
- No engineering maintenance
Effort:
- Integration: 2-4 hours
- Ongoing maintenance: ~0 hours
Hybrid (Extend Existing CRM)
When to use:
- Already have Salesforce/Zendesk/etc.
- Need basic caller memory only
- Want to leverage existing data
Approach:
- Use CRM's contact/account lookup
- Add custom fields for call context
- Integrate via CRM's API
Pros:
- Reuse existing infrastructure
- Integrated with current workflow
- Familiar to agents
Cons:
- CRM APIs may be slow (200-500ms)
- Limited to CRM's data model
- Potentially expensive (CRM API costs)
Architecture Patterns
Pattern 1: Centralized Caller Memory Service
Contact Center → API Gateway → Caller Memory Service → Database
↓
Cache (Redis)
Best for: Multiple contact center platforms, centralized management
Pros:
- Single source of truth
- Easier to maintain
- Consistent behavior
Cons:
- Single point of failure (mitigate with high availability)
- Network latency
Pattern 2: Event-Driven Context Aggregation
Call Start → Event Bus (Kafka) → Context Aggregator → Database
Call End → ↓
Stream Processor
↓
Real-time Updates
Best for: High volume, async processing acceptable
Pros:
- Handles traffic spikes
- Decoupled components
- Real-time analytics possible
Cons:
- More complex
- Eventual consistency
- Higher infrastructure cost
Pattern 3: Federated Identity with Context Hub
Multiple Systems → Identity Resolution Service → Unified Caller ID
↓ ↓ ↓
Phone System CRM System Context Hub
Best for: Enterprise with multiple systems, need unified view
Pros:
- Leverages existing systems
- Gradual migration possible
- Unified customer view
Cons:
- Complex integration
- Data synchronization challenges
- Higher latency
Common Pitfalls
1. Over-Storing Data
Problem: Storing everything "just in case"
Issues:
- Privacy violations
- Storage costs
- Slower queries
- Compliance complexity
Solution: Define clear data retention policy, delete old data
2. Ignoring Number Recycling
Problem: Phone numbers get reassigned to new people
Solution:
- Decay confidence over time
- Require re-verification after 90 days of inactivity
- Use secondary signals (customer ID, behavior)
3. Not Handling Failures
Problem: API errors block calls
Solution:
- Always provide fallback (treat as new caller)
- Timeouts (3-5 seconds max)
- Circuit breakers for database outages
4. Poor Identity Matching
Problem: False positives (wrong person) or false negatives (miss match)
Solution:
- Confidence thresholds (don't auto-use below 0.7)
- Multi-signal matching
- Regular accuracy audits
Next Steps
Now that you understand caller identity storage architecture:
For planning:
- Build vs. Buy calculator - Estimate costs
- API Reference - If using managed solution
- Understanding Limits - Scale considerations
For implementation:
- Twilio Integration - Platform example
- Amazon Connect Integration - Platform example
- Best Practices - Production tips
For business case:
- Reduce AHT Guide - ROI analysis
- Case Studies - Real implementations
Questions? Contact our architecture team for consultation.
Ready to build? Get API access →