Advanced Topics
Deep dive into design decisions, edge cases, and technical specifications that help you get the most out of Sticky Calls.
customer_ref vs identity_hints
The Question
"Why would I use identity_hints in /calls/end when I already have customer_ref from /calls/start?"
The Answer
TL;DR: Use customer_ref 99% of the time. Use identity_hints only for these special cases:
- Standalone end call - Call ended without calling
/calls/startfirst - Backend-only tracking - Saving context after the call, not during
- Webhook-driven flows - Call data arrives via webhook, no real-time start
Normal Flow (Use customer_ref)
// 1. Call starts - get customer_ref
const startResponse = await fetch('https://api.stickycalls.com/v1/calls/start', {
method: 'POST',
headers: { 'Authorization': `Bearer ${API_KEY}` },
body: JSON.stringify({
call_id: 'call_12345',
identity_hints: {
ani: '+14155551234',
external_ids: { crm_id: 'CRM_789' }
}
})
});
const { customer_ref } = await startResponse.json();
// customer_ref: "cust_abc123"
// 2. Call ends - use customer_ref (RECOMMENDED)
await fetch('https://api.stickycalls.com/v1/calls/end', {
method: 'POST',
headers: { 'Authorization': `Bearer ${API_KEY}` },
body: JSON.stringify({
call_id: 'call_12345',
customer_ref: customer_ref, // Use this from /calls/start
intent: 'billing_inquiry',
intent_status: 'resolved'
})
});
Special Case 1: Standalone End (Use identity_hints)
Scenario: Call ended unexpectedly, or you're saving context after the call
// Call dropped before you could call /calls/start
// Or you're batch-processing call data later
await fetch('https://api.stickycalls.com/v1/calls/end', {
method: 'POST',
headers: { 'Authorization': `Bearer ${API_KEY}` },
body: JSON.stringify({
call_id: 'call_12345',
identity_hints: { // Provide hints since you don't have customer_ref
ani: '+14155551234',
external_ids: { crm_id: 'CRM_789' }
},
intent: 'billing_inquiry',
intent_status: 'abandoned'
})
});
What happens: API will match or create customer_ref using identity_hints
Special Case 2: Backend-Only Tracking
Scenario: Saving call context hours after the call ended
// Your backend processes call recordings overnight
// and extracts intents/variables
async function processCallRecording(recording) {
// Extract data from recording
const { phoneNumber, intent, outcome } = await analyzeRecording(recording);
// Save to Sticky Calls (no real-time /calls/start)
await fetch('https://api.stickycalls.com/v1/calls/end', {
method: 'POST',
headers: { 'Authorization': `Bearer ${API_KEY}` },
body: JSON.stringify({
call_id: recording.callId,
identity_hints: { ani: phoneNumber },
intent: intent,
intent_status: outcome
})
});
}
Special Case 3: Webhook-Driven Flow
Scenario: Your contact center platform sends webhook when call ends
// Webhook handler
app.post('/webhooks/call-ended', async (req, res) => {
const { callId, phoneNumber, disposition } = req.body;
// You never called /calls/start (no customer_ref)
await fetch('https://api.stickycalls.com/v1/calls/end', {
method: 'POST',
headers: { 'Authorization': `Bearer ${API_KEY}` },
body: JSON.stringify({
call_id: callId,
identity_hints: { ani: phoneNumber },
intent: disposition,
intent_status: 'resolved'
})
});
res.sendStatus(200);
});
Decision Tree
Do you have customer_ref from /calls/start?
├─ YES → Use customer_ref (99% of cases)
└─ NO → Use identity_hints (special cases)
├─ Standalone end call
├─ Backend batch processing
└─ Webhook-driven flows
Simple vs Advanced Forms
The Question
"Can I mix simple and advanced parameters? When should I use which?"
The Answer
No, you cannot mix them. Choose one mode per request:
| Mode | When To Use | Parameters |
|---|---|---|
| Simple | 90% of use cases - single intent per call | intent, intent_status |
| Advanced | Complex scenarios - multiple intents | intent_updates array |
Simple Mode (Recommended for Most Cases)
When to use:
- Call has one primary intent (e.g., "billing inquiry")
- Straightforward conversation flow
- Standard contact center operations
Example:
{
"call_id": "call_12345",
"customer_ref": "cust_abc123",
"intent": "billing_inquiry",
"intent_status": "resolved",
"variables": {
"invoice_number": "INV-2026-001",
"amount_paid": "49.99"
}
}
Advanced Mode (Multiple Intents)
When to use:
- Call covers multiple topics (e.g., "billing inquiry" → "account update" → "product question")
- Need to track intent progression
- Complex IVR flows with branching
Example:
{
"call_id": "call_12345",
"customer_ref": "cust_abc123",
"intent_updates": [
{
"intent": "billing_inquiry",
"status": "resolved"
},
{
"intent": "account_update",
"status": "resolved"
},
{
"intent": "product_question",
"status": "open" // Still pending
}
],
"variable_updates": {
"invoice_number": {
"value": "INV-2026-001",
"ttl_seconds": 2592000 // 30 days
}
}
}
Cannot Mix Modes
This will fail (400 error):
{
"intent": "billing_inquiry", // Simple mode
"intent_updates": [ // Advanced mode
{ "intent": "another_intent" }
]
}
// ERROR: Cannot use both simple and advanced forms
Decision Guide
Use Simple Mode If:
- ✅ One primary intent per call
- ✅ Standard TTL is acceptable (30 days)
- ✅ Simple variable storage
Use Advanced Mode If:
- ✅ Multiple intents per call
- ✅ Need custom TTL per variable
- ✅ Need to track intent progression
Variable Constraints
Complete Specifications
| Constraint | Limit | What Happens If Exceeded |
|---|---|---|
| Max variables per customer | 100 | Oldest variables auto-deleted |
| Max variable key length | 128 characters | 400 Bad Request error |
| Max variable value length | 1,024 characters | 400 Bad Request error |
| Max total payload size | 100 KB | 400 Bad Request error |
| Reserved key names | None | All valid (except empty string) |
| Allowed characters in keys | Alphanumeric, _, -, . | Other characters: 400 error |
Variable Key Rules
Valid Keys:
{
"customer_name": "John Doe", // ✅ Alphanumeric + underscore
"account-id": "ACC-123", // ✅ Alphanumeric + hyphen
"invoice.number": "INV-001", // ✅ Alphanumeric + dot
"preferred_language": "en-US" // ✅ All combined
}
Invalid Keys:
{
"customer name": "...", // ❌ Space not allowed
"email@address": "...", // ❌ @ symbol not allowed
"price$amount": "...", // ❌ $ symbol not allowed
"": "...", // ❌ Empty string not allowed
}
Variable Value Rules
Accepted Types:
- ✅ Strings (any content, up to 1024 chars)
- ✅ Numbers (converted to strings internally)
- ✅ Booleans (converted to "true"/"false")
- ❌ Objects/Arrays (not supported - flatten first)
Examples:
{
"order_total": "149.99", // ✅ String
"items_purchased": "3", // ✅ Number as string
"is_vip": "true", // ✅ Boolean as string
"shipping_address": { // ❌ Object not supported
"street": "123 Main St"
}
}
Workaround for Complex Data:
// Option 1: Flatten
{
"shipping_street": "123 Main St",
"shipping_city": "San Francisco",
"shipping_state": "CA"
}
// Option 2: JSON stringify (if under 1024 chars)
{
"shipping_address": "{\"street\":\"123 Main St\",\"city\":\"SF\"}"
}
What Happens at Limits
Too Many Variables (>100)
Behavior: Oldest variables are automatically deleted (FIFO)
// Customer already has 100 variables
// Add a new one:
variables.new_variable = "value";
// Result: Oldest variable deleted, new one added
// Total remains 100
Value Too Long (>1024 chars)
Behavior: 400 Bad Request error
{
"error": "Bad Request",
"message": "Variable value exceeds 1024 character limit",
"details": {
"issues": [{
"path": ["variables", "long_description"],
"message": "Value length: 1500 (max: 1024)"
}]
}
}
Solution: Truncate or split the value
// Option 1: Truncate
const longValue = "...very long text...";
variables.description = longValue.substring(0, 1024);
// Option 2: Split into multiple variables
variables.description_part1 = longValue.substring(0, 1024);
variables.description_part2 = longValue.substring(1024, 2048);
TTL (Time-To-Live) Behavior
The Question
"What happens when variables expire? Can I recover them?"
The Answer
Expired variables are permanently deleted. No recovery possible.
TTL Specifications
| Setting | Value |
|---|---|
| Default TTL | 30 days (if not specified) |
| Minimum TTL | 1 hour (3,600 seconds) |
| Maximum TTL | 90 days (7,776,000 seconds) |
| TTL resets on update? | Yes - every update resets the clock |
| Can extend TTL? | Yes - update variable with new TTL |
| Can recover expired? | No - permanent deletion |
How TTL Works
Setting TTL (Advanced Mode):
{
"customer_ref": "cust_abc123",
"call_id": "call_12345",
"variable_updates": {
"invoice_number": {
"value": "INV-2026-001",
"ttl_seconds": 2592000 // 30 days
},
"temporary_note": {
"value": "Call back tomorrow",
"ttl_seconds": 86400 // 1 day
}
}
}
What Happens at Expiration:
T+0: Variable created with 30-day TTL
T+29: Variable still available
T+30: Variable expires → hard deleted from database
T+31: Variable not returned in /calls/start response
No warning, no archival, no recovery.
TTL Reset on Update
Every update resets the TTL:
// Day 1: Create variable with 30-day TTL
variables.customer_preference = {
value: "email",
ttl_seconds: 2592000 // 30 days
};
// Day 20: Update the variable
variables.customer_preference = {
value: "phone", // Changed value
ttl_seconds: 2592000 // TTL resets - expires in 30 days from now (Day 50)
};
Result: Variable expires on Day 50, not Day 31
Extending TTL
// Current: variable expires in 5 days
// Extend TTL to 30 days from now
variables.important_data = {
value: currentValue, // Keep same value
ttl_seconds: 2592000 // New 30-day TTL
};
Recommended TTL Values
| Use Case | TTL | Rationale |
|---|---|---|
| Open intents | 7-14 days | Follow-ups typically happen within 2 weeks |
| Order information | 30-60 days | Customer may call about recent orders |
| Account preferences | 90 days | Long-term settings, rarely change |
| Temporary notes | 1-3 days | One-time callback reminders |
| VIP status | 90 days | Persistent, update as needed |
Checking Variable Age
Variables don't include creation timestamp in API responses. Track this locally if needed:
// When saving variable, also save timestamp
const now = new Date().toISOString();
variables.last_order_date = "2026-01-15";
variables.last_order_saved_at = now; // Your own tracking
Confidence Score Calculation
The Question
"How is the confidence score calculated? What affects it?"
The Algorithm
Confidence scores range from 0.0 (no match) to 1.0 (certain match) and are calculated using weighted signals:
| Signal | Weight | Description |
|---|---|---|
| Mobile ANI match | +0.5 | Mobile phone number previously seen |
| Landline ANI match | +0.3 | Landline number previously seen |
| VoIP ANI match | +0.2 | VoIP number (less reliable) |
| External ID match | +0.4 | CRM ID, account number, etc. |
| Recent call (≤1 day) | +0.1 | Called within last 24 hours |
| Open intent continuity | +0.1 | Has unresolved intent from previous call |
| DNIS match | +0.05 | Called same number as before |
Maximum score: 1.0 (capped even if weights exceed)
Confidence Levels
| Score | Level | Recommendation | Action |
|---|---|---|---|
| ≥0.5 | High | reuse | Use customer context immediately |
| 0.3-0.5 | Medium | confirm | Ask for verification (last 4 of account #) |
| <0.3 | Low | ignore | Treat as new customer |
Example Calculations
Example 1: Returning Customer, Mobile Phone
Signals:
- Mobile ANI match: +0.5
- Recent call (yesterday): +0.1
- Open intent (billing): +0.1
- DNIS match: +0.05
Total: 0.75 → "high" confidence → Recommendation: "reuse"
Response:
{
"identity": {
"confidence": 0.75,
"level": "high",
"sources": ["ani:mobile", "recency:1day", "open_intent", "dnis"],
"recommendation": "reuse"
}
}
Example 2: New Customer
Signals:
- No ANI match: 0
- No external IDs: 0
- No previous calls: 0
Total: 0.0 → "low" confidence → Recommendation: "ignore"
Response:
{
"identity": {
"confidence": 0.0,
"level": "none",
"sources": ["ani:unknown"],
"recommendation": "ignore"
}
}
Example 3: VoIP + CRM ID Match
Signals:
- VoIP ANI match: +0.2
- External ID (CRM) match: +0.4
Total: 0.6 → "high" confidence → Recommendation: "reuse"
Response:
{
"identity": {
"confidence": 0.6,
"level": "high",
"sources": ["ani:voip", "external_id:crm_id"],
"recommendation": "reuse"
}
}
Optimizing Confidence
To get higher confidence scores:
-
Provide external IDs (CRM, account number)
identity_hints: {
ani: "+14155551234",
external_ids: {
crm_id: "CRM_789",
account_number: "ACC_456"
}
} -
Include telco metadata (when available)
telco: {
line_type: "mobile" // Higher confidence than VoIP
} -
Use consistent DNIS (call same number repeatedly)
-
Save open intents (continuity signal)
PII Redaction Details
The Question
"What qualifies as PII? Does this affect saved variables?"
The Answer
PII redaction only applies to server-side logs, not your data.
What Is Redacted
| Data Type | Server Logs | Database (variables) | API Responses |
|---|---|---|---|
| Phone numbers (ANI/DNIS) | ✅ Redacted | ❌ Stored as-is | ❌ Returned as-is |
| Email addresses | ✅ Redacted | ❌ Stored as-is | ❌ Returned as-is |
| Names | ✅ Redacted | ❌ Stored as-is | ❌ Returned as-is |
| Account numbers | ✅ Redacted | ❌ Stored as-is | ❌ Returned as-is |
| Custom variables | ✅ Redacted | ❌ Stored as-is | ❌ Returned as-is |
Where Redaction Applies
Server-side logs only:
- Cloud Logging entries
- Error reports
- Debug traces
- Audit logs
Example log output:
INFO: Call started for ANI [REDACTED] to DNIS [REDACTED]
INFO: Customer context loaded: customer_ref=cust_abc123 variables=[REDACTED]
Where Redaction Does NOT Apply
Your data remains unredacted:
- Variables object - Stored exactly as you send it
- API responses - Full data returned
- Database - Raw data persisted
- Your application - You receive unredacted data
Example API response (NOT redacted):
{
"customer_ref": "cust_abc123",
"variables": {
"customer_name": "John Doe",
"email": "john@example.com",
"phone": "+14155551234"
}
}
Why Logs Are Redacted
Compliance: GDPR/CCPA require limiting PII in logs
Security: Prevents PII exposure in:
- Support tickets
- Error reports shared with vendors
- Debug sessions
- Log analysis tools
You Control Variable Storage
You decide what to save:
// Option 1: Store full details
variables.customer_name = "John Doe";
variables.customer_email = "john@example.com";
// Option 2: Store references only (more private)
variables.customer_id = "CUST_789"; // Reference to your CRM
// Option 3: Store minimal data
variables.name_first_letter = "J";
variables.email_domain = "example.com";
Can You Opt Out?
No. Log redaction is mandatory for compliance. But you can:
- Store whatever you want in variables
- Receive unredacted API responses
- Query your database directly
Data Retention Policy
Storage Duration
| Data Type | Retention Period | Deletion Process |
|---|---|---|
| Customer contexts | Until TTL expires (30-90 days default) | Auto-deleted at TTL expiration |
| Variables | Until TTL expires | Auto-deleted at TTL expiration |
| Open intents | Until marked resolved or 90 days | Auto-deleted |
| Usage events | 90 days | Auto-deleted (rolling window) |
| API keys | Until revoked/deleted | Manual deletion via dashboard |
| Billing records | 7 years | Required by law (tax/accounting) |
| Account data | Active + 30 days after deletion request | Manual deletion via support |
Geographic Storage
Primary Region: us-central1 (Iowa, USA)
- All production data
- PostgreSQL database (Cloud SQL)
Backup Region: us-east1 (South Carolina, USA)
- Automated backups (daily)
- Disaster recovery
No cross-region replication - Data stays within USA
GDPR Right to Erasure
Process: Manual (email-based)
Timeline: 30 days from request
What gets deleted:
- All customer_ref records
- All variables associated with customer
- All open intents
- All usage events (tied to customer_ref)
What persists (anonymized):
- Billing records (legal requirement - 7 years)
- Aggregated usage statistics (no PII)
- API key metadata (no customer link)
How to request:
- Email nate@bananaintelligence.ai
- Include: customer_ref(s) to delete
- Receive confirmation within 30 days
Future: Self-service deletion API (planned)
Data Minimization
Best practice: Store only what you need
// Instead of:
variables.customer_full_name = "John Doe";
variables.customer_email = "john@example.com";
variables.customer_ssn = "123-45-6789"; // ❌ Don't store sensitive data
// Store references:
variables.customer_id = "CRM_789"; // ✅ Reference to your system
variables.vip_tier = "gold"; // ✅ Derived attribute
Idempotency Key Behavior
The Question
"What's the exact behavior when retrying with the same idempotency key?"
The Answer
Idempotency keys allow safe retries for 24 hours without duplicate processing.
How It Works
First Request (with idempotency key):
POST /v1/calls/end
X-Idempotency-Key: idem_abc123
Response: HTTP 200 OK
{
"customer_ref": "cust_xyz",
"intents_updated": 1,
"variables_updated": 2
}
Retry (same idempotency key within 24h):
POST /v1/calls/end
X-Idempotency-Key: idem_abc123 # Same key
Response: HTTP 200 OK
{
"customer_ref": "cust_xyz",
"intents_updated": 1,
"variables_updated": 2
}
Result: Cached response returned, no duplicate processing
Key Details
| Aspect | Behavior |
|---|---|
| Cache duration | 24 hours |
| Response | Exact same response (cached) |
| HTTP status | 200 OK (success) |
| Processing | Skipped (not executed twice) |
| Charging | First request only (no duplicate charge) |
| After 24h | Key expires, treated as new request |
When To Use
Use idempotency keys for:
/v1/calls/end(recommended)- Network retry logic
- Webhook processing
- Batch operations
Not needed for:
/v1/calls/start(read-only operations safe to retry)/v1/health(always safe)
Example Usage
import { v4 as uuidv4 } from 'uuid';
async function endCallWithRetry(callData) {
const idempotencyKey = uuidv4();
let attempts = 0;
const maxAttempts = 3;
while (attempts < maxAttempts) {
try {
const response = await fetch('https://api.stickycalls.com/v1/calls/end', {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'X-Idempotency-Key': idempotencyKey, // Same key for all retries
'Content-Type': 'application/json'
},
body: JSON.stringify(callData)
});
if (response.ok) {
return await response.json();
}
// Handle errors
if (response.status === 500) {
attempts++;
await sleep(Math.pow(2, attempts) * 1000);
continue; // Retry with same idempotency key
}
throw new Error(`HTTP ${response.status}`);
} catch (error) {
if (attempts === maxAttempts - 1) throw error;
attempts++;
await sleep(Math.pow(2, attempts) * 1000);
}
}
}
What If Request Body Changes?
Scenario: Retry with same idempotency key but different body
// First request
POST /v1/calls/end
X-Idempotency-Key: idem_abc123
Body: { intent: "billing_inquiry", intent_status: "resolved" }
// Retry with DIFFERENT body
POST /v1/calls/end
X-Idempotency-Key: idem_abc123
Body: { intent: "account_update", intent_status: "pending" }
Result: Cached response returned (from first request), body ignored
Best practice: Generate new idempotency key if you intentionally change the request
External IDs Format
Specifications
| Constraint | Limit |
|---|---|
| Max external IDs per call | 10 |
| Key format | Alphanumeric + underscore ([a-zA-Z0-9_]) |
| Value format | String only (no arrays/objects) |
| Max key length | 64 characters |
| Max value length | 128 characters |
| Case-sensitive matching | Yes |
| Encoding | UTF-8 |
Valid Examples
external_ids: {
crm_id: "CRM_123456",
account_number: "ACC-789",
user_id: "USER_abc",
order_id: "ORD-2026-001",
customer_uuid: "550e8400-e29b-41d4-a716-446655440000"
}
Invalid Examples
external_ids: {
"customer id": "...", // ❌ Space in key
"email@address": "...", // ❌ @ symbol in key
"account#": "...", // ❌ # symbol in key
"id": ["123", "456"], // ❌ Array value
"data": { "nested": "..." } // ❌ Object value
}
Matching Behavior
Case-sensitive:
// These are DIFFERENT:
{ crm_id: "CRM_123" }
{ CRM_ID: "CRM_123" }
{ crm_id: "crm_123" }
Exact match required:
// Stored:
{ account_number: "ACC-123" }
// Matches:
{ account_number: "ACC-123" } // ✅
// Does NOT match:
{ account_number: "acc-123" } // ❌ Case mismatch
{ account_number: "ACC123" } // ❌ Hyphen missing
Recommended ID Types
| ID Type | Key Name | Example Value | Notes |
|---|---|---|---|
| CRM ID | crm_id | CRM_123456 | Most reliable |
| Account # | account_number | ACC-789 | Good for existing customers |
| User UUID | user_uuid | 550e8400-... | Universal identifier |
| Order ID | order_id | ORD-2026-001 | For order-related calls |
| Email hash | email_hash | 5d41402a... | If email not in CRM |
Multiple IDs Strategy
Provide multiple for higher confidence:
identity_hints: {
ani: "+14155551234",
external_ids: {
crm_id: "CRM_123",
account_number: "ACC-789",
user_uuid: "550e8400-..."
}
}
Matching logic: ANY match triggers identification
Pagination Support
Current Limitations
No pagination is currently supported for these responses:
| Endpoint | Field | Max Items | Behavior When Exceeded |
|---|---|---|---|
/v1/calls/start | open_intents | 100 | Oldest items dropped (FIFO) |
/v1/calls/start | variables | 100 | Oldest items dropped (FIFO) |
What This Means
If a customer has more than 100 open intents or variables:
- Only most recent 100 returned
- Oldest items automatically deleted
- No way to retrieve full history
Current Behavior
// Customer has 150 variables
GET /v1/calls/start
Response:
{
"variables": {
// Only most recent 100 variables
// 50 oldest variables dropped
}
}
Workarounds
1. Store Large Data Externally
// Instead of 100+ variables in Sticky Calls:
variables.data_reference = "S3://bucket/customer_123.json";
// Retrieve from your storage:
const fullData = await s3.getObject({
Bucket: 'bucket',
Key: 'customer_123.json'
});
2. Expire Old Variables
// Set short TTL for temporary data
variable_updates: {
temp_note: {
value: "Callback scheduled",
ttl_seconds: 86400 // 1 day - auto-deleted
}
}
3. Archive Resolved Intents
// Mark intents as resolved to remove from open_intents
intent_updates: [
{
intent: "old_issue",
status: "resolved" // Removed from open_intents list
}
]
Future Plans
Pagination may be added in future versions. Subscribe to updates at stickycalls.com.
Related Documentation
- Understanding Limits - Credits vs rate limits
- Error Handling - Complete error reference
- API Reference - Endpoint documentation
- Best Practices - Production patterns
Need Help?
Questions about edge cases or advanced usage?
- Documentation: docs.stickycalls.com
- Dashboard: stickycalls.com/dashboard
- Support: nate@bananaintelligence.ai