Skip to main content

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 TypeTTLReasoning
Active issue30-90 daysResolve or go stale
Preferences1-2 yearsChange slowly
Conversation summary30-60 daysRecent is relevant
Temporary notes1-7 daysShort-term context
Transaction historyDon't expirePermanent 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:

For implementation:

For business case:


Questions? Contact our architecture team for consultation.

Ready to build? Get API access →